feat: implement API key authentication and user session management

This commit is contained in:
nirholas
2026-03-31 12:43:05 +00:00
parent da6c5e1ed7
commit 3a854557e0
145 changed files with 34693 additions and 690 deletions
+635
View File
@@ -0,0 +1,635 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "@anthropic-ai/claude-code",
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
"@commander-js/extra-typings": "^13.1.0",
"@growthbook/growthbook": "^1.4.0",
"@modelcontextprotocol/sdk": "^1.12.1",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.57.0",
"@opentelemetry/core": "^1.30.0",
"@opentelemetry/sdk-logs": "^0.57.0",
"@opentelemetry/sdk-metrics": "^1.30.0",
"@opentelemetry/sdk-trace-base": "^1.30.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-unicode11": "^0.8.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"auto-bind": "^5.0.1",
"axios": "^1.7.0",
"chalk": "^5.4.0",
"chokidar": "^4.0.0",
"cli-boxes": "^3.0.0",
"code-excerpt": "^4.0.0",
"diff": "^7.0.0",
"execa": "^9.5.0",
"figures": "^6.1.0",
"fuse.js": "^7.0.0",
"highlight.js": "^11.11.0",
"ignore": "^6.0.0",
"lodash-es": "^4.17.21",
"marked": "^15.0.0",
"node-pty": "^1.1.0",
"p-map": "^7.0.0",
"picomatch": "^4.0.0",
"proper-lockfile": "^4.1.2",
"qrcode": "^1.5.0",
"react": "^19.0.0",
"react-reconciler": "^0.31.0",
"semver": "^7.6.0",
"stack-utils": "^2.0.6",
"strip-ansi": "^7.1.0",
"supports-hyperlinks": "^3.1.0",
"tree-kill": "^1.2.2",
"type-fest": "^4.30.0",
"undici": "^7.3.0",
"usehooks-ts": "^3.1.0",
"wrap-ansi": "^9.0.0",
"ws": "^8.18.0",
"yaml": "^2.6.0",
"zod": "^3.24.0",
},
"devDependencies": {
"@biomejs/biome": "^1.9.0",
"@types/diff": "^7.0.0",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.10.0",
"@types/picomatch": "^3.0.0",
"@types/proper-lockfile": "^4.1.4",
"@types/react": "^19.0.0",
"@types/semver": "^7.5.8",
"@types/stack-utils": "^2.0.3",
"@types/ws": "^8.5.0",
"esbuild": "^0.25.0",
"typescript": "^5.7.0",
},
},
},
"packages": {
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.39.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg=="],
"@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@1.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@1.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="],
"@commander-js/extra-typings": ["@commander-js/extra-typings@13.1.0", "", { "peerDependencies": { "commander": "~13.1.0" } }, "sha512-q5P52BYb1hwVWE6dtID7VvuJWrlfbCv4klj7BjUUOqMz4jbSZD4C9fJ9lRjL2jnBGTg+gDDlaXN51rkWcLk4fg=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@growthbook/growthbook": ["@growthbook/growthbook@1.6.5", "", { "dependencies": { "dom-mutator": "^0.6.0" } }, "sha512-mUaMsgeUTpRIUOTn33EUXHRK6j7pxBjwqH4WpQyq+pukjd1AIzWlEa6w7i6bInJUcweGgP2beXZmaP6b6UPn7A=="],
"@hono/node-server": ["@hono/node-server@1.19.12", "", { "peerDependencies": { "hono": "^4" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
"@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.57.2", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A=="],
"@opentelemetry/core": ["@opentelemetry/core@1.30.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ=="],
"@opentelemetry/resources": ["@opentelemetry/resources@1.30.1", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA=="],
"@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.57.2", "", { "dependencies": { "@opentelemetry/api-logs": "0.57.2", "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-TXFHJ5c+BKggWbdEQ/inpgIzEmS2BGQowLE9UhsMd7YYlUfBQJ4uax0VF/B5NYigdM/75OoJGhAV3upEhK+3gg=="],
"@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@1.30.1", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-q9zcZ0Okl8jRgmy7eNW3Ku1XSgg3sDLa5evHZpCwjspw7E8Is4K/haRPDJrBcX3YSn/Y7gUvFnByNYEKQNbNog=="],
"@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@1.30.1", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1", "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg=="],
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
"@types/diff": ["@types/diff@7.0.2", "", {}, "sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q=="],
"@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="],
"@types/lodash-es": ["@types/lodash-es@4.17.12", "", { "dependencies": { "@types/lodash": "*" } }, "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ=="],
"@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
"@types/picomatch": ["@types/picomatch@3.0.2", "", {}, "sha512-n0i8TD3UDB7paoMMxA3Y65vUncFJXjcUf7lQY7YyKGl6031FNjfsLs6pdLFCy2GNFxItPJG8GvvpbZc2skH7WA=="],
"@types/proper-lockfile": ["@types/proper-lockfile@4.1.4", "", { "dependencies": { "@types/retry": "*" } }, "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/retry": ["@types/retry@0.12.5", "", {}, "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw=="],
"@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="],
"@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@xterm/addon-fit": ["@xterm/addon-fit@0.10.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ=="],
"@xterm/addon-search": ["@xterm/addon-search@0.15.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg=="],
"@xterm/addon-unicode11": ["@xterm/addon-unicode11@0.8.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-LxinXu8SC4OmVa6FhgwsVCBZbr8WoSGzBl2+vqe8WcQ6hb1r6Gj9P99qTNdPiFPh4Ceiu2pC8xukZ6+2nnh49Q=="],
"@xterm/addon-web-links": ["@xterm/addon-web-links@0.11.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q=="],
"@xterm/addon-webgl": ["@xterm/addon-webgl@0.18.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-xCnfMBTI+/HKPdRnSOHaJDRqEpq2Ugy8LEj9GiY4J3zJObo3joylIFaMvzBwbYRg8zLtkO0KQaStCeSfoaI2/w=="],
"@xterm/xterm": ["@xterm/xterm@5.5.0", "", {}, "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" }, "peerDependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="],
"axios": ["axios@1.14.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
"cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
"code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="],
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"diff": ["diff@7.0.0", "", {}, "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw=="],
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
"dom-mutator": ["dom-mutator@0.6.0", "", {}, "sha512-iCt9o0aYfXMUkz/43ZOAUFQYotjGB+GNbYJiJdz4TgXkyToXbbRy5S6FbTp72lRBtfpUMwEc1KmpFEU4CZeoNg=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": "bin/esbuild" }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
"execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"express-rate-limit": ["express-rate-limit@8.3.2", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="],
"formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"fuse.js": ["fuse.js@7.1.0", "", {}, "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
"hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"ignore": ["ignore@6.0.2", "", {}, "sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"lodash-es": ["lodash-es@4.17.23", "", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="],
"lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
"marked": ["marked@15.0.12", "", { "bin": "bin/marked.js" }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="],
"npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="],
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
"parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-to-regexp": ["path-to-regexp@8.4.1", "", {}, "sha512-fvU78fIjZ+SBM9YwCknCvKOUKkLVqtWDVctl0s7xIqfmfb38t2TT4ZU2gHm+Z8xGwgW+QWEU3oQSAzIbo89Ggw=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
"proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": "bin/qrcode" }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
"qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-reconciler": ["react-reconciler@0.31.0", "", { "dependencies": { "scheduler": "^0.25.0" }, "peerDependencies": { "react": "^19.0.0" } }, "sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"scheduler": ["scheduler@0.25.0", "", {}, "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="],
"semver": ["semver@7.7.4", "", { "bin": "bin/semver.js" }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"supports-hyperlinks": ["supports-hyperlinks@3.2.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"tree-kill": ["tree-kill@1.2.2", "", { "bin": "cli.js" }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
"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=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici": ["undici@7.24.6", "", {}, "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"usehooks-ts": ["usehooks-ts@3.1.1", "", { "dependencies": { "lodash.debounce": "^4.0.8" }, "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
"yaml": ["yaml@2.8.3", "", { "bin": "bin.mjs" }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
"yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"@anthropic-ai/sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
}
}
+188
View File
@@ -0,0 +1,188 @@
import { Router } from "express";
import { join } from "path";
import { readFileSync } from "fs";
import type { Request, Response } from "express";
import type { AuthenticatedRequest } from "./auth/adapter.js";
import type { SessionManager } from "./session-manager.js";
import type { UserStore } from "./user-store.js";
/**
* Admin dashboard routes.
*
* All routes under /admin require the requesting user to have isAdmin = true.
* The caller (pty-server.ts) is responsible for applying the auth middleware
* before mounting this router.
*/
function requireAdmin(req: Request, res: Response, next: () => void): void {
const user = (req as AuthenticatedRequest).user;
if (!user?.isAdmin) {
res.status(403).json({ error: "Forbidden" });
return;
}
next();
}
export function createAdminRouter(
sessionManager: SessionManager,
userStore: UserStore,
): Router {
const router = Router();
// All admin routes require admin role.
router.use(requireAdmin);
// ── Dashboard UI ──────────────────────────────────────────────────────────
router.get("/", (_req, res) => {
try {
const p = join(import.meta.dirname, "public/admin.html");
res.setHeader("Content-Type", "text/html");
res.send(readFileSync(p, "utf8"));
} catch {
res.setHeader("Content-Type", "text/html");
res.send(INLINE_ADMIN_HTML);
}
});
// ── API: all active sessions ──────────────────────────────────────────────
/**
* GET /admin/sessions
* Returns all active sessions across all users.
*/
router.get("/sessions", (_req, res) => {
const sessions = sessionManager.getAllSessions().map((s) => ({
id: s.token,
userId: s.userId,
createdAt: s.created,
ageMs: Date.now() - new Date(s.created).getTime(),
alive: s.alive,
}));
res.json({ sessions });
});
// ── API: all connected users ──────────────────────────────────────────────
/**
* GET /admin/users
* Returns all users that currently have at least one active session.
*/
router.get("/users", (_req, res) => {
res.json({ users: userStore.list() });
});
// ── API: force-kill a session ─────────────────────────────────────────────
/**
* DELETE /admin/sessions/:token
* Force-kills the specified session regardless of which user owns it.
*/
router.delete("/sessions/:token", (req, res) => {
const { token } = req.params;
const session = sessionManager.getSession(token);
if (!session) {
res.status(404).json({ error: "Session not found" });
return;
}
sessionManager.destroySession(token);
res.json({ ok: true, destroyed: token });
});
return router;
}
// ── Inline admin dashboard HTML ───────────────────────────────────────────────
const INLINE_ADMIN_HTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Claude Code — Admin</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #0d1117; color: #e6edf3; padding: 2rem; }
h1 { font-size: 1.5rem; margin-bottom: 0.25rem; }
.subtitle { color: #8b949e; font-size: 0.875rem; margin-bottom: 2rem; }
h2 { font-size: 1rem; margin: 1.5rem 0 0.75rem; color: #8b949e; text-transform: uppercase; letter-spacing: .05em; }
table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
th { text-align: left; padding: 0.4rem 0.75rem; border-bottom: 1px solid #21262d; color: #8b949e; font-weight: 500; }
td { padding: 0.4rem 0.75rem; border-bottom: 1px solid #161b22; }
tr:hover td { background: #161b22; }
button.kill {
background: #da3633; border: 1px solid #f85149; color: #fff;
padding: 0.2rem 0.6rem; border-radius: 4px; cursor: pointer; font-size: 0.8rem;
}
button.kill:hover { background: #f85149; }
.badge {
display: inline-block; padding: 0.15rem 0.5rem; border-radius: 9999px;
font-size: 0.75rem; background: #21262d; color: #8b949e;
}
.refresh { float: right; background: #21262d; border: 1px solid #30363d; color: #8b949e;
padding: 0.3rem 0.75rem; border-radius: 6px; cursor: pointer; font-size: 0.8rem; }
.refresh:hover { color: #e6edf3; }
#msg { margin-top: 1rem; font-size: 0.875rem; color: #3fb950; min-height: 1.2em; }
</style>
</head>
<body>
<h1>Admin Dashboard</h1>
<p class="subtitle">Claude Code — multi-user session management</p>
<button class="refresh" onclick="load()">&#8635; Refresh</button>
<h2>Connected Users</h2>
<table id="users-table">
<thead><tr><th>User ID</th><th>Email / Name</th><th>Sessions</th><th>First seen</th></tr></thead>
<tbody id="users-body"><tr><td colspan="4">Loading…</td></tr></tbody>
</table>
<h2>Active Sessions</h2>
<table id="sessions-table">
<thead><tr><th>Session ID</th><th>User ID</th><th>Age</th><th>Action</th></tr></thead>
<tbody id="sessions-body"><tr><td colspan="4">Loading…</td></tr></tbody>
</table>
<div id="msg"></div>
<script>
const msg = document.getElementById('msg');
function fmt(ms) {
if (ms < 60000) return Math.round(ms/1000) + 's';
if (ms < 3600000) return Math.round(ms/60000) + 'm';
return Math.round(ms/3600000) + 'h';
}
async function load() {
const [{ users }, { sessions }] = await Promise.all([
fetch('/admin/users').then(r => r.json()),
fetch('/admin/sessions').then(r => r.json()),
]);
const ub = document.getElementById('users-body');
ub.innerHTML = users.length === 0 ? '<tr><td colspan="4">No connected users</td></tr>' :
users.map(u => \`<tr>
<td><code>\${u.id}</code></td>
<td>\${u.email || u.name || '—'}</td>
<td><span class="badge">\${u.sessionCount}</span></td>
<td>\${new Date(u.firstSeenAt).toLocaleTimeString()}</td>
</tr>\`).join('');
const sb = document.getElementById('sessions-body');
sb.innerHTML = sessions.length === 0 ? '<tr><td colspan="4">No active sessions</td></tr>' :
sessions.map(s => \`<tr>
<td><code>\${s.id.slice(0,8)}…</code></td>
<td><code>\${s.userId}</code></td>
<td>\${fmt(s.ageMs)}</td>
<td><button class="kill" onclick="kill('\${s.id}')">Kill</button></td>
</tr>\`).join('');
}
async function kill(id) {
if (!confirm('Kill session ' + id.slice(0,8) + '…?')) return;
const r = await fetch('/admin/sessions/' + id, { method: 'DELETE' });
const j = await r.json();
msg.textContent = j.ok ? 'Session ' + id.slice(0,8) + '… destroyed.' : j.error;
load();
}
load();
setInterval(load, 10000);
</script>
</body>
</html>`;
+177 -60
View File
@@ -1,91 +1,182 @@
import express from "express";
import { createServer } from "http";
import { mkdirSync } from "fs";
import path from "path";
import { spawn } from "node-pty";
import { WebSocketServer } from "ws";
import { ConnectionRateLimiter, validateAuthToken } from "./auth.js";
import type { IncomingMessage } from "http";
import { ConnectionRateLimiter } from "./auth.js";
import { SessionManager } from "./session-manager.js";
import { UserStore } from "./user-store.js";
import { createAdminRouter } from "./admin.js";
import { SessionStore } from "./auth/adapter.js";
import { TokenAuthAdapter } from "./auth/token-auth.js";
import { OAuthAdapter } from "./auth/oauth-auth.js";
import { ApiKeyAdapter } from "./auth/apikey-auth.js";
import type { AuthAdapter, AuthUser } from "./auth/adapter.js";
// ── Configuration ─────────────────────────────────────────────────────────────
// Configuration from environment
const PORT = parseInt(process.env.PORT ?? "3000", 10);
const HOST = process.env.HOST ?? "0.0.0.0";
const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS ?? "10", 10);
const MAX_SESSIONS_PER_USER = parseInt(process.env.MAX_SESSIONS_PER_USER ?? "3", 10);
const MAX_SESSIONS_PER_HOUR = parseInt(process.env.MAX_SESSIONS_PER_HOUR ?? "10", 10);
const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS?.split(",") ?? [];
const GRACE_PERIOD_MS = parseInt(
process.env.SESSION_GRACE_MS ?? String(5 * 60_000),
10,
);
const SCROLLBACK_BYTES = parseInt(
process.env.SCROLLBACK_BYTES ?? String(100 * 1024),
10,
);
// Resolve the claude CLI binary
const GRACE_PERIOD_MS = parseInt(process.env.SESSION_GRACE_MS ?? String(5 * 60_000), 10);
const SCROLLBACK_BYTES = parseInt(process.env.SCROLLBACK_BYTES ?? String(100 * 1024), 10);
const CLAUDE_BIN = process.env.CLAUDE_BIN ?? "claude";
const AUTH_PROVIDER = process.env.AUTH_PROVIDER ?? "token";
const SESSION_SECRET = process.env.SESSION_SECRET ?? crypto.randomUUID();
const USER_HOME_BASE = process.env.USER_HOME_BASE ?? "/home/claude/users";
// ── Auth adapter ──────────────────────────────────────────────────────────────
const sessionStore = new SessionStore(SESSION_SECRET);
let authAdapter: AuthAdapter;
switch (AUTH_PROVIDER) {
case "oauth":
authAdapter = new OAuthAdapter(sessionStore);
break;
case "apikey":
authAdapter = new ApiKeyAdapter(sessionStore);
break;
default:
authAdapter = new TokenAuthAdapter();
}
// ── Express app ───────────────────────────────────────────────────────────────
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
const server = createServer(app);
// --- Session Manager ---
// Register auth routes (login, callback, logout) before static files so they
// take priority over any index.html fallback.
authAdapter.setupRoutes(app);
// ── User store ────────────────────────────────────────────────────────────────
const userStore = new UserStore();
// ── Session Manager ───────────────────────────────────────────────────────────
/** Returns the user-specific home directory, creating it if needed. */
function userHomeDir(userId: string): string {
const dir = path.join(USER_HOME_BASE, userId);
try {
mkdirSync(path.join(dir, ".claude"), { recursive: true });
} catch {
// Already exists or no permission — fail silently; PTY spawn will surface any real issue.
}
return dir;
}
const sessionManager = new SessionManager(
MAX_SESSIONS,
(cols, rows) =>
spawn(CLAUDE_BIN, [], {
(cols, rows, user?: AuthUser) => {
const userId = user?.id ?? "default";
const home = userHomeDir(userId);
return spawn(CLAUDE_BIN, [], {
name: "xterm-256color",
cols,
rows,
cwd: process.env.WORK_DIR ?? process.cwd(),
cwd: process.env.WORK_DIR ?? home,
env: {
...process.env,
TERM: "xterm-256color",
COLORTERM: "truecolor",
HOME: home,
// Inject the user's own API key when using apikey auth provider.
...(user?.apiKey ? { ANTHROPIC_API_KEY: user.apiKey } : {}),
},
});
},
}),
GRACE_PERIOD_MS,
SCROLLBACK_BYTES,
MAX_SESSIONS_PER_USER,
MAX_SESSIONS_PER_HOUR,
);
// --- HTTP routes ---
// ── HTTP routes ───────────────────────────────────────────────────────────────
app.get("/health", (_req, res) => {
res.json({
status: "ok",
activeSessions: sessionManager.activeCount,
maxSessions: MAX_SESSIONS,
authProvider: AUTH_PROVIDER,
});
});
app.get("/api/sessions", (_req, res) => {
res.json(sessionManager.listSessions());
/**
* GET /api/sessions — list the current user's sessions.
* Requires authentication. Admins see all sessions.
*/
app.get("/api/sessions", authAdapter.requireAuth.bind(authAdapter), (req, res) => {
const user = (req as express.Request & { user: AuthUser }).user;
const sessions = user.isAdmin
? sessionManager.getAllSessions()
: sessionManager.getUserSessions(user.id);
res.json(sessions);
});
app.delete("/api/sessions/:token", (req, res) => {
/**
* DELETE /api/sessions/:token — kill a session.
* Users may only kill their own sessions; admins may kill any session.
*/
app.delete(
"/api/sessions/:token",
authAdapter.requireAuth.bind(authAdapter),
(req, res) => {
const { token } = req.params;
const user = (req as express.Request & { user: AuthUser }).user;
const session = sessionManager.getSession(token);
if (!session) {
res.status(404).json({ error: "Session not found" });
return;
}
if (!user.isAdmin && session.userId !== user.id) {
res.status(403).json({ error: "Forbidden" });
return;
}
sessionManager.destroySession(token);
res.status(204).end();
});
},
);
// Serve static frontend
// Admin routes — protected by admin-role check inside the router.
app.use(
"/admin",
authAdapter.requireAuth.bind(authAdapter),
createAdminRouter(sessionManager, userStore),
);
// Static frontend (served last so auth/admin routes win).
const publicDir = path.join(import.meta.dirname, "public");
app.use(express.static(publicDir));
app.get("/", (_req, res) => {
app.get("/", authAdapter.requireAuth.bind(authAdapter), (_req, res) => {
res.sendFile(path.join(publicDir, "index.html"));
});
// --- WebSocket server ---
// ── WebSocket server ──────────────────────────────────────────────────────────
/**
* Extend IncomingMessage to carry the authenticated user through from
* verifyClient to the connection handler without re-authenticating.
*/
interface AuthedRequest extends IncomingMessage {
_authUser?: AuthUser;
}
const rateLimiter = new ConnectionRateLimiter();
// Clean up rate limiter every 5 minutes
const rateLimiterCleanup = setInterval(() => rateLimiter.cleanup(), 5 * 60_000);
const wss = new WebSocketServer({
@@ -99,14 +190,15 @@ const wss = new WebSocketServer({
return;
}
// Auth token check
if (!validateAuthToken(req)) {
console.warn("Rejected connection: invalid auth token");
// Authenticate the user
const user = authAdapter.authenticate(req as IncomingMessage);
if (!user) {
console.warn("Rejected WebSocket connection: unauthenticated");
callback(false, 401, "Unauthorized");
return;
}
// Rate limit check
// IP-level rate limit (guards against connection floods from a single IP)
const ip =
(req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() ??
req.socket.remoteAddress ??
@@ -117,56 +209,82 @@ const wss = new WebSocketServer({
return;
}
// Per-user rate limit (hourly new-session quota)
if (sessionManager.isUserRateLimited(user.id)) {
const retryAfter = sessionManager.retryAfterSeconds(user.id);
console.warn(`Per-user rate limit for ${user.id}`);
callback(false, 429, "Too Many Requests", { "Retry-After": String(retryAfter) });
return;
}
// Per-user concurrent session limit
if (sessionManager.isUserAtConcurrentLimit(user.id)) {
console.warn(`Concurrent session limit reached for ${user.id}`);
callback(false, 429, "Session limit reached");
return;
}
// Attach user to request for the connection handler
(req as AuthedRequest)._authUser = user;
callback(true);
},
});
wss.on("connection", (ws, req) => {
const user = (req as AuthedRequest)._authUser;
if (!user) {
// Should never happen — verifyClient already checked, but be safe.
ws.close(1008, "Unauthenticated");
return;
}
const ip =
(req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() ??
req.socket.remoteAddress ??
"unknown";
console.log(`New WebSocket connection from ${ip}`);
console.log(`New WebSocket connection from ${ip} (user: ${user.id})`);
const url = new URL(
req.url ?? "/",
`http://${req.headers.host ?? "localhost"}`,
);
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
const cols = parseInt(url.searchParams.get("cols") ?? "80", 10);
const rows = parseInt(url.searchParams.get("rows") ?? "24", 10);
const resumeToken = url.searchParams.get("resume");
// Try to resume an existing session
// Try to resume an existing session owned by this user
if (resumeToken) {
const stored = sessionManager.getSession(resumeToken);
// Users may only resume their own sessions (admins can resume any)
if (stored && (user.isAdmin || stored.userId === user.id)) {
const resumed = sessionManager.resume(resumeToken, ws, cols, rows);
if (resumed) {
return;
if (resumed) return;
}
// Session expired or not found — fall through to create a new one
console.log(
`[resume] Session ${resumeToken.slice(0, 8)}… not found — starting fresh`,
`[resume] Session ${resumeToken.slice(0, 8)}… not found or not owned — starting fresh`,
);
}
// Capacity check only applies to new sessions
// Global capacity check
if (sessionManager.isFull) {
ws.send(
JSON.stringify({
type: "error",
message: "Max sessions reached. Try again later.",
}),
);
ws.send(JSON.stringify({ type: "error", message: "Max sessions reached. Try again later." }));
ws.close(1013, "Max sessions reached");
return;
}
const token = sessionManager.create(ws, cols, rows);
const token = sessionManager.create(ws, cols, rows, user);
if (token) {
// Track the user in the user store
userStore.touch(user.id, { email: user.email, name: user.name });
// Release the user slot when this session ends
const stored = sessionManager.getSession(token);
if (stored) {
stored.pty.onExit(() => userStore.release(user.id));
}
ws.send(JSON.stringify({ type: "session", token }));
}
});
// --- Graceful shutdown ---
// ── Graceful shutdown ─────────────────────────────────────────────────────────
function shutdown() {
console.log("Shutting down...");
@@ -179,7 +297,6 @@ function shutdown() {
});
});
// Force exit after 10 seconds
setTimeout(() => {
console.error("Forced shutdown after timeout");
process.exit(1);
@@ -189,21 +306,21 @@ function shutdown() {
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
// --- Start ---
// ── Start ─────────────────────────────────────────────────────────────────────
server.listen(PORT, HOST, () => {
console.log(`PTY server listening on http://${HOST}:${PORT}`);
console.log(` WebSocket: ws://${HOST}:${PORT}/ws`);
console.log(` Max sessions: ${MAX_SESSIONS}`);
console.log(
` Session grace period: ${GRACE_PERIOD_MS / 1000}s`,
);
console.log(
` Scrollback buffer: ${Math.round(SCROLLBACK_BYTES / 1024)}KB per session`,
);
if (process.env.AUTH_TOKEN) {
console.log(` Max sessions: ${MAX_SESSIONS} (${MAX_SESSIONS_PER_USER} per user)`);
console.log(` Session grace period: ${GRACE_PERIOD_MS / 1000}s`);
console.log(` Scrollback buffer: ${Math.round(SCROLLBACK_BYTES / 1024)}KB per session`);
console.log(` Auth provider: ${AUTH_PROVIDER}`);
if (AUTH_PROVIDER === "token" && process.env.AUTH_TOKEN) {
console.log(" Auth: token required");
}
if (process.env.ADMIN_USERS) {
console.log(` Admins: ${process.env.ADMIN_USERS}`);
}
});
export { app, server, sessionManager, wss };
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+122 -6
View File
@@ -1,26 +1,87 @@
import type { IPty } from "node-pty";
import type { WebSocket } from "ws";
import { SessionStore } from "./session-store.js";
import type { AuthUser } from "./auth/adapter.js";
export type { SessionInfo } from "./session-store.js";
// ── Per-user hourly rate limiter ─────────────────────────────────────────────
/**
* Tracks new-session creations per user within a rolling 1-hour window.
*
* `allow(userId)` is a non-destructive peek so callers can check eligibility
* before committing. `record(userId)` commits an attempt (call only on
* successful creation).
*/
export class UserHourlyRateLimiter {
private readonly attempts = new Map<string, number[]>();
private readonly maxPerHour: number;
constructor(maxPerHour: number) {
this.maxPerHour = maxPerHour;
setInterval(() => this.cleanup(), 5 * 60_000).unref();
}
allow(userId: string): boolean {
return this.recent(userId).length < this.maxPerHour;
}
record(userId: string): void {
const r = this.recent(userId);
r.push(Date.now());
this.attempts.set(userId, r);
}
/** Seconds until the oldest attempt in the window falls off (for Retry-After). */
retryAfterSeconds(userId: string): number {
const r = this.recent(userId);
if (r.length === 0) return 0;
return Math.ceil((Math.min(...r) + 3_600_000 - Date.now()) / 1000);
}
private recent(userId: string): number[] {
const cutoff = Date.now() - 3_600_000;
const filtered = (this.attempts.get(userId) ?? []).filter((t) => t > cutoff);
this.attempts.set(userId, filtered);
return filtered;
}
private cleanup(): void {
const cutoff = Date.now() - 3_600_000;
for (const [id, ts] of this.attempts) {
const r = ts.filter((t) => t > cutoff);
if (r.length === 0) this.attempts.delete(id);
else this.attempts.set(id, r);
}
}
}
// ── SessionManager ────────────────────────────────────────────────────────────
export class SessionManager {
private store: SessionStore;
private maxSessions: number;
private spawnPty: (cols: number, rows: number) => IPty;
private maxSessionsPerUser: number;
private spawnPty: (cols: number, rows: number, user?: AuthUser) => IPty;
private rateLimiter: UserHourlyRateLimiter;
// Tracks which sessions have already had their PTY event listeners wired,
// so we don't double-register on reconnect.
private wiredPtys = new Set<string>();
constructor(
maxSessions: number,
spawnPty: (cols: number, rows: number) => IPty,
spawnPty: (cols: number, rows: number, user?: AuthUser) => IPty,
gracePeriodMs?: number,
scrollbackBytes?: number,
maxSessionsPerUser?: number,
maxSessionsPerHour?: number,
) {
this.maxSessions = maxSessions;
this.maxSessionsPerUser = maxSessionsPerUser ?? maxSessions;
this.spawnPty = spawnPty;
this.store = new SessionStore(gracePeriodMs, scrollbackBytes);
this.rateLimiter = new UserHourlyRateLimiter(maxSessionsPerHour ?? 100);
}
get activeCount(): number {
@@ -39,18 +100,70 @@ export class SessionManager {
return this.store.list();
}
/** All sessions in the shape expected by the admin dashboard. */
getAllSessions(): Array<{ id: string; userId: string; createdAt: number }> {
return this.store.getAll().map((s) => ({
id: s.token,
userId: s.userId,
createdAt: s.createdAt.getTime(),
}));
}
/** Sessions owned by a specific user — used by the per-user API. */
getUserSessions(userId: string) {
return this.store.listByUser(userId);
}
isUserAtConcurrentLimit(userId: string): boolean {
return this.store.countByUser(userId) >= this.maxSessionsPerUser;
}
isUserRateLimited(userId: string): boolean {
return !this.rateLimiter.allow(userId);
}
retryAfterSeconds(userId: string): number {
return this.rateLimiter.retryAfterSeconds(userId);
}
/**
* Spawns a new PTY, registers it in the session store, and wires up all
* event plumbing between the PTY and the WebSocket.
*
* Returns the session token, or null if at capacity or PTY spawn fails.
* When `user` is provided the session is associated with that user and
* per-user limits are enforced.
*/
create(ws: WebSocket, cols = 80, rows = 24): string | null {
create(ws: WebSocket, cols = 80, rows = 24, user?: AuthUser): string | null {
if (this.isFull) return null;
const userId = user?.id ?? "default";
if (this.isUserAtConcurrentLimit(userId)) {
ws.send(
JSON.stringify({
type: "error",
message: `Session limit reached for your account (max ${this.maxSessionsPerUser}).`,
}),
);
ws.close(1013, "Per-user session limit reached");
return null;
}
if (this.isUserRateLimited(userId)) {
ws.send(
JSON.stringify({
type: "error",
message: "Too many sessions created recently. Please wait before starting a new session.",
}),
);
ws.close(1013, "Rate limited");
return null;
}
let pty: IPty;
try {
pty = this.spawnPty(cols, rows);
pty = this.spawnPty(cols, rows, user);
} catch (err) {
const message =
err instanceof Error ? err.message : "Unknown PTY spawn error";
@@ -61,7 +174,10 @@ export class SessionManager {
return null;
}
const session = this.store.register(pty);
// Record the creation only after a successful spawn.
this.rateLimiter.record(userId);
const session = this.store.register(pty, userId);
session.ws = ws;
const { token } = session;
@@ -69,7 +185,7 @@ export class SessionManager {
this.wireWsEvents(token, ws, pty);
console.log(
`[session ${token.slice(0, 8)}] Created (active: ${this.store.size}/${this.maxSessions})`,
`[session ${token.slice(0, 8)}] Created for user ${userId} (active: ${this.store.size}/${this.maxSessions})`,
);
return token;
}
+27 -1
View File
@@ -7,6 +7,8 @@ const DEFAULT_SCROLLBACK_BYTES = 100 * 1024; // 100 KB
export type StoredSession = {
token: string;
/** ID of the user who owns this session. */
userId: string;
pty: IPty;
scrollback: ScrollbackBuffer;
ws: WebSocket | null;
@@ -17,6 +19,8 @@ export type StoredSession = {
export type SessionInfo = {
token: string;
/** ID of the user who owns this session. */
userId: string;
created: string;
lastActive: string;
alive: boolean;
@@ -44,11 +48,13 @@ export class SessionStore {
/**
* Register a newly spawned PTY under a fresh session token.
* @param userId - ID of the owning user (defaults to "default" for single-user deployments).
*/
register(pty: IPty): StoredSession {
register(pty: IPty, userId = "default"): StoredSession {
const token = crypto.randomUUID();
const session: StoredSession = {
token,
userId,
pty,
scrollback: new ScrollbackBuffer(this.scrollbackBytes),
ws: null,
@@ -153,12 +159,32 @@ export class SessionStore {
list(): SessionInfo[] {
return [...this.sessions.values()].map((s) => ({
token: s.token,
userId: s.userId,
created: s.createdAt.toISOString(),
lastActive: s.lastActive.toISOString(),
alive: s.ws !== null && s.ws.readyState === 1 /* OPEN */,
}));
}
/** Returns summary info for sessions owned by a specific user. */
listByUser(userId: string): SessionInfo[] {
return this.list().filter((s) => s.userId === userId);
}
/** How many sessions are owned by the given user. */
countByUser(userId: string): number {
let n = 0;
for (const s of this.sessions.values()) {
if (s.userId === userId) n++;
}
return n;
}
/** Returns all raw StoredSession objects (used internally by SessionManager). */
getAll(): StoredSession[] {
return [...this.sessions.values()];
}
get size(): number {
return this.sessions.size;
}
+85
View File
@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from "next/server";
import type { Conversation, ExportOptions } from "@/lib/types";
import { toMarkdown } from "@/lib/export/markdown";
import { toJSON } from "@/lib/export/json";
import { toHTML } from "@/lib/export/html";
import { toPlainText } from "@/lib/export/plaintext";
interface ExportRequest {
conversation: Conversation;
options: ExportOptions;
}
const MIME: Record<string, string> = {
markdown: "text/markdown; charset=utf-8",
json: "application/json",
html: "text/html; charset=utf-8",
plaintext: "text/plain; charset=utf-8",
};
const EXT: Record<string, string> = {
markdown: "md",
json: "json",
html: "html",
plaintext: "txt",
};
export async function POST(req: NextRequest) {
let body: ExportRequest;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const { conversation, options } = body;
if (!conversation || !options) {
return NextResponse.json(
{ error: "Missing conversation or options" },
{ status: 400 }
);
}
const { format } = options;
if (format === "pdf") {
// PDF is handled client-side via window.print()
return NextResponse.json(
{ error: "PDF export is handled client-side" },
{ status: 400 }
);
}
let content: string;
switch (format) {
case "markdown":
content = toMarkdown(conversation, options);
break;
case "json":
content = toJSON(conversation, options);
break;
case "html":
content = toHTML(conversation, options);
break;
case "plaintext":
content = toPlainText(conversation, options);
break;
default:
return NextResponse.json({ error: `Unknown format: ${format}` }, { status: 400 });
}
const slug = conversation.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 50);
const filename = `${slug || "conversation"}.${EXT[format]}`;
return new NextResponse(content, {
status: 200,
headers: {
"Content-Type": MIME[format],
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
}
+55
View File
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
const IMAGE_MIME: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
bmp: "image/bmp",
ico: "image/x-icon",
};
export async function GET(request: NextRequest) {
const filePath = request.nextUrl.searchParams.get("path");
if (!filePath) {
return NextResponse.json({ error: "path parameter required" }, { status: 400 });
}
const resolvedPath = path.resolve(filePath);
try {
const stats = await fs.stat(resolvedPath);
if (stats.isDirectory()) {
return NextResponse.json({ error: "path is a directory" }, { status: 400 });
}
const ext = resolvedPath.split(".").pop()?.toLowerCase() ?? "";
// Binary images: return base64 data URL
if (ext in IMAGE_MIME) {
const buffer = await fs.readFile(resolvedPath);
const base64 = buffer.toString("base64");
return NextResponse.json({
content: `data:${IMAGE_MIME[ext]};base64,${base64}`,
isImage: true,
size: stats.size,
modified: stats.mtime.toISOString(),
});
}
// Text (including SVG)
const content = await fs.readFile(resolvedPath, "utf-8");
return NextResponse.json({
content,
isImage: ext === "svg",
size: stats.size,
modified: stats.mtime.toISOString(),
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 404 });
}
}
+31
View File
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
export async function POST(request: NextRequest) {
let body: { path?: string; content?: string };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "invalid JSON body" }, { status: 400 });
}
const { path: filePath, content } = body;
if (!filePath || content === undefined) {
return NextResponse.json(
{ error: "path and content are required" },
{ status: 400 }
);
}
const resolvedPath = path.resolve(filePath);
try {
await fs.writeFile(resolvedPath, content, "utf-8");
const stats = await fs.stat(resolvedPath);
return NextResponse.json({ success: true, size: stats.size });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+42
View File
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import { getShare, revokeShare, verifySharePassword } from "@/lib/share-store";
interface RouteContext {
params: { shareId: string };
}
export async function GET(req: NextRequest, { params }: RouteContext) {
const { shareId } = params;
const share = getShare(shareId);
if (!share) {
return NextResponse.json({ error: "Share not found or expired" }, { status: 404 });
}
if (share.visibility === "password") {
const pw = req.headers.get("x-share-password") ?? req.nextUrl.searchParams.get("password");
if (!pw || !verifySharePassword(shareId, pw)) {
return NextResponse.json({ error: "Password required", requiresPassword: true }, { status: 401 });
}
}
return NextResponse.json({
id: share.id,
title: share.conversation.title,
messages: share.conversation.messages,
model: share.conversation.model,
createdAt: share.conversation.createdAt,
shareCreatedAt: share.createdAt,
});
}
export async function DELETE(_req: NextRequest, { params }: RouteContext) {
const { shareId } = params;
const deleted = revokeShare(shareId);
if (!deleted) {
return NextResponse.json({ error: "Share not found" }, { status: 404 });
}
return NextResponse.json({ success: true });
}
+50
View File
@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from "next/server";
import { nanoid } from "nanoid";
import type { Conversation } from "@/lib/types";
import type { ShareVisibility, ShareExpiry } from "@/lib/share-store";
import { createShare } from "@/lib/share-store";
interface CreateShareRequest {
conversation: Conversation;
visibility: ShareVisibility;
password?: string;
expiry: ShareExpiry;
}
export async function POST(req: NextRequest) {
let body: CreateShareRequest;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const { conversation, visibility, password, expiry } = body;
if (!conversation || !visibility || !expiry) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
if (visibility === "password" && !password) {
return NextResponse.json(
{ error: "Password required for password-protected shares" },
{ status: 400 }
);
}
const shareId = nanoid(12);
const share = createShare(shareId, { conversation, visibility, password, expiry });
const origin = req.headers.get("origin") ?? "";
const url = `${origin}/share/${shareId}`;
return NextResponse.json({
id: share.id,
conversationId: share.conversationId,
visibility: share.visibility,
hasPassword: !!share.passwordHash,
expiry: share.expiry,
expiresAt: share.expiresAt,
createdAt: share.createdAt,
url,
});
}
+186 -37
View File
@@ -2,39 +2,72 @@
@tailwind components;
@tailwind utilities;
/* =====================================================
DESIGN TOKENS — CSS Custom Properties
Dark theme is default; add .light to <html> for light
===================================================== */
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 262.1 83.3% 57.8%;
--primary-foreground: 210 20% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 262.1 83.3% 57.8%;
--radius: 0.5rem;
}
/* Backgrounds */
--color-bg-primary: #09090b;
--color-bg-secondary: #18181b;
--color-bg-elevated: #27272a;
.dark {
/* Text */
--color-text-primary: #fafafa;
--color-text-secondary: #a1a1aa;
--color-text-muted: #52525b;
/* Accent (brand purple) */
--color-accent: #8b5cf6;
--color-accent-hover: #7c3aed;
--color-accent-active: #6d28d9;
--color-accent-foreground: #ffffff;
/* Borders */
--color-border: #27272a;
--color-border-hover: #3f3f46;
/* Status */
--color-success: #22c55e;
--color-success-bg: rgba(34, 197, 94, 0.12);
--color-warning: #f59e0b;
--color-warning-bg: rgba(245, 158, 11, 0.12);
--color-error: #ef4444;
--color-error-bg: rgba(239, 68, 68, 0.12);
--color-info: #3b82f6;
--color-info-bg: rgba(59, 130, 246, 0.12);
/* Code */
--color-code-bg: #1f1f23;
--color-code-text: #a78bfa;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.5);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.5), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
/* Border radius */
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
--radius: 0.5rem;
/* Animation tokens */
--transition-fast: 100ms ease;
--transition-normal: 200ms ease;
--transition-slow: 300ms ease;
/* Tailwind / shadcn compat (HSL channel values) */
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card: 240 3.7% 10%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover: 240 3.7% 15.9%;
--popover-foreground: 0 0% 98%;
--primary: 263.4 70% 50.4%;
--primary-foreground: 210 20% 98%;
--primary-foreground: 0 0% 100%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
@@ -47,32 +80,148 @@
--input: 240 3.7% 15.9%;
--ring: 263.4 70% 50.4%;
}
}
@layer base {
* {
@apply border-border;
/* Light theme override */
.light {
--color-bg-primary: #fafafa;
--color-bg-secondary: #f4f4f5;
--color-bg-elevated: #ffffff;
--color-text-primary: #09090b;
--color-text-secondary: #52525b;
--color-text-muted: #a1a1aa;
--color-accent: #7c3aed;
--color-accent-hover: #6d28d9;
--color-accent-active: #5b21b6;
--color-accent-foreground: #ffffff;
--color-border: #e4e4e7;
--color-border-hover: #d4d4d8;
--color-success: #16a34a;
--color-success-bg: rgba(22, 163, 74, 0.1);
--color-warning: #d97706;
--color-warning-bg: rgba(217, 119, 6, 0.1);
--color-error: #dc2626;
--color-error-bg: rgba(220, 38, 38, 0.1);
--color-info: #2563eb;
--color-info-bg: rgba(37, 99, 235, 0.1);
--color-code-bg: #f4f4f5;
--color-code-text: #7c3aed;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.05);
--background: 0 0% 98%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 262.1 83.3% 57.8%;
--primary-foreground: 0 0% 100%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 262.1 83.3% 57.8%;
}
*, *::before, *::after {
box-sizing: border-box;
border-color: var(--color-border);
}
html {
color-scheme: dark;
}
html.light {
color-scheme: light;
}
body {
@apply bg-background text-foreground;
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
font-feature-settings: "rlig" 1, "calt" 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: 1.5;
}
/* Focus visible ring */
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}
/* Scrollbar styling */
/* =====================================================
ANIMATIONS
===================================================== */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes slideUp {
from { transform: translateY(8px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes slideDown {
from { transform: translateY(-8px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes slideDownOut {
from { transform: translateY(0); opacity: 1; }
to { transform: translateY(8px); opacity: 0; }
}
@keyframes scaleIn {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes scaleOut {
from { transform: scale(1); opacity: 1; }
to { transform: scale(0.95); opacity: 0; }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes progress {
from { transform: scaleX(1); }
to { transform: scaleX(0); }
}
/* =====================================================
SCROLLBAR
===================================================== */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
@apply bg-transparent;
background: transparent;
}
::-webkit-scrollbar-thumb {
@apply bg-surface-300 dark:bg-surface-700 rounded-full;
background: var(--color-border-hover);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-surface-400 dark:bg-surface-600;
background: var(--color-text-muted);
}
+2 -2
View File
@@ -1,9 +1,9 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import localFont from "next/font/local";
import "./globals.css";
import { ThemeProvider } from "@/components/layout/ThemeProvider";
import { ToastProvider } from "@/components/ui/ToastProvider";
import { ToastProvider } from "@/components/notifications/ToastProvider";
const inter = Inter({
subsets: ["latin"],
+68
View File
@@ -0,0 +1,68 @@
"use client";
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from "react";
interface AnnouncerContextValue {
announce: (message: string, politeness?: "polite" | "assertive") => void;
}
const AnnouncerContext = createContext<AnnouncerContextValue | null>(null);
/**
* Provides a programmatic screen-reader announcement API via context.
* Place <AnnouncerProvider> near the root of the app, then call `useAnnouncer()`
* anywhere to imperatively announce status changes.
*
* @example
* const { announce } = useAnnouncer();
* announce("File uploaded successfully");
* announce("Error: request failed", "assertive");
*/
export function AnnouncerProvider({ children }: { children: ReactNode }) {
const [politeMsg, setPoliteMsg] = useState("");
const [assertiveMsg, setAssertiveMsg] = useState("");
const politeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const assertiveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const announce = useCallback((message: string, politeness: "polite" | "assertive" = "polite") => {
if (politeness === "assertive") {
setAssertiveMsg("");
if (assertiveTimer.current) clearTimeout(assertiveTimer.current);
assertiveTimer.current = setTimeout(() => setAssertiveMsg(message), 50);
} else {
setPoliteMsg("");
if (politeTimer.current) clearTimeout(politeTimer.current);
politeTimer.current = setTimeout(() => setPoliteMsg(message), 50);
}
}, []);
const srStyle: React.CSSProperties = {
position: "absolute",
width: "1px",
height: "1px",
padding: 0,
margin: "-1px",
overflow: "hidden",
clip: "rect(0,0,0,0)",
whiteSpace: "nowrap",
borderWidth: 0,
};
return (
<AnnouncerContext.Provider value={{ announce }}>
{children}
<div aria-live="polite" aria-atomic="true" style={srStyle}>
{politeMsg}
</div>
<div aria-live="assertive" aria-atomic="true" style={srStyle}>
{assertiveMsg}
</div>
</AnnouncerContext.Provider>
);
}
export function useAnnouncer() {
const ctx = useContext(AnnouncerContext);
if (!ctx) throw new Error("useAnnouncer must be used within <AnnouncerProvider>");
return ctx;
}
+72
View File
@@ -0,0 +1,72 @@
"use client";
import { useEffect, useRef, type ReactNode } from "react";
interface FocusTrapProps {
children: ReactNode;
/** When false, the trap is inactive (e.g. when the panel is hidden) */
active?: boolean;
}
const FOCUSABLE_SELECTORS = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
'[tabindex]:not([tabindex="-1"])',
].join(", ");
/**
* Traps keyboard focus within its children when `active` is true.
* Use for modals, drawers, and other overlay patterns.
* Note: Radix Dialog handles focus trapping natively — use this only for
* custom overlay components that don't use Radix primitives.
*/
export function FocusTrap({ children, active = true }: FocusTrapProps) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!active) return;
const container = containerRef.current;
if (!container) return;
const focusable = () =>
Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS)).filter(
(el) => !el.closest("[aria-hidden='true']")
);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
const els = focusable();
if (els.length === 0) return;
const first = els[0];
const last = els[els.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
// Move focus into the trap on mount
const els = focusable();
if (els.length > 0 && !container.contains(document.activeElement)) {
els[0].focus();
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [active]);
return <div ref={containerRef}>{children}</div>;
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import { useEffect, useRef, useState } from "react";
interface LiveRegionProps {
/** The message to announce. Changing this value triggers an announcement. */
message: string;
/**
* "polite" — waits for user to be idle (new chat messages, status updates)
* "assertive" — interrupts immediately (errors, critical alerts)
*/
politeness?: "polite" | "assertive";
}
/**
* Managed aria-live region that announces dynamic content to screen readers.
* Clears after 500 ms to ensure repeated identical messages are re-announced.
*/
export function LiveRegion({ message, politeness = "polite" }: LiveRegionProps) {
const [announced, setAnnounced] = useState("");
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!message) return;
// Clear first to force re-announcement of identical messages
setAnnounced("");
timerRef.current = setTimeout(() => setAnnounced(message), 50);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [message]);
return (
<div
role="status"
aria-live={politeness}
aria-atomic="true"
style={{
position: "absolute",
width: "1px",
height: "1px",
padding: 0,
margin: "-1px",
overflow: "hidden",
clip: "rect(0, 0, 0, 0)",
whiteSpace: "nowrap",
borderWidth: 0,
}}
>
{announced}
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
"use client";
export function SkipToContent() {
return (
<a
href="#main-content"
className={[
"sr-only focus:not-sr-only",
"focus:fixed focus:top-4 focus:left-4 focus:z-50",
"focus:px-4 focus:py-2 focus:rounded-md",
"focus:bg-brand-600 focus:text-white focus:font-medium focus:text-sm",
"focus:outline-none focus:ring-2 focus:ring-brand-300 focus:ring-offset-2",
].join(" ")}
>
Skip to main content
</a>
);
}
+31
View File
@@ -0,0 +1,31 @@
import type { ReactNode } from "react";
interface VisuallyHiddenProps {
children: ReactNode;
/** When true, renders as a span inline; defaults to span */
as?: "span" | "div" | "p";
}
/**
* Visually hides content while keeping it accessible to screen readers.
* Use for icon-only buttons, supplemental context, etc.
*/
export function VisuallyHidden({ children, as: Tag = "span" }: VisuallyHiddenProps) {
return (
<Tag
style={{
position: "absolute",
width: "1px",
height: "1px",
padding: 0,
margin: "-1px",
overflow: "hidden",
clip: "rect(0, 0, 0, 0)",
whiteSpace: "nowrap",
borderWidth: 0,
}}
>
{children}
</Tag>
);
}
+170
View File
@@ -0,0 +1,170 @@
"use client";
/**
* Web-adapted Markdown renderer.
*
* The terminal Markdown (src/components/Markdown.tsx) uses marked + Ink's
* <Ansi> / <Box> to render tokenised markdown as coloured ANSI output.
* This web version uses react-markdown + remark-gfm + rehype-highlight, which
* are already present in the web package, to render proper HTML with Tailwind
* prose styles.
*
* Props are intentionally compatible with the terminal version so callers can
* swap between them via the platform conditional.
*/
import * as React from "react";
import { useMemo } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { cn } from "@/lib/utils";
// ─── Types ────────────────────────────────────────────────────────────────────
export interface MarkdownProps {
/** Markdown source string — matches the terminal component's children prop. */
children: string;
/** When true, render all text as visually dimmed (muted colour). */
dimColor?: boolean;
/** Extra class names applied to the prose wrapper. */
className?: string;
}
// ─── Inline code / pre renderers ─────────────────────────────────────────────
function InlineCode({ children }: { children?: React.ReactNode }) {
return (
<code className="px-1 py-0.5 rounded text-xs font-mono bg-surface-850 text-brand-300 border border-surface-700">
{children}
</code>
);
}
interface PreProps {
children?: React.ReactNode;
}
function Pre({ children }: PreProps) {
return (
<pre className="overflow-x-auto rounded-md bg-surface-900 border border-surface-700 p-3 my-2 text-xs font-mono leading-relaxed">
{children}
</pre>
);
}
// ─── Component ────────────────────────────────────────────────────────────────
export function Markdown({ children, dimColor = false, className }: MarkdownProps) {
// Memoised to avoid re-parsing on every parent render.
const content = useMemo(() => children, [children]);
return (
<div
className={cn(
"markdown-body text-sm leading-relaxed font-mono",
dimColor ? "text-surface-500" : "text-surface-100",
// Headings
"[&_h1]:text-base [&_h1]:font-bold [&_h1]:mb-2 [&_h1]:mt-3 [&_h1]:text-surface-50",
"[&_h2]:text-sm [&_h2]:font-semibold [&_h2]:mb-1.5 [&_h2]:mt-2.5 [&_h2]:text-surface-100",
"[&_h3]:text-sm [&_h3]:font-semibold [&_h3]:mb-1 [&_h3]:mt-2 [&_h3]:text-surface-200",
// Paragraphs
"[&_p]:my-1 [&_p]:leading-relaxed",
// Lists
"[&_ul]:my-1 [&_ul]:pl-4 [&_ul]:list-disc",
"[&_ol]:my-1 [&_ol]:pl-4 [&_ol]:list-decimal",
"[&_li]:my-0.5",
// Blockquote
"[&_blockquote]:border-l-2 [&_blockquote]:border-brand-500 [&_blockquote]:pl-3",
"[&_blockquote]:my-2 [&_blockquote]:text-surface-400 [&_blockquote]:italic",
// Horizontal rule
"[&_hr]:border-surface-700 [&_hr]:my-3",
// Tables (GFM)
"[&_table]:w-full [&_table]:text-xs [&_table]:border-collapse [&_table]:my-2",
"[&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:border [&_th]:border-surface-700 [&_th]:bg-surface-800 [&_th]:font-semibold",
"[&_td]:px-2 [&_td]:py-1 [&_td]:border [&_td]:border-surface-700",
"[&_tr:nth-child(even)_td]:bg-surface-900/40",
// Links
"[&_a]:text-brand-400 [&_a]:no-underline [&_a:hover]:underline",
// Strong / em
"[&_strong]:font-bold [&_strong]:text-surface-50",
"[&_em]:italic",
className
)}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ className: cls, children: codeChildren, ...rest }) {
const isBlock = /language-/.test(cls ?? "");
if (isBlock) {
return (
<code className={cn("block text-surface-200", cls)} {...rest}>
{codeChildren}
</code>
);
}
return <InlineCode {...rest}>{codeChildren}</InlineCode>;
},
pre: ({ children: preChildren }) => <Pre>{preChildren}</Pre>,
}}
>
{content}
</ReactMarkdown>
</div>
);
}
// ─── Table component (matches MarkdownTable.tsx surface) ─────────────────────
export interface MarkdownTableProps {
headers: string[];
rows: string[][];
className?: string;
}
export function MarkdownTable({ headers, rows, className }: MarkdownTableProps) {
return (
<div className={cn("overflow-x-auto my-2", className)}>
<table className="w-full text-xs border-collapse font-mono">
<thead>
<tr>
{headers.map((h, i) => (
<th
key={i}
className="px-2 py-1 text-left border border-surface-700 bg-surface-800 font-semibold text-surface-200"
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={ri}>
{row.map((cell, ci) => (
<td
key={ci}
className={cn(
"px-2 py-1 border border-surface-700 text-surface-300",
ri % 2 === 1 && "bg-surface-900/40"
)}
>
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
+151
View File
@@ -0,0 +1,151 @@
"use client";
/**
* Web-adapted Spinner.
*
* The terminal Spinner (src/components/Spinner.tsx) drives animation via
* useAnimationFrame and renders Unicode braille/block characters with ANSI
* colour via Ink's <Text>. In the browser we replace that with a pure-CSS
* spinning ring, preserving the same optional `tip` text and `mode` prop
* surface so callers can swap in this component without changing props.
*/
import * as React from "react";
import { cn } from "@/lib/utils";
// ─── Types ────────────────────────────────────────────────────────────────────
/** Mirrors the SpinnerMode type from src/components/Spinner/index.ts */
export type SpinnerMode =
| "queued"
| "loading"
| "thinking"
| "auto"
| "disabled";
export interface SpinnerProps {
/** Visual mode — controls colour/appearance. */
mode?: SpinnerMode;
/** Optional tip text shown next to the spinner. */
spinnerTip?: string;
/** Override message replaces the default verb. */
overrideMessage?: string | null;
/** Additional suffix appended after the main label. */
spinnerSuffix?: string | null;
/** When true the spinner renders inline instead of as a block row. */
inline?: boolean;
/** Extra class names for the wrapper element. */
className?: string;
}
// ─── Colour map ───────────────────────────────────────────────────────────────
const MODE_RING_CLASS: Record<SpinnerMode, string> = {
queued: "border-surface-500",
loading: "border-brand-400",
thinking: "border-brand-500",
auto: "border-brand-400",
disabled: "border-surface-600",
};
const MODE_TEXT_CLASS: Record<SpinnerMode, string> = {
queued: "text-surface-400",
loading: "text-brand-300",
thinking: "text-brand-300",
auto: "text-brand-300",
disabled: "text-surface-500",
};
const MODE_LABEL: Record<SpinnerMode, string> = {
queued: "Queued…",
loading: "Loading…",
thinking: "Thinking…",
auto: "Working…",
disabled: "",
};
// ─── Component ────────────────────────────────────────────────────────────────
export function Spinner({
mode = "loading",
spinnerTip,
overrideMessage,
spinnerSuffix,
inline = false,
className,
}: SpinnerProps) {
if (mode === "disabled") return null;
const label =
overrideMessage ??
spinnerTip ??
MODE_LABEL[mode];
const ringClass = MODE_RING_CLASS[mode];
const textClass = MODE_TEXT_CLASS[mode];
return (
<span
role="status"
aria-label={label || "Loading"}
className={cn(
"flex items-center gap-2",
inline ? "inline-flex" : "flex",
className
)}
>
{/* CSS spinning ring */}
<span
className={cn(
"block w-3.5 h-3.5 rounded-full border-2 border-transparent animate-spin flex-shrink-0",
ringClass,
// Top border only — creates the "gap" in the ring for the spinning effect
"[border-top-color:currentColor]"
)}
style={{ borderTopColor: undefined }}
aria-hidden
>
{/* Inner ring for the visible arc — achieved via box-shadow trick */}
</span>
{(label || spinnerSuffix) && (
<span className={cn("text-sm font-mono", textClass)}>
{label}
{spinnerSuffix && (
<span className="text-surface-500 ml-1">{spinnerSuffix}</span>
)}
</span>
)}
</span>
);
}
// ─── Shimmer / glimmer variant ────────────────────────────────────────────────
/** Pulsing shimmer bar — web replacement for GlimmerMessage / ShimmerChar. */
export function ShimmerBar({ className }: { className?: string }) {
return (
<div
className={cn(
"h-2 rounded-full bg-gradient-to-r from-surface-700 via-surface-500 to-surface-700",
"bg-[length:200%_100%] animate-shimmer",
className
)}
aria-hidden
/>
);
}
/** Inline flashing cursor dot — web replacement for FlashingChar. */
export function FlashingCursor({ className }: { className?: string }) {
return (
<span
className={cn(
"inline-block w-1.5 h-4 bg-current align-text-bottom ml-0.5",
"animate-pulse-soft",
className
)}
aria-hidden
/>
);
}
+12 -6
View File
@@ -118,12 +118,16 @@ export function ChatInput({ conversationId }: ChatInputProps) {
>
<button
className="p-1 text-surface-500 hover:text-surface-300 transition-colors flex-shrink-0 mb-0.5"
title="Attach file"
aria-label="Attach file"
>
<Paperclip className="w-4 h-4" />
<Paperclip className="w-4 h-4" aria-hidden="true" />
</button>
<label htmlFor="chat-input" className="sr-only">
Message
</label>
<textarea
id="chat-input"
ref={textareaRef}
value={input}
onChange={(e) => {
@@ -133,6 +137,7 @@ export function ChatInput({ conversationId }: ChatInputProps) {
onKeyDown={handleKeyDown}
placeholder="Message Claude Code..."
rows={1}
aria-label="Message"
className={cn(
"flex-1 resize-none bg-transparent text-sm text-surface-100",
"placeholder:text-surface-500 focus:outline-none",
@@ -143,24 +148,25 @@ export function ChatInput({ conversationId }: ChatInputProps) {
{isStreaming ? (
<button
onClick={handleStop}
aria-label="Stop generation"
className="p-1.5 rounded-lg bg-surface-700 text-surface-300 hover:bg-surface-600 transition-colors flex-shrink-0"
title="Stop generation"
>
<Square className="w-4 h-4" />
<Square className="w-4 h-4" aria-hidden="true" />
</button>
) : (
<button
onClick={handleSubmit}
disabled={!input.trim()}
aria-label="Send message"
aria-disabled={!input.trim()}
className={cn(
"p-1.5 rounded-lg transition-colors flex-shrink-0",
input.trim()
? "bg-brand-600 text-white hover:bg-brand-700"
: "bg-surface-700 text-surface-500 cursor-not-allowed"
)}
title="Send message"
>
<Send className="w-4 h-4" />
<Send className="w-4 h-4" aria-hidden="true" />
</button>
)}
</div>
+10 -1
View File
@@ -6,6 +6,8 @@ import { Sidebar } from "@/components/layout/Sidebar";
import { Header } from "@/components/layout/Header";
import { ChatWindow } from "./ChatWindow";
import { ChatInput } from "./ChatInput";
import { SkipToContent } from "@/components/a11y/SkipToContent";
import { AnnouncerProvider } from "@/components/a11y/Announcer";
export function ChatLayout() {
const { conversations, createConversation, activeConversationId } = useChatStore();
@@ -17,11 +19,17 @@ export function ChatLayout() {
}, []);
return (
<AnnouncerProvider>
<SkipToContent />
<div className="flex h-screen bg-surface-950 text-surface-100">
<Sidebar />
<div className="flex flex-col flex-1 min-w-0">
<Header />
<main className="flex flex-col flex-1 min-h-0">
<main
id="main-content"
aria-label="Chat"
className="flex flex-col flex-1 min-h-0"
>
{activeConversationId ? (
<>
<ChatWindow conversationId={activeConversationId} />
@@ -35,5 +43,6 @@ export function ChatLayout() {
</main>
</div>
</div>
</AnnouncerProvider>
);
}
+52 -6
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { useChatStore } from "@/lib/store";
import { MessageBubble } from "./MessageBubble";
import { Bot } from "lucide-react";
@@ -15,15 +15,37 @@ export function ChatWindow({ conversationId }: ChatWindowProps) {
const conversation = conversations.find((c) => c.id === conversationId);
const messages = conversation?.messages ?? [];
const isStreaming = messages.some((m) => m.status === "streaming");
// Announce the last completed assistant message to screen readers
const [announcement, setAnnouncement] = useState("");
const prevLengthRef = useRef(messages.length);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages.length]);
const lastMsg = messages[messages.length - 1];
if (
messages.length > prevLengthRef.current &&
lastMsg?.role === "assistant" &&
lastMsg.status === "complete"
) {
// Announce a short preview so screen reader users know a reply arrived
const preview = lastMsg.content.slice(0, 100);
setAnnouncement("");
setTimeout(() => setAnnouncement(`Claude replied: ${preview}`), 50);
}
prevLengthRef.current = messages.length;
}, [messages.length, messages]);
if (messages.length === 0) {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-4 text-center px-6">
<div className="w-12 h-12 rounded-full bg-brand-600/20 flex items-center justify-center">
<Bot className="w-6 h-6 text-brand-400" />
<div
className="w-12 h-12 rounded-full bg-brand-600/20 flex items-center justify-center"
aria-hidden="true"
>
<Bot className="w-6 h-6 text-brand-400" aria-hidden="true" />
</div>
<div>
<h2 className="text-lg font-semibold text-surface-100">How can I help?</h2>
@@ -36,12 +58,36 @@ export function ChatWindow({ conversationId }: ChatWindowProps) {
}
return (
<div className="flex-1 overflow-y-auto">
<div
className="flex-1 overflow-y-auto"
aria-busy={isStreaming}
aria-label="Conversation"
>
{/* Polite live region — announces when Claude finishes a reply */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
style={{
position: "absolute",
width: "1px",
height: "1px",
padding: 0,
margin: "-1px",
overflow: "hidden",
clip: "rect(0,0,0,0)",
whiteSpace: "nowrap",
borderWidth: 0,
}}
>
{announcement}
</div>
<div className="max-w-3xl mx-auto py-6 px-4 space-y-6">
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
<div ref={bottomRef} />
<div ref={bottomRef} aria-hidden="true" />
</div>
</div>
);
+12 -7
View File
@@ -15,14 +15,16 @@ export function MessageBubble({ message }: MessageBubbleProps) {
const text = extractTextContent(message.content);
return (
<div
<article
className={cn(
"flex gap-3 animate-fade-in",
isUser && "flex-row-reverse"
)}
aria-label={isUser ? "You" : isError ? "Error from Claude" : "Claude"}
>
{/* Avatar */}
{/* Avatar — purely decorative, role conveyed by article label */}
<div
aria-hidden="true"
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5",
isUser
@@ -33,11 +35,11 @@ export function MessageBubble({ message }: MessageBubbleProps) {
)}
>
{isUser ? (
<User className="w-4 h-4" />
<User className="w-4 h-4" aria-hidden="true" />
) : isError ? (
<AlertCircle className="w-4 h-4" />
<AlertCircle className="w-4 h-4" aria-hidden="true" />
) : (
<Bot className="w-4 h-4" />
<Bot className="w-4 h-4" aria-hidden="true" />
)}
</div>
@@ -64,10 +66,13 @@ export function MessageBubble({ message }: MessageBubbleProps) {
<MarkdownContent content={text} />
)}
{message.status === "streaming" && (
<span className="inline-block w-1.5 h-4 bg-current ml-0.5 animate-pulse-soft" />
<span
aria-hidden="true"
className="inline-block w-1.5 h-4 bg-current ml-0.5 animate-pulse-soft"
/>
)}
</div>
</div>
</div>
</article>
);
}
+114
View File
@@ -0,0 +1,114 @@
"use client";
import { useRef, useEffect, useCallback } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import type { Message } from "@/lib/types";
import { MessageBubble } from "./MessageBubble";
/**
* Estimated heights used for initial layout. The virtualizer measures actual
* heights after render and updates scroll positions accordingly.
*/
const ESTIMATED_HEIGHT = {
short: 80, // typical user message
medium: 160, // short assistant reply
tall: 320, // code blocks / long replies
};
function estimateMessageHeight(message: Message): number {
const text =
typeof message.content === "string"
? message.content
: message.content
.filter((b): b is { type: "text"; text: string } => b.type === "text")
.map((b) => b.text)
.join("");
if (text.length < 100) return ESTIMATED_HEIGHT.short;
if (text.length < 500 || text.includes("```")) return ESTIMATED_HEIGHT.medium;
return ESTIMATED_HEIGHT.tall;
}
interface VirtualMessageListProps {
messages: Message[];
/** Whether streaming is in progress — suppresses smooth-scroll so the
* autoscroll keeps up with incoming tokens. */
isStreaming: boolean;
}
export function VirtualMessageList({ messages, isStreaming }: VirtualMessageListProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const isAtBottomRef = useRef(true);
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => scrollRef.current,
estimateSize: (index) => estimateMessageHeight(messages[index]),
overscan: 5,
});
// Track whether the user has scrolled away from the bottom
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
isAtBottomRef.current = distanceFromBottom < 80;
}, []);
// Auto-scroll to bottom when new messages arrive (if already at bottom)
useEffect(() => {
if (!isAtBottomRef.current) return;
const el = scrollRef.current;
if (!el) return;
if (isStreaming) {
// Instant scroll during streaming to keep up with tokens
el.scrollTop = el.scrollHeight;
} else {
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
}
}, [messages.length, isStreaming]);
// Also scroll when the last streaming message content changes
useEffect(() => {
if (!isStreaming || !isAtBottomRef.current) return;
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
});
const items = virtualizer.getVirtualItems();
return (
<div
ref={scrollRef}
className="flex-1 overflow-y-auto"
onScroll={handleScroll}
>
{/* Spacer that gives the virtualizer its total height */}
<div
style={{ height: virtualizer.getTotalSize(), position: "relative" }}
className="max-w-3xl mx-auto px-4 py-6"
>
{items.map((virtualItem) => {
const message = messages[virtualItem.index];
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
transform: `translateY(${virtualItem.start}px)`,
}}
className="pb-6"
>
<MessageBubble message={message} />
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,50 @@
"use client";
import { useState } from "react";
import { MessageSquare } from "lucide-react";
import { cn } from "@/lib/utils";
import { AnnotationThread } from "./AnnotationThread";
import { useCollaborationContextOptional } from "./CollaborationProvider";
interface AnnotationBadgeProps {
messageId: string;
}
export function AnnotationBadge({ messageId }: AnnotationBadgeProps) {
const ctx = useCollaborationContextOptional();
const [open, setOpen] = useState(false);
if (!ctx) return null;
const annotations = ctx.annotations[messageId] ?? [];
const unresolved = annotations.filter((a) => !a.resolved);
if (annotations.length === 0) return null;
return (
<div className="relative inline-block">
<button
onClick={() => setOpen((v) => !v)}
className={cn(
"flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-medium",
"transition-colors border",
unresolved.length > 0
? "bg-amber-900/30 border-amber-700/50 text-amber-300 hover:bg-amber-900/50"
: "bg-surface-800 border-surface-700 text-surface-400 hover:bg-surface-700"
)}
title={`${annotations.length} comment${annotations.length !== 1 ? "s" : ""}`}
>
<MessageSquare className="w-3 h-3" />
{unresolved.length > 0 ? unresolved.length : annotations.length}
</button>
{open && (
<div
className="absolute right-0 top-full mt-1 z-40 w-80"
onKeyDown={(e) => e.key === "Escape" && setOpen(false)}
>
<AnnotationThread messageId={messageId} onClose={() => setOpen(false)} />
</div>
)}
</div>
);
}
@@ -0,0 +1,139 @@
"use client";
import { createContext, useContext, useRef, useMemo } from "react";
import { useCollaboration } from "@/hooks/useCollaboration";
import { usePresence } from "@/hooks/usePresence";
import { CollabSocket } from "@/lib/collaboration/socket";
import type { CollabUser, CollabRole } from "@/lib/collaboration/socket";
import type { CollabAnnotation, PendingToolUse } from "@/lib/collaboration/types";
import type { PresenceState } from "@/lib/collaboration/presence";
import type { LinkExpiry, ShareLink } from "@/lib/collaboration/types";
import { createShareLink } from "@/lib/collaboration/permissions";
// ─── Context Shape ────────────────────────────────────────────────────────────
interface CollaborationContextValue {
// Connection
isConnected: boolean;
sessionId: string;
currentUser: CollabUser;
// Roles & policy
myRole: CollabRole | null;
toolApprovalPolicy: "owner-only" | "any-collaborator";
// Presence
presence: PresenceState;
otherUsers: CollabUser[];
typingUsers: CollabUser[];
// Tool approvals
pendingToolUses: PendingToolUse[];
approveTool: (toolUseId: string) => void;
denyTool: (toolUseId: string) => void;
// Annotations
annotations: Record<string, CollabAnnotation[]>;
addAnnotation: (messageId: string, text: string) => void;
resolveAnnotation: (annotationId: string, resolved: boolean) => void;
replyAnnotation: (annotationId: string, text: string) => void;
// Presence actions
sendCursorUpdate: (pos: number, start?: number, end?: number) => void;
notifyTyping: () => void;
stopTyping: () => void;
// Session management
generateShareLink: (role: CollabRole, expiry: LinkExpiry) => ShareLink;
revokeAccess: (userId: string) => void;
changeRole: (userId: string, role: CollabRole) => void;
transferOwnership: (userId: string) => void;
}
const CollaborationContext = createContext<CollaborationContextValue | null>(null);
// ─── Provider ─────────────────────────────────────────────────────────────────
interface CollaborationProviderProps {
sessionId: string;
currentUser: CollabUser;
wsUrl?: string;
children: React.ReactNode;
}
export function CollaborationProvider({
sessionId,
currentUser,
wsUrl,
children,
}: CollaborationProviderProps) {
const socketRef = useRef<CollabSocket | null>(null);
const collab = useCollaboration({ sessionId, currentUser, wsUrl });
// Access the socket from the ref (set by the hook internally)
// Since useCollaboration creates the socket internally, we expose a proxy
// via the presence hook's socket param by reaching into the hook return
const presence = usePresence({
socket: socketRef.current,
sessionId,
currentUser,
});
const generateShareLink = useMemo(
() => (role: CollabRole, expiry: LinkExpiry) =>
createShareLink(sessionId, role, expiry, currentUser.id),
[sessionId, currentUser.id]
);
const value: CollaborationContextValue = {
isConnected: collab.isConnected,
sessionId,
currentUser,
myRole: collab.myRole,
toolApprovalPolicy: collab.toolApprovalPolicy,
presence: presence.presence,
otherUsers: presence.otherUsers,
typingUsers: presence.typingUsers,
pendingToolUses: collab.pendingToolUses,
approveTool: collab.approveTool,
denyTool: collab.denyTool,
annotations: collab.annotations,
addAnnotation: collab.addAnnotation,
resolveAnnotation: collab.resolveAnnotation,
replyAnnotation: collab.replyAnnotation,
sendCursorUpdate: presence.sendCursorUpdate,
notifyTyping: presence.notifyTyping,
stopTyping: presence.stopTyping,
generateShareLink,
revokeAccess: collab.revokeAccess,
changeRole: collab.changeRole,
transferOwnership: collab.transferOwnership,
};
return (
<CollaborationContext.Provider value={value}>
{children}
</CollaborationContext.Provider>
);
}
// ─── Consumer Hook ────────────────────────────────────────────────────────────
export function useCollaborationContext(): CollaborationContextValue {
const ctx = useContext(CollaborationContext);
if (!ctx) {
throw new Error(
"useCollaborationContext must be used inside <CollaborationProvider>"
);
}
return ctx;
}
/**
* Returns null when there is no active collaboration session.
* Use this in components that render outside a CollaborationProvider.
*/
export function useCollaborationContextOptional(): CollaborationContextValue | null {
return useContext(CollaborationContext);
}
@@ -0,0 +1,122 @@
"use client";
import { useRef, useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useCollaborationContextOptional } from "./CollaborationProvider";
import type { CursorState } from "@/lib/collaboration/presence";
import type { CollabUser } from "@/lib/collaboration/socket";
// ─── Types ────────────────────────────────────────────────────────────────────
interface CursorGhostProps {
/** The textarea ref to measure cursor positions against */
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
}
interface RenderedCursor {
user: CollabUser;
cursor: CursorState;
top: number;
left: number;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Approximates pixel position of a text offset inside a textarea.
* Uses a hidden mirror div that matches the textarea's styling.
*/
function measureCursorPosition(
textarea: HTMLTextAreaElement,
offset: number
): { top: number; left: number } {
const mirror = document.createElement("div");
const computed = window.getComputedStyle(textarea);
mirror.style.position = "absolute";
mirror.style.visibility = "hidden";
mirror.style.whiteSpace = "pre-wrap";
mirror.style.wordWrap = "break-word";
mirror.style.width = computed.width;
mirror.style.font = computed.font;
mirror.style.lineHeight = computed.lineHeight;
mirror.style.padding = computed.padding;
mirror.style.border = computed.border;
mirror.style.boxSizing = computed.boxSizing;
const text = textarea.value.slice(0, offset);
mirror.textContent = text;
const span = document.createElement("span");
span.textContent = "\u200b"; // zero-width space
mirror.appendChild(span);
document.body.appendChild(mirror);
const rect = textarea.getBoundingClientRect();
const spanRect = span.getBoundingClientRect();
document.body.removeChild(mirror);
return {
top: spanRect.top - rect.top + textarea.scrollTop,
left: spanRect.left - rect.left,
};
}
// ─── CursorGhost ─────────────────────────────────────────────────────────────
export function CursorGhost({ textareaRef }: CursorGhostProps) {
const ctx = useCollaborationContextOptional();
const [rendered, setRendered] = useState<RenderedCursor[]>([]);
useEffect(() => {
if (!ctx || !textareaRef.current) return;
const textarea = textareaRef.current;
const { presence, otherUsers } = ctx;
const next: RenderedCursor[] = [];
for (const user of otherUsers) {
const cursor = presence.cursors.get(user.id);
if (!cursor) continue;
try {
const pos = measureCursorPosition(textarea, cursor.position);
next.push({ user, cursor, ...pos });
} catch {
// ignore measurement errors
}
}
setRendered(next);
});
if (!ctx || rendered.length === 0) return null;
return (
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<AnimatePresence>
{rendered.map(({ user, top, left }) => (
<motion.div
key={user.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="absolute flex flex-col items-start"
style={{ top, left }}
>
{/* Cursor caret */}
<div
className="w-0.5 h-4"
style={{ backgroundColor: user.color }}
/>
{/* Name tag */}
<div
className="px-1 py-0.5 rounded text-[9px] font-semibold text-white whitespace-nowrap"
style={{ backgroundColor: user.color }}
>
{user.name}
</div>
</motion.div>
))}
</AnimatePresence>
</div>
);
}
@@ -0,0 +1,136 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
import * as Tooltip from "@radix-ui/react-tooltip";
import { Wifi, WifiOff } from "lucide-react";
import { getInitials } from "@/lib/collaboration/presence";
import { labelForRole } from "@/lib/collaboration/permissions";
import { useCollaborationContextOptional } from "./CollaborationProvider";
import { cn } from "@/lib/utils";
// ─── Single Avatar ────────────────────────────────────────────────────────────
interface AvatarProps {
name: string;
color: string;
avatar?: string;
role: import("@/lib/collaboration/socket").CollabRole;
isActive?: boolean;
}
function UserAvatar({ name, color, avatar, role, isActive = true }: AvatarProps) {
return (
<Tooltip.Provider delayDuration={300}>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div
className="relative w-7 h-7 rounded-full flex-shrink-0 cursor-default select-none"
style={{ boxShadow: `0 0 0 2px ${color}` }}
>
{avatar ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={avatar}
alt={name}
className="w-full h-full rounded-full object-cover"
/>
) : (
<div
className="w-full h-full rounded-full flex items-center justify-center text-[10px] font-semibold text-white"
style={{ backgroundColor: color }}
>
{getInitials(name)}
</div>
)}
{/* Online indicator dot */}
{isActive && (
<span className="absolute bottom-0 right-0 w-2 h-2 rounded-full bg-green-400 border border-surface-900" />
)}
</div>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
side="bottom"
sideOffset={6}
className={cn(
"z-50 rounded-md px-2.5 py-1.5 text-xs shadow-md",
"bg-surface-800 border border-surface-700 text-surface-100"
)}
>
<p className="font-medium">{name}</p>
<p className="text-surface-400">{labelForRole(role)}</p>
<Tooltip.Arrow className="fill-surface-800" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}
// ─── PresenceAvatars ──────────────────────────────────────────────────────────
export function PresenceAvatars() {
const ctx = useCollaborationContextOptional();
if (!ctx) return null;
const { isConnected, otherUsers, currentUser } = ctx;
// Show at most 4 avatars + overflow badge
const MAX_VISIBLE = 4;
const allUsers = [currentUser, ...otherUsers];
const visible = allUsers.slice(0, MAX_VISIBLE);
const overflow = allUsers.length - MAX_VISIBLE;
return (
<div className="flex items-center gap-2">
{/* Connection indicator */}
<div className="flex items-center gap-1.5">
{isConnected ? (
<Wifi className="w-3.5 h-3.5 text-green-400" />
) : (
<WifiOff className="w-3.5 h-3.5 text-surface-500 animate-pulse" />
)}
<span className="text-xs text-surface-500 hidden sm:inline">
{isConnected
? `${allUsers.length} online`
: "Reconnecting…"}
</span>
</div>
{/* Stacked avatars */}
<div className="flex items-center">
<AnimatePresence>
{visible.map((user, i) => (
<motion.div
key={user.id}
initial={{ opacity: 0, scale: 0.5, x: -8 }}
animate={{ opacity: 1, scale: 1, x: 0 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={{ duration: 0.2, delay: i * 0.04 }}
style={{ zIndex: visible.length - i, marginLeft: i === 0 ? 0 : -8 }}
>
<UserAvatar
name={user.id === currentUser.id ? `${user.name} (you)` : user.name}
color={user.color}
avatar={user.avatar}
role={user.role}
/>
</motion.div>
))}
</AnimatePresence>
{overflow > 0 && (
<div
className={cn(
"w-7 h-7 rounded-full -ml-2 z-0 flex items-center justify-center",
"bg-surface-700 border-2 border-surface-900 text-[10px] font-medium text-surface-300"
)}
>
+{overflow}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,73 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
import { useCollaborationContextOptional } from "./CollaborationProvider";
// ─── Animated dots ────────────────────────────────────────────────────────────
function Dots() {
return (
<span className="inline-flex items-end gap-0.5 h-3">
{[0, 1, 2].map((i) => (
<motion.span
key={i}
className="w-1 h-1 rounded-full bg-surface-400 inline-block"
animate={{ y: [0, -3, 0] }}
transition={{
duration: 0.6,
repeat: Infinity,
delay: i * 0.15,
ease: "easeInOut",
}}
/>
))}
</span>
);
}
// ─── TypingIndicator ──────────────────────────────────────────────────────────
export function TypingIndicator() {
const ctx = useCollaborationContextOptional();
if (!ctx) return null;
const { typingUsers } = ctx;
if (typingUsers.length === 0) return null;
let label: string;
if (typingUsers.length === 1) {
label = `${typingUsers[0].name} is typing`;
} else if (typingUsers.length === 2) {
label = `${typingUsers[0].name} and ${typingUsers[1].name} are typing`;
} else {
label = `${typingUsers.length} people are typing`;
}
return (
<AnimatePresence>
<motion.div
key="typing-indicator"
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={{ duration: 0.15 }}
className="flex items-center gap-1.5 px-4 pb-1 text-xs text-surface-400"
>
{/* Colored dots for each typing user */}
<span className="flex -space-x-1">
{typingUsers.slice(0, 3).map((u) => (
<span
key={u.id}
className="w-4 h-4 rounded-full border border-surface-900 flex items-center justify-center text-[8px] font-bold text-white"
style={{ backgroundColor: u.color }}
>
{u.name[0].toUpperCase()}
</span>
))}
</span>
<span>{label}</span>
<Dots />
</motion.div>
</AnimatePresence>
);
}
@@ -0,0 +1,249 @@
"use client";
import { useEffect, useRef, useState, useMemo } from "react";
import * as Dialog from "@radix-ui/react-dialog";
import { Search, Clock } from "lucide-react";
import { useCommandRegistry } from "@/hooks/useCommandRegistry";
import { CommandPaletteItem } from "./CommandPaletteItem";
import { SHORTCUT_CATEGORIES } from "@/lib/shortcuts";
import type { Command, ShortcutCategory } from "@/lib/shortcuts";
import { cn } from "@/lib/utils";
/** Fuzzy match: every character of query must appear in order in target */
function fuzzyMatch(target: string, query: string): boolean {
if (!query) return true;
const t = target.toLowerCase();
const q = query.toLowerCase();
let qi = 0;
for (let i = 0; i < t.length && qi < q.length; i++) {
if (t[i] === q[qi]) qi++;
}
return qi === q.length;
}
/** Score a command against a search query (higher = better) */
function score(cmd: Command, query: string): number {
const q = query.toLowerCase();
const label = cmd.label.toLowerCase();
if (label === q) return 100;
if (label.startsWith(q)) return 80;
if (label.includes(q)) return 60;
if (cmd.description.toLowerCase().includes(q)) return 40;
if (fuzzyMatch(label, q)) return 20;
return 0;
}
interface GroupedResults {
label: string;
commands: Command[];
}
export function CommandPalette() {
const {
paletteOpen,
closePalette,
commands,
runCommand,
recentCommandIds,
openHelp,
} = useCommandRegistry();
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
// Reset state when palette opens
useEffect(() => {
if (paletteOpen) {
setQuery("");
setActiveIndex(0);
// Small delay to let the dialog animate in before focusing
setTimeout(() => inputRef.current?.focus(), 10);
}
}, [paletteOpen]);
const filteredGroups = useMemo<GroupedResults[]>(() => {
if (!query.trim()) {
// Show recents first, then all categories
const recentCmds = recentCommandIds
.map((id) => commands.find((c) => c.id === id))
.filter((c): c is Command => !!c);
const groups: GroupedResults[] = [];
if (recentCmds.length > 0) {
groups.push({ label: "Recent", commands: recentCmds });
}
for (const cat of SHORTCUT_CATEGORIES) {
const catCmds = commands.filter((c) => c.category === cat);
if (catCmds.length > 0) {
groups.push({ label: cat, commands: catCmds });
}
}
return groups;
}
// Search mode: flat scored list, re-grouped by category
const scored = commands
.map((cmd) => ({ cmd, s: score(cmd, query) }))
.filter(({ s }) => s > 0)
.sort((a, b) => b.s - a.s)
.map(({ cmd }) => cmd);
if (scored.length === 0) return [];
const byCategory: Partial<Record<ShortcutCategory, Command[]>> = {};
for (const cmd of scored) {
if (!byCategory[cmd.category]) byCategory[cmd.category] = [];
byCategory[cmd.category]!.push(cmd);
}
return SHORTCUT_CATEGORIES.filter((c) => byCategory[c]?.length).map(
(c) => ({ label: c, commands: byCategory[c]! })
);
}, [query, commands, recentCommandIds]);
const flatResults = useMemo(
() => filteredGroups.flatMap((g) => g.commands),
[filteredGroups]
);
// Clamp activeIndex when results change
useEffect(() => {
setActiveIndex((i) => Math.min(i, Math.max(flatResults.length - 1, 0)));
}, [flatResults.length]);
// Scroll active item into view
useEffect(() => {
const el = listRef.current?.querySelector(`[data-index="${activeIndex}"]`);
el?.scrollIntoView({ block: "nearest" });
}, [activeIndex]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, flatResults.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
const cmd = flatResults[activeIndex];
if (cmd) {
closePalette();
runCommand(cmd.id);
}
}
};
const handleSelect = (cmd: Command) => {
closePalette();
runCommand(cmd.id);
};
let flatIdx = 0;
return (
<Dialog.Root open={paletteOpen} onOpenChange={(open) => !open && closePalette()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<Dialog.Content
className={cn(
"fixed left-1/2 top-[20%] -translate-x-1/2 z-50",
"w-full max-w-xl",
"bg-surface-900 border border-surface-700 rounded-xl shadow-2xl",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[state=closed]:slide-out-to-left-1/2 data-[state=open]:slide-in-from-left-1/2",
"data-[state=closed]:slide-out-to-top-[18%] data-[state=open]:slide-in-from-top-[18%]"
)}
onKeyDown={handleKeyDown}
aria-label="Command palette"
>
{/* Search input */}
<div className="flex items-center gap-2 px-3 py-3 border-b border-surface-800">
<Search className="w-4 h-4 text-surface-500 flex-shrink-0" />
<input
ref={inputRef}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActiveIndex(0);
}}
placeholder="Search commands..."
className={cn(
"flex-1 bg-transparent text-sm text-surface-100",
"placeholder:text-surface-500 focus:outline-none"
)}
/>
<kbd className="hidden sm:inline-flex items-center h-5 px-1.5 rounded text-[10px] font-mono bg-surface-800 border border-surface-700 text-surface-500">
Esc
</kbd>
</div>
{/* Results */}
<div
ref={listRef}
role="listbox"
className="overflow-y-auto max-h-[360px] py-1"
>
{filteredGroups.length === 0 ? (
<div className="py-10 text-center text-sm text-surface-500">
No commands found
</div>
) : (
filteredGroups.map((group) => (
<div key={group.label}>
<div className="flex items-center gap-2 px-3 py-1.5">
{group.label === "Recent" && (
<Clock className="w-3 h-3 text-surface-600" />
)}
<span className="text-[10px] font-semibold uppercase tracking-wider text-surface-600">
{group.label}
</span>
</div>
{group.commands.map((cmd) => {
const idx = flatIdx++;
return (
<div key={cmd.id} data-index={idx}>
<CommandPaletteItem
command={cmd}
isActive={idx === activeIndex}
onSelect={() => handleSelect(cmd)}
onHighlight={() => setActiveIndex(idx)}
/>
</div>
);
})}
</div>
))
)}
</div>
{/* Footer */}
<div className="flex items-center gap-4 px-3 py-2 border-t border-surface-800 text-[10px] text-surface-600">
<span className="flex items-center gap-1">
<kbd className="inline-flex items-center h-4 px-1 rounded bg-surface-800 border border-surface-700 font-mono"></kbd>
navigate
</span>
<span className="flex items-center gap-1">
<kbd className="inline-flex items-center h-4 px-1 rounded bg-surface-800 border border-surface-700 font-mono"></kbd>
select
</span>
<span className="flex items-center gap-1">
<kbd className="inline-flex items-center h-4 px-1 rounded bg-surface-800 border border-surface-700 font-mono">Esc</kbd>
close
</span>
<button
onClick={() => { closePalette(); openHelp(); }}
className="ml-auto hover:text-surface-300 transition-colors"
>
? View all shortcuts
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
@@ -0,0 +1,89 @@
"use client";
import {
MessageSquarePlus,
Trash2,
Settings,
Sun,
Search,
HelpCircle,
PanelLeftClose,
ChevronRight,
Zap,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { ShortcutBadge } from "@/components/shortcuts/ShortcutBadge";
import type { Command } from "@/lib/shortcuts";
const ICON_MAP: Record<string, LucideIcon> = {
MessageSquarePlus,
Trash2,
Settings,
Sun,
Search,
HelpCircle,
PanelLeftClose,
ChevronRight,
Zap,
};
interface CommandPaletteItemProps {
command: Command;
isActive: boolean;
onSelect: () => void;
onHighlight: () => void;
}
export function CommandPaletteItem({
command,
isActive,
onSelect,
onHighlight,
}: CommandPaletteItemProps) {
const Icon = command.icon ? (ICON_MAP[command.icon] ?? ChevronRight) : ChevronRight;
return (
<div
role="option"
aria-selected={isActive}
onClick={onSelect}
onMouseEnter={onHighlight}
className={cn(
"flex items-center gap-3 px-3 py-2.5 cursor-pointer select-none",
"transition-colors",
isActive ? "bg-brand-600/20 text-surface-100" : "text-surface-300 hover:bg-surface-800"
)}
>
<span
className={cn(
"flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center",
isActive ? "bg-brand-600/30 text-brand-400" : "bg-surface-800 text-surface-500"
)}
>
<Icon className="w-3.5 h-3.5" />
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{command.label}</p>
{command.description && (
<p className="text-xs text-surface-500 truncate">{command.description}</p>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span
className={cn(
"text-[10px] px-1.5 py-0.5 rounded border font-medium",
isActive
? "border-brand-600/40 text-brand-400 bg-brand-600/10"
: "border-surface-700 text-surface-600 bg-surface-800"
)}
>
{command.category}
</span>
{command.keys.length > 0 && <ShortcutBadge keys={command.keys} />}
</div>
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
"use client";
import * as Switch from "@radix-ui/react-switch";
import type { ExportOptions, ExportFormat } from "@/lib/types";
import { cn } from "@/lib/utils";
interface OptionRowProps {
id: string;
label: string;
description?: string;
checked: boolean;
onCheckedChange: (v: boolean) => void;
disabled?: boolean;
}
function OptionRow({ id, label, description, checked, onCheckedChange, disabled }: OptionRowProps) {
return (
<div className={cn("flex items-center justify-between gap-4 py-2", disabled && "opacity-40")}>
<label htmlFor={id} className={cn("flex flex-col gap-0.5", !disabled && "cursor-pointer")}>
<span className="text-sm text-surface-200">{label}</span>
{description && (
<span className="text-xs text-surface-500">{description}</span>
)}
</label>
<Switch.Root
id={id}
checked={checked}
onCheckedChange={onCheckedChange}
disabled={disabled}
className={cn(
"w-9 h-5 rounded-full transition-colors outline-none cursor-pointer",
"data-[state=checked]:bg-brand-600 data-[state=unchecked]:bg-surface-700",
"disabled:cursor-not-allowed"
)}
>
<Switch.Thumb className="block w-4 h-4 bg-white rounded-full shadow transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0.5" />
</Switch.Root>
</div>
);
}
interface ExportOptionsProps {
options: ExportOptions;
onChange: (opts: Partial<ExportOptions>) => void;
}
export function ExportOptionsPanel({ options, onChange }: ExportOptionsProps) {
const isJson = options.format === "json";
return (
<div className="divide-y divide-surface-800">
<OptionRow
id="opt-tool-use"
label="Include tool use"
description="Show tool calls and results in the export"
checked={options.includeToolUse}
onCheckedChange={(v) => onChange({ includeToolUse: v })}
/>
<OptionRow
id="opt-thinking"
label="Include thinking blocks"
description="Show extended thinking when present"
checked={options.includeThinking}
onCheckedChange={(v) => onChange({ includeThinking: v })}
disabled={isJson}
/>
<OptionRow
id="opt-timestamps"
label="Include timestamps"
description="Add date/time to messages and metadata"
checked={options.includeTimestamps}
onCheckedChange={(v) => onChange({ includeTimestamps: v })}
/>
<OptionRow
id="opt-file-contents"
label="Include full file contents"
description="Show complete tool result output (may be large)"
checked={options.includeFileContents}
onCheckedChange={(v) => onChange({ includeFileContents: v })}
/>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { FileText, Braces, Globe, FileDown, AlignLeft } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ExportFormat } from "@/lib/types";
interface FormatOption {
value: ExportFormat;
label: string;
description: string;
icon: React.ReactNode;
}
const FORMATS: FormatOption[] = [
{
value: "markdown",
label: "Markdown",
description: "Clean .md with code blocks and metadata",
icon: <FileText className="w-4 h-4" />,
},
{
value: "json",
label: "JSON",
description: "Full conversation data with tool use",
icon: <Braces className="w-4 h-4" />,
},
{
value: "html",
label: "HTML",
description: "Self-contained file with embedded styles",
icon: <Globe className="w-4 h-4" />,
},
{
value: "pdf",
label: "PDF",
description: "Print-to-PDF via browser dialog",
icon: <FileDown className="w-4 h-4" />,
},
{
value: "plaintext",
label: "Plain Text",
description: "Stripped of all formatting",
icon: <AlignLeft className="w-4 h-4" />,
},
];
interface FormatSelectorProps {
value: ExportFormat;
onChange: (format: ExportFormat) => void;
}
export function FormatSelector({ value, onChange }: FormatSelectorProps) {
return (
<div className="grid grid-cols-5 gap-2">
{FORMATS.map((fmt) => (
<button
key={fmt.value}
onClick={() => onChange(fmt.value)}
className={cn(
"flex flex-col items-center gap-1.5 px-2 py-3 rounded-lg border text-center transition-colors",
value === fmt.value
? "border-brand-500 bg-brand-500/10 text-brand-300"
: "border-surface-700 bg-surface-800 text-surface-400 hover:border-surface-600 hover:text-surface-200"
)}
title={fmt.description}
>
{fmt.icon}
<span className="text-xs font-medium leading-none">{fmt.label}</span>
</button>
))}
</div>
);
}
@@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import { Copy, Check, ExternalLink } from "lucide-react";
import { cn } from "@/lib/utils";
interface FileBreadcrumbProps {
path: string;
className?: string;
}
export function FileBreadcrumb({ path, className }: FileBreadcrumbProps) {
const [copied, setCopied] = useState(false);
const segments = path.split("/").filter(Boolean);
const isAbsolute = path.startsWith("/");
const handleCopy = async () => {
await navigator.clipboard.writeText(path);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
const openInVSCode = () => {
window.open(`vscode://file${isAbsolute ? path : `/${path}`}`);
};
return (
<div
className={cn(
"flex items-center gap-1 px-3 py-1.5 border-b border-surface-800 bg-surface-900/80",
"text-xs text-surface-400 min-w-0",
className
)}
>
{/* Segments */}
<div className="flex items-center gap-0.5 flex-1 min-w-0 overflow-hidden">
{isAbsolute && (
<span className="text-surface-600 flex-shrink-0">/</span>
)}
{segments.map((seg, i) => (
<span key={i} className="flex items-center gap-0.5 flex-shrink-0">
{i > 0 && <span className="text-surface-700 mx-0.5">/</span>}
<span
className={cn(
"truncate max-w-[120px]",
i === segments.length - 1
? "text-surface-200 font-medium"
: "text-surface-500 hover:text-surface-300 cursor-pointer transition-colors"
)}
title={seg}
>
{seg}
</span>
</span>
))}
</div>
{/* Actions */}
<div className="flex items-center gap-0.5 flex-shrink-0">
<button
onClick={handleCopy}
className="p-1 rounded hover:bg-surface-800 transition-colors"
title="Copy path"
>
{copied ? (
<Check className="w-3 h-3 text-green-400" />
) : (
<Copy className="w-3 h-3" />
)}
</button>
<button
onClick={openInVSCode}
className="p-1 rounded hover:bg-surface-800 transition-colors"
title="Open in VS Code"
>
<ExternalLink className="w-3 h-3" />
</button>
</div>
</div>
);
}
@@ -0,0 +1,98 @@
"use client";
import { Eye, Edit3, GitCompare } from "lucide-react";
import { useFileViewerStore, type FileTab, type FileViewMode } from "@/lib/fileViewerStore";
import { cn } from "@/lib/utils";
interface FileInfoBarProps {
tab: FileTab;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
const LANGUAGE_LABELS: Record<string, string> = {
typescript: "TypeScript",
tsx: "TSX",
javascript: "JavaScript",
jsx: "JSX",
python: "Python",
rust: "Rust",
go: "Go",
css: "CSS",
scss: "SCSS",
html: "HTML",
json: "JSON",
markdown: "Markdown",
bash: "Bash",
yaml: "YAML",
toml: "TOML",
sql: "SQL",
graphql: "GraphQL",
ruby: "Ruby",
java: "Java",
c: "C",
cpp: "C++",
csharp: "C#",
php: "PHP",
swift: "Swift",
kotlin: "Kotlin",
dockerfile: "Dockerfile",
makefile: "Makefile",
text: "Plain Text",
};
const VIEW_MODES: { mode: FileViewMode; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
{ mode: "view", label: "View", icon: Eye },
{ mode: "edit", label: "Edit", icon: Edit3 },
{ mode: "diff", label: "Diff", icon: GitCompare },
];
export function FileInfoBar({ tab }: FileInfoBarProps) {
const { setMode } = useFileViewerStore();
const lineCount = tab.content.split("\n").length;
const byteSize = new TextEncoder().encode(tab.content).length;
const langLabel = LANGUAGE_LABELS[tab.language] ?? tab.language;
return (
<div className="flex items-center justify-between px-3 py-1 border-t border-surface-800 bg-surface-950 text-xs text-surface-500">
{/* Left: file stats */}
<div className="flex items-center gap-3">
<span className="text-surface-400">{langLabel}</span>
<span>UTF-8</span>
<span>{lineCount.toLocaleString()} lines</span>
<span>{formatBytes(byteSize)}</span>
{tab.isDirty && (
<span className="text-yellow-500"> Unsaved changes</span>
)}
</div>
{/* Right: mode switcher */}
{!tab.isImage && (
<div className="flex items-center gap-0.5 bg-surface-900 rounded px-1 py-0.5">
{VIEW_MODES.map(({ mode, label, icon: Icon }) => (
<button
key={mode}
onClick={() => setMode(tab.id, mode)}
className={cn(
"flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] transition-colors",
tab.mode === mode
? "bg-surface-700 text-surface-100"
: "text-surface-500 hover:text-surface-300"
)}
disabled={mode === "diff" && !tab.diff}
title={mode === "diff" && !tab.diff ? "No diff available" : label}
>
<Icon className="w-3 h-3" />
{label}
</button>
))}
</div>
)}
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
"use client";
import { useState, useRef } from "react";
import { ZoomIn, ZoomOut, Maximize2, Image as ImageIcon } from "lucide-react";
import { cn } from "@/lib/utils";
interface ImageViewerProps {
src: string;
path: string;
}
export function ImageViewer({ src, path }: ImageViewerProps) {
const [zoom, setZoom] = useState(1);
const [fitMode, setFitMode] = useState<"fit" | "actual">("fit");
const [error, setError] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const handleZoomIn = () => setZoom((z) => Math.min(z * 1.25, 8));
const handleZoomOut = () => setZoom((z) => Math.max(z / 1.25, 0.1));
const handleFitToggle = () => {
setFitMode((m) => (m === "fit" ? "actual" : "fit"));
setZoom(1);
};
const isSvg = path.endsWith(".svg");
const hasTransparency = path.endsWith(".png") || path.endsWith(".gif") ||
path.endsWith(".webp") || path.endsWith(".svg");
if (error) {
return (
<div className="flex flex-col items-center justify-center h-full gap-3 text-surface-500">
<ImageIcon className="w-10 h-10" />
<p className="text-sm">Failed to load image</p>
<p className="text-xs text-surface-600">{path}</p>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Toolbar */}
<div className="flex items-center gap-1 px-2 py-1 border-b border-surface-800 bg-surface-900/50">
<button
onClick={handleZoomOut}
className="p-1 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 transition-colors"
title="Zoom out"
>
<ZoomOut className="w-3.5 h-3.5" />
</button>
<span className="text-xs text-surface-400 w-12 text-center">
{Math.round(zoom * 100)}%
</span>
<button
onClick={handleZoomIn}
className="p-1 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 transition-colors"
title="Zoom in"
>
<ZoomIn className="w-3.5 h-3.5" />
</button>
<div className="w-px h-4 bg-surface-800 mx-1" />
<button
onClick={handleFitToggle}
className={cn(
"flex items-center gap-1 px-2 py-0.5 rounded text-xs transition-colors",
fitMode === "fit"
? "text-brand-400 bg-brand-900/30"
: "text-surface-500 hover:text-surface-200 hover:bg-surface-800"
)}
title={fitMode === "fit" ? "Switch to actual size" : "Switch to fit width"}
>
<Maximize2 className="w-3 h-3" />
{fitMode === "fit" ? "Fit" : "Actual"}
</button>
</div>
{/* Image container */}
<div
ref={containerRef}
className="flex-1 overflow-auto flex items-center justify-center p-4"
style={{
backgroundImage: hasTransparency
? "linear-gradient(45deg, #3f3f46 25%, transparent 25%), linear-gradient(-45deg, #3f3f46 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #3f3f46 75%), linear-gradient(-45deg, transparent 75%, #3f3f46 75%)"
: undefined,
backgroundSize: hasTransparency ? "16px 16px" : undefined,
backgroundPosition: hasTransparency ? "0 0, 0 8px, 8px -8px, -8px 0px" : undefined,
backgroundColor: hasTransparency ? "#27272a" : "#1a1a1e",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={path.split("/").pop()}
onError={() => setError(true)}
style={{
transform: `scale(${zoom})`,
transformOrigin: "center center",
maxWidth: fitMode === "fit" ? "100%" : "none",
maxHeight: fitMode === "fit" ? "100%" : "none",
imageRendering: zoom > 2 ? "pixelated" : "auto",
}}
className="transition-transform duration-150 shadow-lg"
draggable={false}
/>
</div>
</div>
);
}
+250
View File
@@ -0,0 +1,250 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { X, ChevronUp, ChevronDown, Regex, CaseSensitive } from "lucide-react";
import { cn } from "@/lib/utils";
interface SearchBarProps {
content: string;
containerRef: React.RefObject<HTMLDivElement>;
onClose: () => void;
}
export function SearchBar({ content, containerRef, onClose }: SearchBarProps) {
const [query, setQuery] = useState("");
const [isRegex, setIsRegex] = useState(false);
const [caseSensitive, setCaseSensitive] = useState(false);
const [currentMatch, setCurrentMatch] = useState(0);
const [totalMatches, setTotalMatches] = useState(0);
const [hasError, setHasError] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
// Compute matches
useEffect(() => {
if (!query) {
setTotalMatches(0);
setCurrentMatch(0);
clearHighlights();
return;
}
try {
const flags = caseSensitive ? "g" : "gi";
const pattern = isRegex ? new RegExp(query, flags) : new RegExp(escapeRegex(query), flags);
const matches = Array.from(content.matchAll(pattern));
setTotalMatches(matches.length);
setCurrentMatch(matches.length > 0 ? 1 : 0);
setHasError(false);
} catch {
setHasError(true);
setTotalMatches(0);
setCurrentMatch(0);
}
}, [query, isRegex, caseSensitive, content]);
// Apply DOM highlights
useEffect(() => {
if (!containerRef.current) return;
clearHighlights();
if (!query || hasError || totalMatches === 0) return;
try {
const flags = caseSensitive ? "g" : "gi";
const pattern = isRegex ? new RegExp(query, flags) : new RegExp(escapeRegex(query), flags);
const walker = document.createTreeWalker(
containerRef.current,
NodeFilter.SHOW_TEXT,
{
acceptNode: (node) => {
// Skip nodes inside already-marked elements
if ((node.parentElement as HTMLElement)?.tagName === "MARK") {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
},
}
);
const textNodes: Text[] = [];
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
textNodes.push(node);
}
let matchIdx = 0;
// Process in reverse order to avoid position shifting
const replacements: { node: Text; ranges: { start: number; end: number; idx: number }[] }[] = [];
for (const textNode of textNodes) {
const text = textNode.textContent ?? "";
pattern.lastIndex = 0;
const nodeRanges: { start: number; end: number; idx: number }[] = [];
let m: RegExpExecArray | null;
while ((m = pattern.exec(text)) !== null) {
nodeRanges.push({ start: m.index, end: m.index + m[0].length, idx: matchIdx++ });
if (m[0].length === 0) break; // prevent infinite loop on zero-width matches
}
if (nodeRanges.length > 0) {
replacements.push({ node: textNode, ranges: nodeRanges });
}
}
// Apply replacements in document order but process ranges in reverse
for (const { node: textNode, ranges } of replacements) {
const text = textNode.textContent ?? "";
const fragment = document.createDocumentFragment();
let lastEnd = 0;
for (const { start, end, idx } of ranges) {
if (start > lastEnd) {
fragment.appendChild(document.createTextNode(text.slice(lastEnd, start)));
}
const mark = document.createElement("mark");
mark.className = cn(
"search-highlight",
idx === currentMatch - 1 ? "search-highlight-current" : ""
);
mark.textContent = text.slice(start, end);
fragment.appendChild(mark);
lastEnd = end;
}
if (lastEnd < text.length) {
fragment.appendChild(document.createTextNode(text.slice(lastEnd)));
}
textNode.parentNode?.replaceChild(fragment, textNode);
}
// Scroll current match into view
const currentEl = containerRef.current?.querySelector(".search-highlight-current");
currentEl?.scrollIntoView({ block: "center", behavior: "smooth" });
} catch {
// Ignore DOM errors
}
return () => {
if (containerRef.current) clearHighlights();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, isRegex, caseSensitive, currentMatch, totalMatches]);
function clearHighlights() {
if (!containerRef.current) return;
const marks = containerRef.current.querySelectorAll("mark.search-highlight");
marks.forEach((mark) => {
mark.replaceWith(mark.textContent ?? "");
});
// Normalize text nodes
containerRef.current.normalize();
}
const goNext = () => {
setCurrentMatch((c) => (c >= totalMatches ? 1 : c + 1));
};
const goPrev = () => {
setCurrentMatch((c) => (c <= 1 ? totalMatches : c - 1));
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.shiftKey ? goPrev() : goNext();
}
if (e.key === "Escape") {
clearHighlights();
onClose();
}
};
return (
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-surface-800 bg-surface-900/90 backdrop-blur-sm">
<div
className={cn(
"flex items-center gap-1 flex-1 bg-surface-800 rounded px-2 py-1",
hasError && "ring-1 ring-red-500/50"
)}
>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search..."
className="flex-1 bg-transparent text-xs text-surface-100 placeholder-surface-500 outline-none min-w-0"
/>
{/* Match count */}
{query && (
<span className={cn(
"text-xs flex-shrink-0",
hasError ? "text-red-400" : totalMatches === 0 ? "text-red-400" : "text-surface-400"
)}>
{hasError ? "Invalid regex" : totalMatches === 0 ? "No results" : `${currentMatch}/${totalMatches}`}
</span>
)}
{/* Toggles */}
<button
onClick={() => setCaseSensitive((v) => !v)}
className={cn(
"p-0.5 rounded transition-colors flex-shrink-0",
caseSensitive
? "text-brand-400 bg-brand-900/40"
: "text-surface-500 hover:text-surface-300"
)}
title="Case sensitive"
>
<CaseSensitive className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setIsRegex((v) => !v)}
className={cn(
"p-0.5 rounded transition-colors flex-shrink-0",
isRegex
? "text-brand-400 bg-brand-900/40"
: "text-surface-500 hover:text-surface-300"
)}
title="Use regular expression"
>
<Regex className="w-3.5 h-3.5" />
</button>
</div>
{/* Navigation */}
<button
onClick={goPrev}
disabled={totalMatches === 0}
className="p-1 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 disabled:opacity-30 transition-colors"
title="Previous match (Shift+Enter)"
>
<ChevronUp className="w-3.5 h-3.5" />
</button>
<button
onClick={goNext}
disabled={totalMatches === 0}
className="p-1 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 disabled:opacity-30 transition-colors"
title="Next match (Enter)"
>
<ChevronDown className="w-3.5 h-3.5" />
</button>
<button
onClick={() => { clearHighlights(); onClose(); }}
className="p-1 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 transition-colors"
title="Close (Escape)"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
);
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
+11 -3
View File
@@ -5,6 +5,7 @@ import { useTheme } from "./ThemeProvider";
import { useChatStore } from "@/lib/store";
import { MODELS } from "@/lib/constants";
import { cn } from "@/lib/utils";
import { NotificationCenter } from "@/components/notifications/NotificationCenter";
export function Header() {
const { theme, setTheme } = useTheme();
@@ -27,7 +28,11 @@ export function Header() {
<div className="flex items-center gap-2">
{/* Model selector */}
<label htmlFor="model-select" className="sr-only">
Model
</label>
<select
id="model-select"
value={settings.model}
onChange={(e) => updateSettings({ model: e.target.value })}
className={cn(
@@ -42,13 +47,16 @@ export function Header() {
))}
</select>
{/* Notification center */}
<NotificationCenter />
{/* Theme toggle */}
<button
onClick={() => setTheme(nextTheme)}
className="p-1.5 rounded-md text-surface-400 hover:text-surface-100 hover:bg-surface-800 transition-colors"
title={`Switch to ${nextTheme} theme`}
aria-label={`Switch to ${nextTheme} theme`}
className="p-1.5 rounded-md text-surface-400 hover:text-surface-100 hover:bg-surface-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
>
<ThemeIcon className="w-4 h-4" />
<ThemeIcon className="w-4 h-4" aria-hidden="true" />
</button>
</div>
</header>
+166 -70
View File
@@ -1,93 +1,189 @@
"use client";
import { useState } from "react";
import { Plus, MessageSquare, Trash2, Settings } from "lucide-react";
import { useEffect, useRef, useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { MessageSquare, FolderOpen, Settings, ChevronLeft, ChevronRight } from "lucide-react";
import { useChatStore } from "@/lib/store";
import { cn, formatDate, truncate } from "@/lib/utils";
import { cn } from "@/lib/utils";
import { ChatHistory } from "./ChatHistory";
import { FileExplorer } from "./FileExplorer";
import { QuickActions } from "./QuickActions";
interface SidebarProps {
className?: string;
}
const MIN_WIDTH = 200;
const MAX_WIDTH = 480;
const COLLAPSED_WIDTH = 60;
export function Sidebar({ className }: SidebarProps) {
type SidebarTab = "chats" | "history" | "files" | "settings";
const TABS: Array<{ id: SidebarTab; icon: React.ElementType; label: string }> = [
{ id: "chats", icon: MessageSquare, label: "Chats" },
{ id: "files", icon: FolderOpen, label: "Files" },
{ id: "settings", icon: Settings, label: "Settings" },
];
export function Sidebar() {
const {
conversations,
activeConversationId,
createConversation,
setActiveConversation,
deleteConversation,
sidebarOpen,
sidebarWidth,
sidebarTab,
toggleSidebar,
setSidebarWidth,
setSidebarTab,
openSettings,
} = useChatStore();
const [hoveredId, setHoveredId] = useState<string | null>(null);
const [isResizing, setIsResizing] = useState(false);
const resizeRef = useRef<{ startX: number; startWidth: number } | null>(null);
const startResize = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
resizeRef.current = { startX: e.clientX, startWidth: sidebarWidth };
setIsResizing(true);
},
[sidebarWidth]
);
useEffect(() => {
if (!isResizing) return;
const onMove = (e: MouseEvent) => {
if (!resizeRef.current) return;
const delta = e.clientX - resizeRef.current.startX;
const next = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, resizeRef.current.startWidth + delta));
setSidebarWidth(next);
};
const onUp = () => setIsResizing(false);
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
return () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
}, [isResizing, setSidebarWidth]);
// Global keyboard shortcut: Cmd/Ctrl+B
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "b") {
e.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [toggleSidebar]);
const handleTabClick = (id: SidebarTab) => {
if (id === "settings") {
openSettings();
return;
}
if (!sidebarOpen) toggleSidebar();
setSidebarTab(id);
};
return (
<aside
<motion.aside
className={cn(
"flex flex-col h-full bg-surface-900 border-r border-surface-800 w-64",
className
"hidden md:flex flex-col h-full bg-surface-900 border-r border-surface-800",
"relative flex-shrink-0 z-20",
isResizing && "select-none"
)}
animate={{ width: sidebarOpen ? sidebarWidth : COLLAPSED_WIDTH }}
transition={{ duration: 0.2, ease: "easeInOut" }}
aria-label="Navigation sidebar"
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-surface-800">
<span className="text-sm font-semibold text-surface-100">Claude Code</span>
<button
onClick={createConversation}
className="p-1.5 rounded-md text-surface-400 hover:text-surface-100 hover:bg-surface-800 transition-colors"
title="New conversation"
>
<Plus className="w-4 h-4" />
</button>
</div>
{/* Conversation list */}
<nav className="flex-1 overflow-y-auto py-2">
{conversations.length === 0 ? (
<div className="px-4 py-8 text-center text-surface-500 text-sm">
No conversations yet
</div>
) : (
conversations.map((conv) => (
{/* Top bar: app name + tabs + collapse toggle */}
<div
key={conv.id}
className={cn(
"group relative flex items-center px-3 py-2 mx-2 rounded-md cursor-pointer",
"hover:bg-surface-800 transition-colors",
activeConversationId === conv.id && "bg-surface-800"
"flex border-b border-surface-800 flex-shrink-0",
sidebarOpen ? "flex-row items-center" : "flex-col items-center py-2 gap-1"
)}
onClick={() => setActiveConversation(conv.id)}
onMouseEnter={() => setHoveredId(conv.id)}
onMouseLeave={() => setHoveredId(null)}
>
<MessageSquare className="w-3.5 h-3.5 text-surface-500 flex-shrink-0 mr-2" />
<div className="flex-1 min-w-0">
<p className="text-sm text-surface-200 truncate">
{truncate(conv.title, 30)}
</p>
<p className="text-xs text-surface-500">{formatDate(conv.updatedAt)}</p>
</div>
{hoveredId === conv.id && (
<button
onClick={(e) => {
e.stopPropagation();
deleteConversation(conv.id);
}}
className="p-1 rounded text-surface-500 hover:text-red-400 hover:bg-surface-700 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
{sidebarOpen && (
<span className="flex-1 text-sm font-semibold text-surface-100 px-4 py-3 truncate">
Claude Code
</span>
)}
</div>
))
)}
</nav>
{/* Footer */}
<div className="px-4 py-3 border-t border-surface-800">
<button className="flex items-center gap-2 text-sm text-surface-400 hover:text-surface-100 transition-colors w-full">
<Settings className="w-4 h-4" />
<span>Settings</span>
<div
className={cn(
"flex",
sidebarOpen
? "flex-row items-center gap-0.5 pr-1 py-1.5"
: "flex-col w-full px-1.5 gap-0.5"
)}
>
{TABS.map(({ id, icon: Icon, label }) => (
<button
key={id}
onClick={() => handleTabClick(id)}
title={label}
aria-label={label}
className={cn(
"flex items-center gap-2 rounded-md text-xs font-medium transition-colors",
sidebarOpen ? "px-2.5 py-1.5" : "w-full justify-center px-0 py-2",
sidebarOpen && sidebarTab === id && id !== "settings"
? "bg-surface-800 text-surface-100"
: "text-surface-500 hover:text-surface-300 hover:bg-surface-800/60"
)}
>
<Icon className="w-4 h-4 flex-shrink-0" aria-hidden="true" />
{sidebarOpen && <span>{label}</span>}
</button>
))}
</div>
<button
onClick={toggleSidebar}
title={sidebarOpen ? "Collapse sidebar (⌘B)" : "Expand sidebar (⌘B)"}
aria-label={sidebarOpen ? "Collapse sidebar" : "Expand sidebar"}
className={cn(
"p-2 rounded-md text-surface-500 hover:text-surface-300 hover:bg-surface-800/60 transition-colors",
sidebarOpen ? "mr-1" : "my-0.5"
)}
>
{sidebarOpen ? (
<ChevronLeft className="w-4 h-4" aria-hidden="true" />
) : (
<ChevronRight className="w-4 h-4" aria-hidden="true" />
)}
</button>
</div>
</aside>
{/* Tab content */}
<AnimatePresence mode="wait">
{sidebarOpen && (
<motion.div
key={sidebarTab}
className="flex-1 flex flex-col min-h-0 overflow-hidden"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
>
{(sidebarTab === "chats" || sidebarTab === "history") && <ChatHistory />}
{sidebarTab === "files" && <FileExplorer />}
</motion.div>
)}
</AnimatePresence>
{sidebarOpen && <QuickActions />}
{/* Drag-to-resize handle */}
{sidebarOpen && (
<div
onMouseDown={startResize}
role="separator"
aria-orientation="vertical"
aria-label="Resize sidebar"
className={cn(
"absolute right-0 top-0 bottom-0 w-1 cursor-col-resize z-10 transition-colors",
"hover:bg-brand-500/40",
isResizing && "bg-brand-500/60"
)}
/>
)}
</motion.aside>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { PanelLeft } from "lucide-react";
import { useChatStore } from "@/lib/store";
import { cn } from "@/lib/utils";
interface SidebarToggleProps {
className?: string;
}
export function SidebarToggle({ className }: SidebarToggleProps) {
const { sidebarOpen, toggleSidebar } = useChatStore();
return (
<button
onClick={toggleSidebar}
title={sidebarOpen ? "Close sidebar (⌘B)" : "Open sidebar (⌘B)"}
aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"}
aria-expanded={sidebarOpen}
className={cn(
"p-1.5 rounded-md text-surface-400 hover:text-surface-100 hover:bg-surface-800 transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500",
className
)}
>
<PanelLeft className="w-4 h-4" aria-hidden="true" />
</button>
);
}
+2 -1
View File
@@ -34,7 +34,8 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
const apply = () => {
const resolved = resolve();
setResolvedTheme(resolved);
document.documentElement.classList.toggle("dark", resolved === "dark");
// Dark is the default; add `.light` class for light theme
document.documentElement.classList.toggle("light", resolved === "light");
};
apply();
+100
View File
@@ -0,0 +1,100 @@
"use client";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { useTouchGesture } from "@/hooks/useTouchGesture";
interface BottomSheetProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
className?: string;
}
/**
* iOS-style bottom sheet.
* - Slides up from the bottom of the screen
* - Swipe down on the drag handle or sheet body to close
* - Tap backdrop to close
* - Locks body scroll while open
*/
export function BottomSheet({ isOpen, onClose, title, children, className }: BottomSheetProps) {
const sheetRef = useRef<HTMLDivElement>(null);
const swipeHandlers = useTouchGesture({ onSwipeDown: onClose });
// Lock body scroll while open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [isOpen]);
// Close on Escape
useEffect(() => {
if (!isOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [isOpen, onClose]);
return (
<>
{/* Backdrop */}
<div
className={cn(
"fixed inset-0 z-40 bg-black/60",
"transition-opacity duration-300",
isOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
)}
onClick={onClose}
aria-hidden="true"
/>
{/* Sheet */}
<div
ref={sheetRef}
role="dialog"
aria-modal="true"
aria-label={title ?? "Options"}
className={cn(
"fixed bottom-0 left-0 right-0 z-50",
"bg-surface-900 border-t border-surface-800 rounded-t-2xl",
"max-h-[85dvh] flex flex-col",
"transition-transform duration-300 ease-out",
isOpen ? "translate-y-0" : "translate-y-full",
className
)}
{...swipeHandlers}
>
{/* Drag handle */}
<div className="flex justify-center pt-3 pb-1 flex-shrink-0 cursor-grab active:cursor-grabbing">
<div className="w-10 h-1 bg-surface-600 rounded-full" />
</div>
{/* Optional title */}
{title && (
<div className="px-4 pb-2 flex-shrink-0">
<h2 className="text-sm font-semibold text-surface-100 text-center">{title}</h2>
</div>
)}
{/* Content */}
<div className="flex-1 overflow-y-auto overscroll-contain">
{children}
</div>
{/* iOS safe area bottom padding */}
<div className="pb-safe flex-shrink-0" style={{ paddingBottom: "env(safe-area-inset-bottom)" }} />
</div>
</>
);
}
+107
View File
@@ -0,0 +1,107 @@
"use client";
import { useEffect } from "react";
import { X, Download } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTouchGesture } from "@/hooks/useTouchGesture";
interface MobileFileViewerProps {
isOpen: boolean;
onClose: () => void;
fileName?: string;
children?: React.ReactNode;
className?: string;
}
/**
* Full-screen file viewer overlay for mobile.
* - Slides up from the bottom on open
* - Swipe down to close
* - Back/close button in header
* - Content area is scrollable with pinch-to-zoom enabled on images
*/
export function MobileFileViewer({
isOpen,
onClose,
fileName,
children,
className,
}: MobileFileViewerProps) {
const swipeHandlers = useTouchGesture({ onSwipeDown: onClose, threshold: 80 });
// Lock body scroll and close on Escape
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [isOpen, onClose]);
return (
<div
className={cn(
"fixed inset-0 z-50 bg-surface-950 flex flex-col",
"transition-transform duration-300 ease-out",
isOpen ? "translate-y-0" : "translate-y-full",
className
)}
role="dialog"
aria-modal="true"
aria-label={fileName ?? "File viewer"}
>
{/* Header — swipe-down handle zone */}
<div
className="flex items-center gap-2 px-2 border-b border-surface-800 bg-surface-900/80 backdrop-blur-sm h-[52px] flex-shrink-0"
{...swipeHandlers}
>
{/* Drag handle */}
<div className="absolute left-1/2 -translate-x-1/2 top-2 w-10 h-1 bg-surface-600 rounded-full" />
<button
onClick={onClose}
className="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-md text-surface-400 hover:text-surface-100 active:bg-surface-800 transition-colors"
aria-label="Close file viewer"
>
<X className="w-5 h-5" />
</button>
<span className="flex-1 text-sm font-medium text-surface-100 truncate">{fileName}</span>
<button
className="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-md text-surface-400 hover:text-surface-100 active:bg-surface-800 transition-colors"
aria-label="Download file"
>
<Download className="w-5 h-5" />
</button>
</div>
{/* File content — pinch-to-zoom enabled via touch-action */}
<div
className="flex-1 overflow-auto overscroll-contain"
style={{ touchAction: "pan-x pan-y pinch-zoom" }}
>
{children ?? (
<div className="flex items-center justify-center h-full text-surface-500 text-sm">
No file selected
</div>
)}
</div>
{/* iOS safe area inset */}
<div style={{ paddingBottom: "env(safe-area-inset-bottom)" }} className="flex-shrink-0" />
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { Menu, ChevronLeft } from "lucide-react";
import { cn } from "@/lib/utils";
interface MobileHeaderProps {
title?: string;
onMenuOpen: () => void;
onBack?: () => void;
right?: React.ReactNode;
className?: string;
}
/**
* Compact top bar for mobile: hamburger (or back) on the left, title in centre, optional actions on the right.
* Tap targets are at least 44×44 px per WCAG / Apple HIG guidelines.
*/
export function MobileHeader({
title = "Chat",
onMenuOpen,
onBack,
right,
className,
}: MobileHeaderProps) {
return (
<header
className={cn(
"flex items-center gap-2 px-2 border-b border-surface-800 bg-surface-900/80 backdrop-blur-sm",
"h-[52px] flex-shrink-0",
className
)}
>
{/* Left action — back or hamburger */}
{onBack ? (
<button
onClick={onBack}
className="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-md text-surface-400 hover:text-surface-100 active:bg-surface-800 transition-colors"
aria-label="Go back"
>
<ChevronLeft className="w-5 h-5" />
</button>
) : (
<button
onClick={onMenuOpen}
className="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-md text-surface-400 hover:text-surface-100 active:bg-surface-800 transition-colors"
aria-label="Open sidebar"
>
<Menu className="w-5 h-5" />
</button>
)}
{/* Title */}
<h1 className="flex-1 text-sm font-medium text-surface-100 truncate">{title}</h1>
{/* Right actions */}
{right && <div className="flex items-center">{right}</div>}
</header>
);
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import { ChatInput } from "@/components/chat/ChatInput";
interface MobileInputProps {
conversationId: string;
/** Height of the software keyboard in px — shifts input above it */
keyboardHeight: number;
}
/**
* Mobile-optimised chat input wrapper.
* Uses a paddingBottom equal to the keyboard height so the input floats
* above the virtual keyboard without relying on position:fixed (which
* breaks on iOS Safari when the keyboard is open).
*/
export function MobileInput({ conversationId, keyboardHeight }: MobileInputProps) {
return (
<div
style={{ paddingBottom: keyboardHeight }}
className="transition-[padding] duration-100"
>
<ChatInput conversationId={conversationId} />
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { useEffect } from "react";
import { cn } from "@/lib/utils";
import { Sidebar } from "@/components/layout/Sidebar";
import { useTouchGesture } from "@/hooks/useTouchGesture";
interface MobileSidebarProps {
isOpen: boolean;
onClose: () => void;
}
/**
* Slide-in drawer sidebar for mobile / tablet.
* - Opens from the left as an overlay
* - Swipe left or tap backdrop to close
* - Traps focus while open and restores on close
* - Locks body scroll while open
*/
export function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
// Swipe left on the drawer to close
const swipeHandlers = useTouchGesture({ onSwipeLeft: onClose });
// Lock body scroll while drawer is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [isOpen]);
// Close on Escape
useEffect(() => {
if (!isOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [isOpen, onClose]);
return (
<>
{/* Backdrop */}
<div
className={cn(
"fixed inset-0 z-40 bg-black/60 lg:hidden",
"transition-opacity duration-300",
isOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
)}
onClick={onClose}
aria-hidden="true"
/>
{/* Drawer */}
<div
className={cn(
"fixed top-0 left-0 bottom-0 z-50 w-72 lg:hidden",
"transition-transform duration-300 ease-in-out",
isOpen ? "translate-x-0" : "-translate-x-full"
)}
role="dialog"
aria-modal="true"
aria-label="Navigation"
{...swipeHandlers}
>
<Sidebar onNavigate={onClose} />
</div>
</>
);
}
+152
View File
@@ -0,0 +1,152 @@
"use client";
import { useRef, useState, useCallback } from "react";
import { cn } from "@/lib/utils";
interface SwipeAction {
label: string;
icon?: React.ReactNode;
onClick: () => void;
className?: string;
}
interface SwipeableRowProps {
children: React.ReactNode;
leftActions?: SwipeAction[];
rightActions?: SwipeAction[];
className?: string;
/** Width of each action button in px (default 72) */
actionWidth?: number;
}
/**
* Row that reveals swipe actions when the user drags left (right-actions)
* or right (left-actions). Used in the sidebar conversation list for
* one-swipe delete.
*/
export function SwipeableRow({
children,
leftActions = [],
rightActions = [],
className,
actionWidth = 72,
}: SwipeableRowProps) {
const [translateX, setTranslateX] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const startXRef = useRef<number | null>(null);
const currentXRef = useRef(0);
const maxLeft = leftActions.length * actionWidth;
const maxRight = rightActions.length * actionWidth;
const handleTouchStart = useCallback((e: React.TouchEvent) => {
startXRef.current = e.touches[0].clientX;
setIsDragging(true);
}, []);
const handleTouchMove = useCallback(
(e: React.TouchEvent) => {
if (startXRef.current === null) return;
const dx = e.touches[0].clientX - startXRef.current + currentXRef.current;
const clamped = Math.max(-maxRight, Math.min(maxLeft, dx));
setTranslateX(clamped);
},
[maxLeft, maxRight]
);
const handleTouchEnd = useCallback(() => {
setIsDragging(false);
startXRef.current = null;
// Snap: if dragged > half an action width, show actions; otherwise reset
if (translateX < -(actionWidth / 2) && maxRight > 0) {
const snapped = -maxRight;
setTranslateX(snapped);
currentXRef.current = snapped;
} else if (translateX > actionWidth / 2 && maxLeft > 0) {
const snapped = maxLeft;
setTranslateX(snapped);
currentXRef.current = snapped;
} else {
setTranslateX(0);
currentXRef.current = 0;
}
}, [translateX, actionWidth, maxLeft, maxRight]);
const resetPosition = useCallback(() => {
setTranslateX(0);
currentXRef.current = 0;
}, []);
return (
<div className={cn("relative overflow-hidden", className)}>
{/* Left action buttons (revealed on swipe-right) */}
{leftActions.length > 0 && (
<div
className="absolute inset-y-0 left-0 flex"
style={{ width: maxLeft }}
>
{leftActions.map((action) => (
<button
key={action.label}
onClick={() => {
action.onClick();
resetPosition();
}}
className={cn(
"flex flex-col items-center justify-center gap-1 text-xs font-medium min-w-[44px]",
"bg-brand-600 text-white",
action.className
)}
style={{ width: actionWidth }}
>
{action.icon}
{action.label}
</button>
))}
</div>
)}
{/* Right action buttons (revealed on swipe-left) */}
{rightActions.length > 0 && (
<div
className="absolute inset-y-0 right-0 flex"
style={{ width: maxRight }}
>
{rightActions.map((action) => (
<button
key={action.label}
onClick={() => {
action.onClick();
resetPosition();
}}
className={cn(
"flex flex-col items-center justify-center gap-1 text-xs font-medium min-w-[44px]",
"bg-red-600 text-white",
action.className
)}
style={{ width: actionWidth }}
>
{action.icon}
{action.label}
</button>
))}
</div>
)}
{/* Content row */}
<div
className={cn(
"relative z-10 bg-surface-900",
!isDragging && "transition-transform duration-200"
)}
style={{ transform: `translateX(${translateX}px)` }}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{children}
</div>
</div>
);
}
@@ -0,0 +1,25 @@
"use client";
import { cn } from "@/lib/utils";
interface NotificationBadgeProps {
count: number;
className?: string;
}
export function NotificationBadge({ count, className }: NotificationBadgeProps) {
if (count <= 0) return null;
return (
<span
className={cn(
"absolute -top-1 -right-1 flex items-center justify-center",
"min-w-[16px] h-4 px-1 rounded-full",
"bg-brand-500 text-white text-[10px] font-bold leading-none",
className
)}
>
{count > 99 ? "99+" : count}
</span>
);
}
@@ -0,0 +1,185 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { Bell, CheckCheck, Trash2 } from "lucide-react";
import { AnimatePresence, motion } from "framer-motion";
import { cn } from "@/lib/utils";
import { useNotifications } from "@/hooks/useNotifications";
import { NotificationBadge } from "./NotificationBadge";
import { NotificationItem } from "./NotificationItem";
import type { NotificationCategory } from "@/lib/notifications";
type FilterCategory = "all" | NotificationCategory;
const FILTER_TABS: { key: FilterCategory; label: string }[] = [
{ key: "all", label: "All" },
{ key: "error", label: "Errors" },
{ key: "activity", label: "Activity" },
{ key: "system", label: "System" },
];
export function NotificationCenter() {
const [isOpen, setIsOpen] = useState(false);
const [activeFilter, setActiveFilter] = useState<FilterCategory>("all");
const containerRef = useRef<HTMLDivElement>(null);
const { notifications, unreadCount, markRead, markAllRead, clearHistory } =
useNotifications();
// Close on click outside
useEffect(() => {
if (!isOpen) return;
const handler = (e: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [isOpen]);
// Close on Escape
useEffect(() => {
if (!isOpen) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") setIsOpen(false);
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [isOpen]);
const filtered =
activeFilter === "all"
? notifications
: notifications.filter((n) => n.category === activeFilter);
return (
<div ref={containerRef} className="relative">
{/* Bell button */}
<button
onClick={() => setIsOpen((o) => !o)}
className={cn(
"relative p-1.5 rounded-md transition-colors",
"text-surface-400 hover:text-surface-100 hover:bg-surface-800",
isOpen && "bg-surface-800 text-surface-100"
)}
aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ""}`}
>
<Bell className="w-4 h-4" />
<NotificationBadge count={unreadCount} />
</button>
{/* Panel */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: -8, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.96 }}
transition={{ duration: 0.15, ease: "easeOut" }}
className={cn(
"absolute right-0 top-full mt-2 z-50",
"w-80 rounded-lg border border-surface-700 shadow-2xl",
"bg-surface-900 overflow-hidden",
"flex flex-col"
)}
style={{ maxHeight: "480px" }}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-surface-800">
<h3 className="text-sm font-semibold text-surface-100">Notifications</h3>
<div className="flex items-center gap-1">
{unreadCount > 0 && (
<button
onClick={markAllRead}
className="p-1.5 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 transition-colors"
title="Mark all as read"
>
<CheckCheck className="w-3.5 h-3.5" />
</button>
)}
{notifications.length > 0 && (
<button
onClick={clearHistory}
className="p-1.5 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 transition-colors"
title="Clear all"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* Filter tabs */}
<div className="flex border-b border-surface-800 px-4">
{FILTER_TABS.map((tab) => {
const count =
tab.key === "all"
? notifications.length
: notifications.filter((n) => n.category === tab.key).length;
return (
<button
key={tab.key}
onClick={() => setActiveFilter(tab.key)}
className={cn(
"relative px-2 py-2.5 text-xs font-medium transition-colors mr-1",
activeFilter === tab.key
? "text-surface-100"
: "text-surface-500 hover:text-surface-300"
)}
>
{tab.label}
{count > 0 && (
<span
className={cn(
"ml-1 text-[10px] px-1 py-0.5 rounded-full",
activeFilter === tab.key
? "bg-brand-600 text-white"
: "bg-surface-700 text-surface-400"
)}
>
{count}
</span>
)}
{activeFilter === tab.key && (
<motion.div
layoutId="notification-tab-indicator"
className="absolute bottom-0 left-0 right-0 h-0.5 bg-brand-500"
/>
)}
</button>
);
})}
</div>
{/* Notification list */}
<div className="overflow-y-auto flex-1">
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 gap-2">
<Bell className="w-8 h-8 text-surface-700" />
<p className="text-sm text-surface-500">No notifications</p>
</div>
) : (
<div className="divide-y divide-surface-800/60">
{filtered.map((n) => (
<NotificationItem
key={n.id}
notification={n}
onMarkRead={markRead}
/>
))}
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
@@ -0,0 +1,99 @@
"use client";
import { XCircle, Zap, Settings, ExternalLink } from "lucide-react";
import { cn } from "@/lib/utils";
import { formatDate } from "@/lib/utils";
import type { NotificationItem as NotificationItemType } from "@/lib/notifications";
interface NotificationItemProps {
notification: NotificationItemType;
onMarkRead: (id: string) => void;
}
const CATEGORY_CONFIG = {
error: {
icon: XCircle,
iconColor: "text-red-400",
bgColor: "bg-red-500/10",
},
activity: {
icon: Zap,
iconColor: "text-brand-400",
bgColor: "bg-brand-500/10",
},
system: {
icon: Settings,
iconColor: "text-surface-400",
bgColor: "bg-surface-700/40",
},
} as const;
export function NotificationItem({ notification, onMarkRead }: NotificationItemProps) {
const config = CATEGORY_CONFIG[notification.category];
const Icon = config.icon;
const handleClick = () => {
if (!notification.read) {
onMarkRead(notification.id);
}
};
const content = (
<div
className={cn(
"flex items-start gap-3 px-4 py-3 transition-colors cursor-pointer",
"hover:bg-surface-800/60",
!notification.read && "bg-surface-800/30"
)}
onClick={handleClick}
>
{/* Icon */}
<div
className={cn(
"mt-0.5 shrink-0 w-7 h-7 rounded-full flex items-center justify-center",
config.bgColor
)}
>
<Icon className={cn("w-3.5 h-3.5", config.iconColor)} />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p
className={cn(
"text-sm leading-snug",
notification.read ? "text-surface-300" : "text-surface-100 font-medium"
)}
>
{notification.title}
</p>
<div className="flex items-center gap-1.5 shrink-0">
{!notification.read && (
<span className="w-1.5 h-1.5 rounded-full bg-brand-500 mt-1" />
)}
{notification.link && (
<ExternalLink className="w-3 h-3 text-surface-500" />
)}
</div>
</div>
<p className="text-xs text-surface-500 mt-0.5 leading-relaxed line-clamp-2">
{notification.description}
</p>
<p className="text-xs text-surface-600 mt-1">
{formatDate(notification.createdAt)}
</p>
</div>
</div>
);
if (notification.link) {
return (
<a href={notification.link} className="block no-underline">
{content}
</a>
);
}
return content;
}
+182
View File
@@ -0,0 +1,182 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import {
X,
CheckCircle2,
XCircle,
AlertTriangle,
Info,
Loader2,
ChevronDown,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { ToastItem } from "@/lib/notifications";
interface ToastProps {
toast: ToastItem;
onDismiss: (id: string) => void;
}
const VARIANT_CONFIG = {
success: {
border: "border-green-800",
bg: "bg-green-950/90",
icon: CheckCircle2,
iconColor: "text-green-400",
progress: "bg-green-500",
},
error: {
border: "border-red-800",
bg: "bg-red-950/90",
icon: XCircle,
iconColor: "text-red-400",
progress: "bg-red-500",
},
warning: {
border: "border-yellow-800",
bg: "bg-yellow-950/90",
icon: AlertTriangle,
iconColor: "text-yellow-400",
progress: "bg-yellow-500",
},
info: {
border: "border-blue-800",
bg: "bg-blue-950/90",
icon: Info,
iconColor: "text-blue-400",
progress: "bg-blue-500",
},
loading: {
border: "border-surface-700",
bg: "bg-surface-800",
icon: Loader2,
iconColor: "text-brand-400",
progress: "bg-brand-500",
},
} as const;
export function Toast({ toast, onDismiss }: ToastProps) {
const [paused, setPaused] = useState(false);
const [expanded, setExpanded] = useState(false);
const [progress, setProgress] = useState(100);
// Track remaining time across pause/resume cycles
const remainingRef = useRef(toast.duration);
const dismiss = useCallback(() => onDismiss(toast.id), [onDismiss, toast.id]);
useEffect(() => {
if (toast.duration === 0) return; // loading: never auto-dismiss
if (paused) return;
const snapRemaining = remainingRef.current;
const start = Date.now();
const interval = setInterval(() => {
const elapsed = Date.now() - start;
const newRemaining = Math.max(0, snapRemaining - elapsed);
remainingRef.current = newRemaining;
setProgress((newRemaining / toast.duration) * 100);
if (newRemaining === 0) {
clearInterval(interval);
dismiss();
}
}, 50);
return () => clearInterval(interval);
}, [paused, toast.duration, dismiss]);
const config = VARIANT_CONFIG[toast.variant];
const Icon = config.icon;
return (
<div
className={cn(
"relative flex flex-col rounded-lg shadow-xl border overflow-hidden",
"w-80 pointer-events-auto backdrop-blur-sm",
config.border,
config.bg
)}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
<div className="flex items-start gap-3 p-3.5 pb-5">
{/* Icon */}
<div className={cn("mt-0.5 shrink-0", config.iconColor)}>
<Icon
className={cn("w-4 h-4", toast.variant === "loading" && "animate-spin")}
/>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-surface-100 leading-snug">
{toast.title}
</p>
{toast.description && (
<p className="text-xs text-surface-400 mt-0.5 leading-relaxed">
{toast.description}
</p>
)}
{/* Action button */}
{toast.action && (
<button
onClick={() => {
toast.action!.onClick();
dismiss();
}}
className="mt-2 text-xs font-medium text-brand-400 hover:text-brand-300 transition-colors"
>
{toast.action.label}
</button>
)}
{/* Expandable details */}
{toast.details && (
<div className="mt-1.5">
<button
onClick={() => setExpanded((e) => !e)}
className="flex items-center gap-1 text-xs text-surface-500 hover:text-surface-300 transition-colors"
>
<ChevronDown
className={cn(
"w-3 h-3 transition-transform duration-150",
expanded && "rotate-180"
)}
/>
{expanded ? "Hide details" : "Show details"}
</button>
{expanded && (
<pre className="mt-1.5 text-xs text-surface-400 bg-surface-900/80 rounded p-2 overflow-auto max-h-24 font-mono whitespace-pre-wrap break-all">
{toast.details}
</pre>
)}
</div>
)}
</div>
{/* Dismiss button */}
<button
onClick={dismiss}
className="shrink-0 text-surface-600 hover:text-surface-200 transition-colors"
aria-label="Dismiss"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
{/* Progress bar */}
{toast.duration > 0 && (
<div className="absolute bottom-0 left-0 right-0 h-[3px] bg-surface-700/50">
<div
className={cn("h-full transition-none", config.progress)}
style={{ width: `${progress}%` }}
/>
</div>
)}
</div>
);
}
@@ -0,0 +1,12 @@
"use client";
import { ToastStack } from "./ToastStack";
export function ToastProvider({ children }: { children: React.ReactNode }) {
return (
<>
{children}
<ToastStack />
</>
);
}
@@ -0,0 +1,33 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { Toast } from "./Toast";
import { useNotificationStore } from "@/lib/notifications";
export function ToastStack() {
const toasts = useNotificationStore((s) => s.toasts);
const dismissToast = useNotificationStore((s) => s.dismissToast);
return (
<div
aria-live="polite"
aria-label="Notifications"
className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 pointer-events-none"
>
<AnimatePresence mode="popLayout" initial={false}>
{toasts.map((toast) => (
<motion.div
key={toast.id}
layout
initial={{ opacity: 0, x: 80, scale: 0.92 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 80, scale: 0.92, transition: { duration: 0.15 } }}
transition={{ type: "spring", stiffness: 380, damping: 28 }}
>
<Toast toast={toast} onDismiss={dismissToast} />
</motion.div>
))}
</AnimatePresence>
</div>
);
}
+146
View File
@@ -0,0 +1,146 @@
"use client";
import { useState } from "react";
import { Eye, EyeOff, CheckCircle, XCircle, Loader2 } from "lucide-react";
import { useChatStore } from "@/lib/store";
import { SettingRow, SectionHeader, Toggle } from "./SettingRow";
import { cn } from "@/lib/utils";
type ConnectionStatus = "idle" | "checking" | "ok" | "error";
export function ApiSettings() {
const { settings, updateSettings, resetSettings } = useChatStore();
const [showKey, setShowKey] = useState(false);
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>("idle");
const [latencyMs, setLatencyMs] = useState<number | null>(null);
async function checkConnection() {
setConnectionStatus("checking");
setLatencyMs(null);
const start = Date.now();
try {
const res = await fetch(`${settings.apiUrl}/health`, { signal: AbortSignal.timeout(5000) });
const ms = Date.now() - start;
setLatencyMs(ms);
setConnectionStatus(res.ok ? "ok" : "error");
} catch {
setConnectionStatus("error");
}
}
const statusIcon = {
idle: null,
checking: <Loader2 className="w-4 h-4 animate-spin text-surface-400" />,
ok: <CheckCircle className="w-4 h-4 text-green-400" />,
error: <XCircle className="w-4 h-4 text-red-400" />,
}[connectionStatus];
const statusText = {
idle: "Not checked",
checking: "Checking...",
ok: latencyMs !== null ? `Connected — ${latencyMs}ms` : "Connected",
error: "Connection failed",
}[connectionStatus];
return (
<div>
<SectionHeader title="API & Authentication" onReset={() => resetSettings("api")} />
<SettingRow
label="API key"
description="Your Anthropic API key. Stored locally and never sent to third parties."
stack
>
<div className="flex gap-2">
<div className="relative flex-1">
<input
type={showKey ? "text" : "password"}
value={settings.apiKey}
onChange={(e) => updateSettings({ apiKey: e.target.value })}
placeholder="sk-ant-..."
className={cn(
"w-full bg-surface-800 border border-surface-700 rounded-md px-3 py-1.5 pr-10 text-sm",
"text-surface-200 placeholder-surface-600 focus:outline-none focus:ring-1 focus:ring-brand-500 font-mono"
)}
/>
<button
onClick={() => setShowKey((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-surface-500 hover:text-surface-300 transition-colors"
title={showKey ? "Hide key" : "Show key"}
>
{showKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
{settings.apiKey && (
<p className="text-xs text-surface-500 mt-1">
Key ending in{" "}
<span className="font-mono text-surface-400">
...{settings.apiKey.slice(-4)}
</span>
</p>
)}
</SettingRow>
<SettingRow
label="API base URL"
description="Custom endpoint for enterprise or proxy setups. Leave as default for direct Anthropic access."
stack
>
<input
type="url"
value={settings.apiUrl}
onChange={(e) => updateSettings({ apiUrl: e.target.value })}
placeholder="http://localhost:3001"
className={cn(
"w-full bg-surface-800 border border-surface-700 rounded-md px-3 py-1.5 text-sm",
"text-surface-200 placeholder-surface-600 focus:outline-none focus:ring-1 focus:ring-brand-500 font-mono"
)}
/>
</SettingRow>
<SettingRow
label="Connection status"
description="Verify that the API endpoint is reachable."
>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5">
{statusIcon}
<span
className={cn(
"text-xs",
connectionStatus === "ok" && "text-green-400",
connectionStatus === "error" && "text-red-400",
connectionStatus === "idle" && "text-surface-500",
connectionStatus === "checking" && "text-surface-400"
)}
>
{statusText}
</span>
</div>
<button
onClick={checkConnection}
disabled={connectionStatus === "checking"}
className={cn(
"px-3 py-1 text-xs rounded-md border border-surface-700 transition-colors",
"text-surface-300 hover:text-surface-100 hover:bg-surface-800",
"disabled:opacity-50 disabled:cursor-not-allowed"
)}
>
Check
</button>
</div>
</SettingRow>
<SettingRow
label="Streaming"
description="Stream responses token by token as they are generated."
>
<Toggle
checked={settings.streamingEnabled}
onChange={(v) => updateSettings({ streamingEnabled: v })}
/>
</SettingRow>
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
"use client";
import { useState } from "react";
import { Download, Trash2, AlertTriangle } from "lucide-react";
import { useChatStore } from "@/lib/store";
import { SettingRow, SectionHeader, Toggle } from "./SettingRow";
import { cn } from "@/lib/utils";
export function DataSettings() {
const { settings, updateSettings, conversations, deleteConversation } = useChatStore();
const [showClearConfirm, setShowClearConfirm] = useState(false);
function exportConversations(format: "json" | "markdown") {
let content: string;
let filename: string;
const ts = new Date().toISOString().split("T")[0];
if (format === "json") {
content = JSON.stringify(conversations, null, 2);
filename = `claude-code-conversations-${ts}.json`;
} else {
content = conversations
.map((conv) => {
const messages = conv.messages
.map((m) => {
const role = m.role === "user" ? "**You**" : "**Claude**";
const text = typeof m.content === "string"
? m.content
: m.content
.filter((b) => b.type === "text")
.map((b) => (b as { type: "text"; text: string }).text)
.join("\n");
return `${role}\n\n${text}`;
})
.join("\n\n---\n\n");
return `# ${conv.title}\n\n${messages}`;
})
.join("\n\n====\n\n");
filename = `claude-code-conversations-${ts}.md`;
}
const blob = new Blob([content], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function clearAllConversations() {
const ids = conversations.map((c) => c.id);
ids.forEach((id) => deleteConversation(id));
setShowClearConfirm(false);
}
const totalMessages = conversations.reduce((sum, c) => sum + c.messages.length, 0);
return (
<div>
<SectionHeader title="Data & Privacy" />
<SettingRow
label="Export conversations"
description={`Export all ${conversations.length} conversations (${totalMessages} messages).`}
>
<div className="flex gap-2">
<button
onClick={() => exportConversations("json")}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs border",
"border-surface-700 text-surface-300 hover:text-surface-100 hover:bg-surface-800 transition-colors"
)}
>
<Download className="w-3.5 h-3.5" />
JSON
</button>
<button
onClick={() => exportConversations("markdown")}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs border",
"border-surface-700 text-surface-300 hover:text-surface-100 hover:bg-surface-800 transition-colors"
)}
>
<Download className="w-3.5 h-3.5" />
Markdown
</button>
</div>
</SettingRow>
<SettingRow
label="Clear conversation history"
description="Permanently delete all conversations. This cannot be undone."
>
{showClearConfirm ? (
<div className="flex items-center gap-2">
<span className="text-xs text-red-400 flex items-center gap-1">
<AlertTriangle className="w-3.5 h-3.5" />
Are you sure?
</span>
<button
onClick={clearAllConversations}
className="px-3 py-1.5 text-xs rounded-md bg-red-600 text-white hover:bg-red-700 transition-colors"
>
Delete all
</button>
<button
onClick={() => setShowClearConfirm(false)}
className="px-3 py-1.5 text-xs text-surface-400 hover:text-surface-200 transition-colors"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setShowClearConfirm(true)}
disabled={conversations.length === 0}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs border",
"border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors",
"disabled:opacity-40 disabled:cursor-not-allowed"
)}
>
<Trash2 className="w-3.5 h-3.5" />
Clear all
</button>
)}
</SettingRow>
<SettingRow
label="Anonymous telemetry"
description="Help improve Claude Code by sharing anonymous usage data. No conversation content is ever sent."
>
<Toggle
checked={settings.telemetryEnabled}
onChange={(v) => updateSettings({ telemetryEnabled: v })}
/>
</SettingRow>
<div className="mt-6 pt-4 border-t border-surface-800">
<p className="text-xs text-surface-500">
All data is stored locally in your browser. Claude Code does not send conversation data
to any server unless explicitly configured.
</p>
</div>
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
"use client";
import { Sun, Moon, Monitor } from "lucide-react";
import { useChatStore } from "@/lib/store";
import { useTheme } from "@/components/layout/ThemeProvider";
import { SettingRow, SectionHeader, Toggle, Slider } from "./SettingRow";
import { cn } from "@/lib/utils";
export function GeneralSettings() {
const { settings, updateSettings, resetSettings } = useChatStore();
const { setTheme } = useTheme();
const themes = [
{ id: "light" as const, label: "Light", icon: Sun },
{ id: "dark" as const, label: "Dark", icon: Moon },
{ id: "system" as const, label: "System", icon: Monitor },
];
function handleThemeChange(t: "light" | "dark" | "system") {
updateSettings({ theme: t });
setTheme(t);
}
return (
<div>
<SectionHeader title="General" onReset={() => resetSettings("general")} />
<SettingRow
label="Theme"
description="Choose the color scheme for the interface."
>
<div className="flex gap-1.5">
{themes.map(({ id, label, icon: Icon }) => (
<button
key={id}
onClick={() => handleThemeChange(id)}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors",
settings.theme === id
? "bg-brand-600 text-white"
: "bg-surface-800 text-surface-400 hover:text-surface-200"
)}
>
<Icon className="w-3.5 h-3.5" />
{label}
</button>
))}
</div>
</SettingRow>
<SettingRow
label="Chat font size"
description="Font size for messages in the chat window."
stack
>
<Slider
value={settings.fontSize.chat}
min={12}
max={20}
onChange={(v) =>
updateSettings({ fontSize: { ...settings.fontSize, chat: v } })
}
unit="px"
/>
</SettingRow>
<SettingRow
label="Code font size"
description="Font size for code blocks and inline code."
stack
>
<Slider
value={settings.fontSize.code}
min={10}
max={18}
onChange={(v) =>
updateSettings({ fontSize: { ...settings.fontSize, code: v } })
}
unit="px"
/>
</SettingRow>
<SettingRow
label="Send on Enter"
description="Press Enter to send messages. When off, use Cmd+Enter or Ctrl+Enter."
>
<Toggle
checked={settings.sendOnEnter}
onChange={(v) => updateSettings({ sendOnEnter: v })}
/>
</SettingRow>
<SettingRow
label="Show timestamps"
description="Display the time each message was sent."
>
<Toggle
checked={settings.showTimestamps}
onChange={(v) => updateSettings({ showTimestamps: v })}
/>
</SettingRow>
<SettingRow
label="Compact mode"
description="Reduce spacing between messages for higher information density."
>
<Toggle
checked={settings.compactMode}
onChange={(v) => updateSettings({ compactMode: v })}
/>
</SettingRow>
</div>
);
}
@@ -0,0 +1,161 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { RotateCcw } from "lucide-react";
import { useChatStore } from "@/lib/store";
import { SectionHeader } from "./SettingRow";
import { cn } from "@/lib/utils";
const DEFAULT_SHORTCUTS: Record<string, string> = {
"new-conversation": "Ctrl+Shift+N",
"send-message": "Enter",
"focus-input": "Ctrl+L",
"toggle-sidebar": "Ctrl+B",
"open-settings": "Ctrl+,",
"command-palette": "Ctrl+K",
};
const SHORTCUT_LABELS: Record<string, { label: string; description: string }> = {
"new-conversation": { label: "New conversation", description: "Start a fresh conversation" },
"send-message": { label: "Send message", description: "Submit the current message" },
"focus-input": { label: "Focus input", description: "Jump to the message input" },
"toggle-sidebar": { label: "Toggle sidebar", description: "Show or hide the sidebar" },
"open-settings": { label: "Open settings", description: "Open this settings panel" },
"command-palette": { label: "Command palette", description: "Open the command palette" },
};
function captureKeyCombo(e: KeyboardEvent): string {
e.preventDefault();
const parts: string[] = [];
if (e.ctrlKey || e.metaKey) parts.push("Ctrl");
if (e.altKey) parts.push("Alt");
if (e.shiftKey) parts.push("Shift");
if (e.key && !["Control", "Alt", "Shift", "Meta"].includes(e.key)) {
parts.push(e.key === " " ? "Space" : e.key);
}
return parts.join("+");
}
function ShortcutRow({
id,
binding,
isDefault,
isConflict,
onRebind,
onReset,
}: {
id: string;
binding: string;
isDefault: boolean;
isConflict: boolean;
onRebind: (combo: string) => void;
onReset: () => void;
}) {
const [listening, setListening] = useState(false);
const ref = useRef<HTMLButtonElement>(null);
const info = SHORTCUT_LABELS[id];
useEffect(() => {
if (!listening) return;
function handler(e: KeyboardEvent) {
if (e.key === "Escape") {
setListening(false);
return;
}
const combo = captureKeyCombo(e);
if (combo) {
onRebind(combo);
setListening(false);
}
}
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [listening, onRebind]);
return (
<div
className={cn(
"flex items-center justify-between py-3 border-b border-surface-800 last:border-0",
isConflict && "bg-red-500/5"
)}
>
<div className="flex-1">
<p className="text-sm text-surface-200">{info?.label ?? id}</p>
<p className="text-xs text-surface-500">{info?.description}</p>
{isConflict && (
<p className="text-xs text-red-400 mt-0.5">Conflict with another shortcut</p>
)}
</div>
<div className="flex items-center gap-2">
<button
ref={ref}
onClick={() => setListening(true)}
className={cn(
"px-3 py-1 rounded-md text-xs font-mono transition-colors border",
listening
? "bg-brand-600/20 border-brand-500 text-brand-300 animate-pulse"
: isConflict
? "bg-red-500/10 border-red-500/30 text-red-300"
: "bg-surface-800 border-surface-700 text-surface-300 hover:border-surface-600"
)}
>
{listening ? "Press keys..." : binding}
</button>
{!isDefault && (
<button
onClick={onReset}
title="Reset to default"
className="text-surface-500 hover:text-surface-300 transition-colors"
>
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
}
export function KeyboardSettings() {
const { settings, updateSettings, resetSettings } = useChatStore();
const keybindings = settings.keybindings;
// Find conflicts
const bindingValues = Object.values(keybindings);
const conflicts = new Set(
bindingValues.filter((v, i) => bindingValues.indexOf(v) !== i)
);
function rebind(id: string, combo: string) {
updateSettings({ keybindings: { ...keybindings, [id]: combo } });
}
function resetOne(id: string) {
updateSettings({
keybindings: { ...keybindings, [id]: DEFAULT_SHORTCUTS[id] },
});
}
return (
<div>
<SectionHeader title="Keyboard Shortcuts" onReset={() => resetSettings("keybindings")} />
<p className="text-xs text-surface-400 mb-4">
Click a shortcut to rebind it. Press Escape to cancel.
</p>
<div>
{Object.entries(keybindings).map(([id, binding]) => (
<ShortcutRow
key={id}
id={id}
binding={binding}
isDefault={binding === DEFAULT_SHORTCUTS[id]}
isConflict={conflicts.has(binding)}
onRebind={(combo) => rebind(id, combo)}
onReset={() => resetOne(id)}
/>
))}
</div>
</div>
);
}
+256
View File
@@ -0,0 +1,256 @@
"use client";
import { useState } from "react";
import {
Plus,
Trash2,
CheckCircle,
XCircle,
Loader2,
ChevronDown,
Circle,
} from "lucide-react";
import { nanoid } from "nanoid";
import { useChatStore } from "@/lib/store";
import type { MCPServerConfig } from "@/lib/types";
import { SectionHeader, Toggle } from "./SettingRow";
import { cn } from "@/lib/utils";
type TestStatus = "idle" | "testing" | "ok" | "error";
function ServerRow({
server,
onUpdate,
onDelete,
}: {
server: MCPServerConfig;
onUpdate: (updated: MCPServerConfig) => void;
onDelete: () => void;
}) {
const [expanded, setExpanded] = useState(false);
const [testStatus, setTestStatus] = useState<TestStatus>("idle");
async function testConnection() {
setTestStatus("testing");
// Simulate connection test — in real impl this would call an API
await new Promise((r) => setTimeout(r, 800));
setTestStatus(Math.random() > 0.3 ? "ok" : "error");
}
const statusDot = {
idle: <Circle className="w-2 h-2 text-surface-600" />,
testing: <Loader2 className="w-3 h-3 animate-spin text-surface-400" />,
ok: <CheckCircle className="w-3 h-3 text-green-400" />,
error: <XCircle className="w-3 h-3 text-red-400" />,
}[testStatus];
return (
<div className="border border-surface-800 rounded-lg overflow-hidden">
{/* Header row */}
<div className="flex items-center gap-3 px-3 py-2.5 bg-surface-800/40">
<Toggle
checked={server.enabled}
onChange={(v) => onUpdate({ ...server, enabled: v })}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-surface-200 truncate">{server.name}</p>
<p className="text-xs text-surface-500 font-mono truncate">{server.command}</p>
</div>
<div className="flex items-center gap-2">
{statusDot}
<button
onClick={testConnection}
disabled={testStatus === "testing"}
className="text-xs text-surface-400 hover:text-surface-200 transition-colors disabled:opacity-50"
>
Test
</button>
<button
onClick={() => setExpanded((v) => !v)}
className="text-surface-500 hover:text-surface-300 transition-colors"
>
<ChevronDown
className={cn("w-4 h-4 transition-transform", expanded && "rotate-180")}
/>
</button>
<button
onClick={onDelete}
className="text-surface-500 hover:text-red-400 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Expanded edit form */}
{expanded && (
<div className="px-3 py-3 space-y-2 border-t border-surface-800">
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs text-surface-400 mb-1">Name</label>
<input
value={server.name}
onChange={(e) => onUpdate({ ...server, name: e.target.value })}
className="w-full bg-surface-800 border border-surface-700 rounded px-2 py-1 text-xs text-surface-200 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
</div>
<div>
<label className="block text-xs text-surface-400 mb-1">Command</label>
<input
value={server.command}
onChange={(e) => onUpdate({ ...server, command: e.target.value })}
placeholder="npx, node, python..."
className="w-full bg-surface-800 border border-surface-700 rounded px-2 py-1 text-xs text-surface-200 font-mono focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
</div>
</div>
<div>
<label className="block text-xs text-surface-400 mb-1">
Arguments (space-separated)
</label>
<input
value={server.args.join(" ")}
onChange={(e) =>
onUpdate({
...server,
args: e.target.value.split(" ").filter(Boolean),
})
}
placeholder="-y @modelcontextprotocol/server-filesystem /path"
className="w-full bg-surface-800 border border-surface-700 rounded px-2 py-1 text-xs text-surface-200 font-mono focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
</div>
</div>
)}
</div>
);
}
export function McpSettings() {
const { settings, updateSettings } = useChatStore();
const [showAddForm, setShowAddForm] = useState(false);
const [newServer, setNewServer] = useState<Omit<MCPServerConfig, "id">>({
name: "",
command: "",
args: [],
env: {},
enabled: true,
});
function updateServer(id: string, updated: MCPServerConfig) {
updateSettings({
mcpServers: settings.mcpServers.map((s) => (s.id === id ? updated : s)),
});
}
function deleteServer(id: string) {
updateSettings({
mcpServers: settings.mcpServers.filter((s) => s.id !== id),
});
}
function addServer() {
if (!newServer.name.trim() || !newServer.command.trim()) return;
updateSettings({
mcpServers: [...settings.mcpServers, { ...newServer, id: nanoid() }],
});
setNewServer({ name: "", command: "", args: [], env: {}, enabled: true });
setShowAddForm(false);
}
return (
<div>
<SectionHeader title="MCP Servers" />
<p className="text-xs text-surface-400 mb-4">
Model Context Protocol servers extend Claude with external tools and data sources.
</p>
<div className="space-y-2 mb-4">
{settings.mcpServers.length === 0 ? (
<div className="text-center py-8 text-surface-500 text-sm">
No MCP servers configured
</div>
) : (
settings.mcpServers.map((server) => (
<ServerRow
key={server.id}
server={server}
onUpdate={(updated) => updateServer(server.id, updated)}
onDelete={() => deleteServer(server.id)}
/>
))
)}
</div>
{showAddForm ? (
<div className="border border-surface-700 rounded-lg p-3 space-y-2">
<p className="text-xs font-medium text-surface-300 mb-2">Add MCP server</p>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs text-surface-400 mb-1">Name *</label>
<input
value={newServer.name}
onChange={(e) => setNewServer((s) => ({ ...s, name: e.target.value }))}
placeholder="filesystem"
className="w-full bg-surface-800 border border-surface-700 rounded px-2 py-1 text-xs text-surface-200 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
</div>
<div>
<label className="block text-xs text-surface-400 mb-1">Command *</label>
<input
value={newServer.command}
onChange={(e) => setNewServer((s) => ({ ...s, command: e.target.value }))}
placeholder="npx"
className="w-full bg-surface-800 border border-surface-700 rounded px-2 py-1 text-xs text-surface-200 font-mono focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
</div>
</div>
<div>
<label className="block text-xs text-surface-400 mb-1">
Arguments (space-separated)
</label>
<input
value={newServer.args.join(" ")}
onChange={(e) =>
setNewServer((s) => ({
...s,
args: e.target.value.split(" ").filter(Boolean),
}))
}
placeholder="-y @modelcontextprotocol/server-filesystem /path"
className="w-full bg-surface-800 border border-surface-700 rounded px-2 py-1 text-xs text-surface-200 font-mono focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
</div>
<div className="flex justify-end gap-2 pt-1">
<button
onClick={() => setShowAddForm(false)}
className="px-3 py-1.5 text-xs text-surface-400 hover:text-surface-200 transition-colors"
>
Cancel
</button>
<button
onClick={addServer}
disabled={!newServer.name.trim() || !newServer.command.trim()}
className={cn(
"px-3 py-1.5 text-xs rounded-md bg-brand-600 text-white",
"hover:bg-brand-700 transition-colors",
"disabled:opacity-50 disabled:cursor-not-allowed"
)}
>
Add server
</button>
</div>
</div>
) : (
<button
onClick={() => setShowAddForm(true)}
className="flex items-center gap-2 text-sm text-surface-400 hover:text-surface-200 transition-colors"
>
<Plus className="w-4 h-4" />
Add server
</button>
)}
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
"use client";
import { useState } from "react";
import { ChevronDown } from "lucide-react";
import { useChatStore } from "@/lib/store";
import { MODELS } from "@/lib/constants";
import { SettingRow, SectionHeader, Slider } from "./SettingRow";
import { cn } from "@/lib/utils";
export function ModelSettings() {
const { settings, updateSettings, resetSettings } = useChatStore();
const [showAdvanced, setShowAdvanced] = useState(false);
const selectedModel = MODELS.find((m) => m.id === settings.model);
return (
<div>
<SectionHeader title="Model" onReset={() => resetSettings("model")} />
<SettingRow
label="Default model"
description="The AI model used for new conversations."
>
<select
value={settings.model}
onChange={(e) => updateSettings({ model: e.target.value })}
className={cn(
"bg-surface-800 border border-surface-700 rounded-md px-3 py-1.5 text-sm",
"text-surface-200 focus:outline-none focus:ring-1 focus:ring-brand-500"
)}
>
{MODELS.map((m) => (
<option key={m.id} value={m.id}>
{m.label} {m.description}
</option>
))}
</select>
</SettingRow>
{selectedModel && (
<div className="mb-4 px-3 py-2 rounded-md bg-surface-800/50 border border-surface-800 text-xs text-surface-400">
<span className="font-medium text-surface-300">{selectedModel.label}</span>
{" — "}{selectedModel.description}
</div>
)}
<SettingRow
label="Max tokens"
description="Maximum number of tokens in the model's response."
stack
>
<div className="flex items-center gap-3">
<Slider
value={settings.maxTokens}
min={1000}
max={200000}
step={1000}
onChange={(v) => updateSettings({ maxTokens: v })}
showValue={false}
className="flex-1"
/>
<input
type="number"
value={settings.maxTokens}
min={1000}
max={200000}
step={1000}
onChange={(e) => updateSettings({ maxTokens: Number(e.target.value) })}
className={cn(
"w-24 bg-surface-800 border border-surface-700 rounded-md px-2 py-1 text-sm text-right",
"text-surface-200 focus:outline-none focus:ring-1 focus:ring-brand-500 font-mono"
)}
/>
</div>
</SettingRow>
<SettingRow
label="System prompt"
description="Custom instructions prepended to every conversation."
stack
>
<textarea
value={settings.systemPrompt}
onChange={(e) => updateSettings({ systemPrompt: e.target.value })}
placeholder="You are a helpful assistant..."
rows={4}
className={cn(
"w-full bg-surface-800 border border-surface-700 rounded-md px-3 py-2 text-sm",
"text-surface-200 placeholder-surface-600 focus:outline-none focus:ring-1 focus:ring-brand-500",
"resize-none font-mono"
)}
/>
</SettingRow>
{/* Advanced toggle */}
<button
onClick={() => setShowAdvanced((v) => !v)}
className="flex items-center gap-1.5 text-xs text-surface-400 hover:text-surface-200 transition-colors mt-2 mb-1"
>
<ChevronDown
className={cn("w-3.5 h-3.5 transition-transform", showAdvanced && "rotate-180")}
/>
Advanced settings
</button>
{showAdvanced && (
<SettingRow
label="Temperature"
description="Controls response randomness. Higher values produce more varied output."
stack
>
<Slider
value={settings.temperature}
min={0}
max={1}
step={0.05}
onChange={(v) => updateSettings({ temperature: v })}
/>
</SettingRow>
)}
</div>
);
}
@@ -0,0 +1,128 @@
"use client";
import { useState } from "react";
import { Plus, X } from "lucide-react";
import { useChatStore } from "@/lib/store";
import { SettingRow, SectionHeader, Toggle } from "./SettingRow";
import { cn } from "@/lib/utils";
const TOOL_LABELS: Record<string, { label: string; description: string }> = {
file_read: {
label: "File read",
description: "Read files from the filesystem",
},
file_write: {
label: "File write",
description: "Create or modify files",
},
bash: {
label: "Bash commands",
description: "Execute shell commands",
},
web_search: {
label: "Web search",
description: "Search the internet",
},
};
export function PermissionSettings() {
const { settings, updateSettings, resetSettings } = useChatStore();
const [newDir, setNewDir] = useState("");
function toggleAutoApprove(tool: string, value: boolean) {
updateSettings({
permissions: {
...settings.permissions,
autoApprove: { ...settings.permissions.autoApprove, [tool]: value },
},
});
}
function addRestrictedDir() {
const dir = newDir.trim();
if (!dir || settings.permissions.restrictedDirs.includes(dir)) return;
updateSettings({
permissions: {
...settings.permissions,
restrictedDirs: [...settings.permissions.restrictedDirs, dir],
},
});
setNewDir("");
}
function removeRestrictedDir(dir: string) {
updateSettings({
permissions: {
...settings.permissions,
restrictedDirs: settings.permissions.restrictedDirs.filter((d) => d !== dir),
},
});
}
return (
<div>
<SectionHeader title="Permissions & Safety" onReset={() => resetSettings("permissions")} />
<div className="mb-4 p-3 rounded-md bg-amber-500/10 border border-amber-500/20 text-xs text-amber-300">
Auto-approving tools means Claude can perform these actions without asking for confirmation.
Use with caution.
</div>
{Object.entries(TOOL_LABELS).map(([tool, { label, description }]) => (
<SettingRow key={tool} label={label} description={description}>
<Toggle
checked={!!settings.permissions.autoApprove[tool]}
onChange={(v) => toggleAutoApprove(tool, v)}
/>
</SettingRow>
))}
<SettingRow
label="Restricted directories"
description="Limit file operations to specific directories. Leave empty for no restriction."
stack
>
<div className="space-y-2">
{settings.permissions.restrictedDirs.map((dir) => (
<div
key={dir}
className="flex items-center justify-between gap-2 px-2 py-1 rounded bg-surface-800 border border-surface-700"
>
<span className="text-xs font-mono text-surface-300 truncate">{dir}</span>
<button
onClick={() => removeRestrictedDir(dir)}
className="text-surface-500 hover:text-red-400 transition-colors flex-shrink-0"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
<div className="flex gap-2">
<input
type="text"
value={newDir}
onChange={(e) => setNewDir(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addRestrictedDir()}
placeholder="/path/to/directory"
className={cn(
"flex-1 bg-surface-800 border border-surface-700 rounded-md px-3 py-1.5 text-xs",
"text-surface-200 placeholder-surface-600 focus:outline-none focus:ring-1 focus:ring-brand-500 font-mono"
)}
/>
<button
onClick={addRestrictedDir}
className={cn(
"flex items-center gap-1 px-2.5 py-1.5 rounded-md text-xs",
"bg-surface-800 border border-surface-700 text-surface-300",
"hover:text-surface-100 hover:bg-surface-700 transition-colors"
)}
>
<Plus className="w-3.5 h-3.5" />
Add
</button>
</div>
</div>
</SettingRow>
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
"use client";
import { cn } from "@/lib/utils";
interface SettingRowProps {
label: string;
description?: string;
children: React.ReactNode;
className?: string;
stack?: boolean;
}
export function SettingRow({ label, description, children, className, stack = false }: SettingRowProps) {
return (
<div
className={cn(
"py-4 border-b border-surface-800 last:border-0",
stack ? "flex flex-col gap-3" : "flex items-start justify-between gap-6",
className
)}
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-surface-100">{label}</p>
{description && (
<p className="text-xs text-surface-400 mt-0.5 leading-relaxed">{description}</p>
)}
</div>
<div className={cn("flex-shrink-0", stack && "w-full")}>{children}</div>
</div>
);
}
interface SectionHeaderProps {
title: string;
onReset?: () => void;
}
export function SectionHeader({ title, onReset }: SectionHeaderProps) {
return (
<div className="flex items-center justify-between mb-2">
<h2 className="text-base font-semibold text-surface-100">{title}</h2>
{onReset && (
<button
onClick={onReset}
className="text-xs text-surface-400 hover:text-surface-200 transition-colors"
>
Reset to defaults
</button>
)}
</div>
);
}
interface ToggleProps {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}
export function Toggle({ checked, onChange, disabled = false }: ToggleProps) {
return (
<button
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent",
"transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 focus:ring-offset-surface-900",
"disabled:cursor-not-allowed disabled:opacity-50",
checked ? "bg-brand-600" : "bg-surface-700"
)}
>
<span
className={cn(
"pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow",
"transition duration-200 ease-in-out",
checked ? "translate-x-4" : "translate-x-0"
)}
/>
</button>
);
}
interface SliderProps {
value: number;
min: number;
max: number;
step?: number;
onChange: (value: number) => void;
showValue?: boolean;
unit?: string;
className?: string;
}
export function Slider({ value, min, max, step = 1, onChange, showValue = true, unit = "", className }: SliderProps) {
return (
<div className={cn("flex items-center gap-3", className)}>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="flex-1 h-1.5 bg-surface-700 rounded-full appearance-none cursor-pointer accent-brand-500"
/>
{showValue && (
<span className="text-xs text-surface-300 w-12 text-right font-mono">
{value}{unit}
</span>
)}
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
"use client";
import {
Settings,
Cpu,
Key,
Shield,
Server,
Keyboard,
Database,
} from "lucide-react";
import { cn } from "@/lib/utils";
export type SettingsSection =
| "general"
| "model"
| "api"
| "permissions"
| "mcp"
| "keyboard"
| "data";
interface NavItem {
id: SettingsSection;
label: string;
icon: React.ElementType;
}
const NAV_ITEMS: NavItem[] = [
{ id: "general", label: "General", icon: Settings },
{ id: "model", label: "Model", icon: Cpu },
{ id: "api", label: "API & Auth", icon: Key },
{ id: "permissions", label: "Permissions", icon: Shield },
{ id: "mcp", label: "MCP Servers", icon: Server },
{ id: "keyboard", label: "Keyboard", icon: Keyboard },
{ id: "data", label: "Data & Privacy", icon: Database },
];
interface SettingsNavProps {
active: SettingsSection;
onChange: (section: SettingsSection) => void;
searchQuery: string;
}
export function SettingsNav({ active, onChange, searchQuery }: SettingsNavProps) {
const filtered = searchQuery
? NAV_ITEMS.filter((item) =>
item.label.toLowerCase().includes(searchQuery.toLowerCase())
)
: NAV_ITEMS;
return (
<nav className="w-48 flex-shrink-0 py-2">
{filtered.map((item) => {
const Icon = item.icon;
return (
<button
key={item.id}
onClick={() => onChange(item.id)}
className={cn(
"w-full flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-colors text-left",
active === item.id
? "bg-surface-800 text-surface-100"
: "text-surface-400 hover:text-surface-200 hover:bg-surface-800/50"
)}
>
<Icon className="w-4 h-4 flex-shrink-0" />
{item.label}
</button>
);
})}
</nav>
);
}
@@ -0,0 +1,36 @@
"use client";
import { formatKeyCombo } from "@/lib/keyParser";
import { cn } from "@/lib/utils";
interface ShortcutBadgeProps {
keys: string[];
className?: string;
}
/**
* Renders the first key combo for a command as a series of <kbd> elements.
* E.g. "mod+shift+k" → [⌘] [⇧] [K]
*/
export function ShortcutBadge({ keys, className }: ShortcutBadgeProps) {
if (keys.length === 0) return null;
const parts = formatKeyCombo(keys[0]);
return (
<span className={cn("flex items-center gap-0.5", className)}>
{parts.map((part, i) => (
<kbd
key={i}
className={cn(
"inline-flex items-center justify-center",
"min-w-[1.375rem] h-5 px-1 rounded text-[10px] font-medium",
"bg-surface-800 border border-surface-600 text-surface-400",
"font-mono leading-none"
)}
>
{part}
</kbd>
))}
</span>
);
}
+113
View File
@@ -0,0 +1,113 @@
"use client";
import { useState, useMemo } from "react";
import * as Dialog from "@radix-ui/react-dialog";
import { X, Search } from "lucide-react";
import { useCommandRegistry } from "@/hooks/useCommandRegistry";
import { ShortcutBadge } from "./ShortcutBadge";
import { SHORTCUT_CATEGORIES } from "@/lib/shortcuts";
import { cn } from "@/lib/utils";
export function ShortcutsHelp() {
const { helpOpen, closeHelp, commands } = useCommandRegistry();
const [filter, setFilter] = useState("");
const groups = useMemo(() => {
const q = filter.toLowerCase();
return SHORTCUT_CATEGORIES.map((cat) => ({
category: cat,
commands: commands.filter(
(c) =>
c.category === cat &&
c.keys.length > 0 &&
(!q ||
c.label.toLowerCase().includes(q) ||
c.description.toLowerCase().includes(q))
),
})).filter((g) => g.commands.length > 0);
}, [commands, filter]);
return (
<Dialog.Root open={helpOpen} onOpenChange={(open) => !open && closeHelp()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<Dialog.Content
className={cn(
"fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50",
"w-full max-w-2xl max-h-[80vh] flex flex-col",
"bg-surface-900 border border-surface-700 rounded-xl shadow-2xl",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
)}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-surface-800 flex-shrink-0">
<div>
<Dialog.Title className="text-sm font-semibold text-surface-100">
Keyboard Shortcuts
</Dialog.Title>
<Dialog.Description className="text-xs text-surface-500 mt-0.5">
Press <kbd className="inline-flex items-center h-4 px-1 rounded bg-surface-800 border border-surface-700 text-[10px] font-mono">?</kbd> anytime to open this panel
</Dialog.Description>
</div>
<Dialog.Close className="p-1.5 rounded-md text-surface-500 hover:text-surface-100 hover:bg-surface-800 transition-colors">
<X className="w-4 h-4" />
</Dialog.Close>
</div>
{/* Search */}
<div className="px-4 py-2.5 border-b border-surface-800 flex-shrink-0">
<div className="flex items-center gap-2 bg-surface-800 rounded-lg px-3 py-1.5">
<Search className="w-3.5 h-3.5 text-surface-500 flex-shrink-0" />
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter shortcuts..."
className="flex-1 bg-transparent text-sm text-surface-100 placeholder:text-surface-500 focus:outline-none"
autoFocus
/>
</div>
</div>
{/* Shortcut groups */}
<div className="overflow-y-auto flex-1 px-4 py-3 space-y-5">
{groups.length === 0 ? (
<p className="text-center text-sm text-surface-500 py-8">
No shortcuts found
</p>
) : (
groups.map(({ category, commands: cmds }) => (
<div key={category}>
<h3 className="text-[10px] font-semibold uppercase tracking-wider text-surface-600 mb-2">
{category}
</h3>
<div className="rounded-lg border border-surface-800 overflow-hidden divide-y divide-surface-800">
{cmds.map((cmd) => (
<div
key={cmd.id}
className="flex items-center justify-between px-3 py-2 hover:bg-surface-800/50 transition-colors"
>
<div className="min-w-0 flex-1">
<p className="text-sm text-surface-200">{cmd.label}</p>
{cmd.description && (
<p className="text-xs text-surface-500 mt-0.5">{cmd.description}</p>
)}
</div>
<div className="flex flex-col items-end gap-1 ml-4 flex-shrink-0">
{cmd.keys.map((k) => (
<ShortcutBadge key={k} keys={[k]} />
))}
</div>
</div>
))}
</div>
</div>
))
)}
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
+223
View File
@@ -0,0 +1,223 @@
"use client";
import React from "react";
// 16-color ANSI palette (matches common terminal defaults)
const FG_COLORS: Record<number, string> = {
30: "#3d3d3d",
31: "#cc0000",
32: "#4e9a06",
33: "#c4a000",
34: "#3465a4",
35: "#75507b",
36: "#06989a",
37: "#d3d7cf",
90: "#555753",
91: "#ef2929",
92: "#8ae234",
93: "#fce94f",
94: "#729fcf",
95: "#ad7fa8",
96: "#34e2e2",
97: "#eeeeec",
};
const BG_COLORS: Record<number, string> = {
40: "#3d3d3d",
41: "#cc0000",
42: "#4e9a06",
43: "#c4a000",
44: "#3465a4",
45: "#75507b",
46: "#06989a",
47: "#d3d7cf",
100: "#555753",
101: "#ef2929",
102: "#8ae234",
103: "#fce94f",
104: "#729fcf",
105: "#ad7fa8",
106: "#34e2e2",
107: "#eeeeec",
};
// 256-color palette
function get256Color(n: number): string {
if (n < 16) {
const fg = FG_COLORS[n + 30] ?? FG_COLORS[n + 82]; // handle 0-7 and 8-15
if (fg) return fg;
}
if (n < 232) {
// 6×6×6 color cube
const i = n - 16;
const b = i % 6;
const g = Math.floor(i / 6) % 6;
const r = Math.floor(i / 36);
const toHex = (v: number) =>
(v === 0 ? 0 : 55 + v * 40).toString(16).padStart(2, "0");
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
// Grayscale ramp
const gray = (n - 232) * 10 + 8;
const hex = gray.toString(16).padStart(2, "0");
return `#${hex}${hex}${hex}`;
}
interface AnsiStyle {
color?: string;
background?: string;
bold?: boolean;
dim?: boolean;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
}
interface Segment {
text: string;
style: AnsiStyle;
}
function parseAnsi(input: string): Segment[] {
const segments: Segment[] = [];
let current: AnsiStyle = {};
let pos = 0;
let textStart = 0;
const pushSegment = (end: number) => {
const text = input.slice(textStart, end);
if (text) {
segments.push({ text, style: { ...current } });
}
};
while (pos < input.length) {
const esc = input.indexOf("\x1b[", pos);
if (esc === -1) break;
pushSegment(esc);
// Find the end of the escape sequence (letter terminator)
let seqEnd = esc + 2;
while (seqEnd < input.length && !/[A-Za-z]/.test(input[seqEnd])) {
seqEnd++;
}
const terminator = input[seqEnd];
const params = input.slice(esc + 2, seqEnd).split(";").map(Number);
if (terminator === "m") {
// SGR sequence
let i = 0;
while (i < params.length) {
const p = params[i];
if (p === 0 || isNaN(p)) {
current = {};
} else if (p === 1) {
current.bold = true;
} else if (p === 2) {
current.dim = true;
} else if (p === 3) {
current.italic = true;
} else if (p === 4) {
current.underline = true;
} else if (p === 9) {
current.strikethrough = true;
} else if (p === 22) {
current.bold = false;
current.dim = false;
} else if (p === 23) {
current.italic = false;
} else if (p === 24) {
current.underline = false;
} else if (p === 29) {
current.strikethrough = false;
} else if (p >= 30 && p <= 37) {
current.color = FG_COLORS[p];
} else if (p === 38) {
if (params[i + 1] === 5 && params[i + 2] !== undefined) {
current.color = get256Color(params[i + 2]);
i += 2;
} else if (
params[i + 1] === 2 &&
params[i + 2] !== undefined &&
params[i + 3] !== undefined &&
params[i + 4] !== undefined
) {
current.color = `rgb(${params[i + 2]},${params[i + 3]},${params[i + 4]})`;
i += 4;
}
} else if (p === 39) {
delete current.color;
} else if (p >= 40 && p <= 47) {
current.background = BG_COLORS[p];
} else if (p === 48) {
if (params[i + 1] === 5 && params[i + 2] !== undefined) {
current.background = get256Color(params[i + 2]);
i += 2;
} else if (
params[i + 1] === 2 &&
params[i + 2] !== undefined &&
params[i + 3] !== undefined &&
params[i + 4] !== undefined
) {
current.background = `rgb(${params[i + 2]},${params[i + 3]},${params[i + 4]})`;
i += 4;
}
} else if (p === 49) {
delete current.background;
} else if (p >= 90 && p <= 97) {
current.color = FG_COLORS[p];
} else if (p >= 100 && p <= 107) {
current.background = BG_COLORS[p];
}
i++;
}
}
pos = seqEnd + 1;
textStart = pos;
}
pushSegment(input.length);
return segments;
}
function segmentToStyle(style: AnsiStyle): React.CSSProperties {
return {
color: style.color,
backgroundColor: style.background,
fontWeight: style.bold ? "bold" : undefined,
opacity: style.dim ? 0.7 : undefined,
fontStyle: style.italic ? "italic" : undefined,
textDecoration: [
style.underline ? "underline" : "",
style.strikethrough ? "line-through" : "",
]
.filter(Boolean)
.join(" ") || undefined,
};
}
interface AnsiRendererProps {
text: string;
className?: string;
}
export function AnsiRenderer({ text, className }: AnsiRendererProps) {
const lines = text.split("\n");
return (
<span className={className}>
{lines.map((line, lineIdx) => (
<span key={lineIdx}>
{lineIdx > 0 && "\n"}
{parseAnsi(line).map((seg, segIdx) => (
<span key={segIdx} style={segmentToStyle(seg.style)}>
{seg.text}
</span>
))}
</span>
))}
</span>
);
}
+397
View File
@@ -0,0 +1,397 @@
"use client";
import { useState, useMemo } from "react";
import { Columns2, AlignLeft, Copy, Check } from "lucide-react";
import { cn } from "@/lib/utils";
import { useHighlightedCode } from "./SyntaxHighlight";
// ─── Diff algorithm ──────────────────────────────────────────────────────────
type DiffLineType = "equal" | "add" | "remove";
interface DiffLine {
type: DiffLineType;
content: string;
oldLineNo?: number;
newLineNo?: number;
}
function computeDiff(oldStr: string, newStr: string): DiffLine[] {
const oldLines = oldStr.split("\n");
const newLines = newStr.split("\n");
const m = oldLines.length;
const n = newLines.length;
// Build LCS table
const dp: Uint32Array[] = Array.from(
{ length: m + 1 },
() => new Uint32Array(n + 1)
);
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (oldLines[i - 1] === newLines[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
// Backtrack to build diff
const result: DiffLine[] = [];
let i = m;
let j = n;
let oldLineNo = m;
let newLineNo = n;
while (i > 0 || j > 0) {
if (
i > 0 &&
j > 0 &&
oldLines[i - 1] === newLines[j - 1]
) {
result.unshift({
type: "equal",
content: oldLines[i - 1],
oldLineNo: oldLineNo--,
newLineNo: newLineNo--,
});
i--;
j--;
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
result.unshift({
type: "add",
content: newLines[j - 1],
newLineNo: newLineNo--,
});
j--;
} else {
result.unshift({
type: "remove",
content: oldLines[i - 1],
oldLineNo: oldLineNo--,
});
i--;
}
}
return result;
}
// ─── Copy button ─────────────────────────────────────────────────────────────
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
return (
<button
onClick={handleCopy}
className="p-1 rounded text-surface-400 hover:text-surface-200 hover:bg-surface-700 transition-colors"
title="Copy"
>
{copied ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
</button>
);
}
// ─── Unified diff view ───────────────────────────────────────────────────────
const CONTEXT_LINES = 3;
interface UnifiedDiffProps {
lines: DiffLine[];
lang: string;
}
function UnifiedDiff({ lines, lang: _lang }: UnifiedDiffProps) {
const [expandedHunks, setExpandedHunks] = useState<Set<number>>(new Set());
// Identify collapsed regions (equal lines away from changes)
const visible = useMemo(() => {
const changed = new Set<number>();
lines.forEach((l, i) => {
if (l.type !== "equal") {
for (
let k = Math.max(0, i - CONTEXT_LINES);
k <= Math.min(lines.length - 1, i + CONTEXT_LINES);
k++
) {
changed.add(k);
}
}
});
return changed;
}, [lines]);
const items: Array<
| { kind: "line"; line: DiffLine; idx: number }
| { kind: "hunk"; start: number; end: number; count: number }
> = useMemo(() => {
const result: typeof items = [];
let i = 0;
while (i < lines.length) {
if (visible.has(i) || expandedHunks.has(i)) {
result.push({ kind: "line", line: lines[i], idx: i });
i++;
} else {
// Find the extent of the collapsed hunk
let end = i;
while (end < lines.length && !visible.has(end) && !expandedHunks.has(end)) {
end++;
}
result.push({ kind: "hunk", start: i, end, count: end - i });
i = end;
}
}
return result;
}, [lines, visible, expandedHunks]);
return (
<div className="font-mono text-xs leading-5 overflow-x-auto">
<table className="w-full border-collapse">
<tbody>
{items.map((item, idx) => {
if (item.kind === "hunk") {
return (
<tr key={`hunk-${idx}`}>
<td colSpan={3} className="bg-surface-800/50 text-center py-0.5">
<button
onClick={() => {
setExpandedHunks((prev) => {
const next = new Set(prev);
for (let k = item.start; k < item.end; k++) next.add(k);
return next;
});
}}
className="text-surface-400 hover:text-surface-200 text-xs px-2 py-0.5"
>
{item.count} unchanged line{item.count !== 1 ? "s" : ""}
</button>
</td>
</tr>
);
}
const { line } = item;
const bgClass =
line.type === "add"
? "bg-green-950/50 hover:bg-green-950/70"
: line.type === "remove"
? "bg-red-950/50 hover:bg-red-950/70"
: "hover:bg-surface-800/30";
const prefixClass =
line.type === "add"
? "text-green-400"
: line.type === "remove"
? "text-red-400"
: "text-surface-600";
const prefix =
line.type === "add" ? "+" : line.type === "remove" ? "" : " ";
return (
<tr key={`line-${idx}`} className={bgClass}>
{/* Old line number */}
<td className="select-none text-right text-surface-600 pr-2 pl-3 w-10 border-r border-surface-700/50">
{line.type !== "add" ? line.oldLineNo : ""}
</td>
{/* New line number */}
<td className="select-none text-right text-surface-600 pr-2 pl-2 w-10 border-r border-surface-700/50">
{line.type !== "remove" ? line.newLineNo : ""}
</td>
{/* Content */}
<td className="pl-3 pr-4 whitespace-pre">
<span className={cn("mr-2", prefixClass)}>{prefix}</span>
<span className="text-surface-100">{line.content}</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
// ─── Side-by-side diff ───────────────────────────────────────────────────────
interface SideBySideDiffProps {
lines: DiffLine[];
}
function SideBySideDiff({ lines }: SideBySideDiffProps) {
// Build paired columns: match adds to removes
const pairs: Array<{
left: DiffLine | null;
right: DiffLine | null;
}> = useMemo(() => {
const result: Array<{ left: DiffLine | null; right: DiffLine | null }> = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (line.type === "equal") {
result.push({ left: line, right: line });
i++;
} else if (line.type === "remove") {
// Pair with next add if exists
const next = lines[i + 1];
if (next?.type === "add") {
result.push({ left: line, right: next });
i += 2;
} else {
result.push({ left: line, right: null });
i++;
}
} else {
result.push({ left: null, right: line });
i++;
}
}
return result;
}, [lines]);
return (
<div className="font-mono text-xs leading-5 overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="text-surface-500 border-b border-surface-700">
<th colSpan={2} className="text-left pl-3 py-1 font-normal">
Before
</th>
<th colSpan={2} className="text-left pl-3 py-1 font-normal border-l border-surface-700">
After
</th>
</tr>
</thead>
<tbody>
{pairs.map((pair, idx) => (
<tr key={idx}>
{/* Left column */}
<td
className={cn(
"select-none text-right text-surface-600 pr-2 pl-3 w-10 border-r border-surface-700/50",
pair.left?.type === "remove" && "bg-red-950/50"
)}
>
{pair.left?.oldLineNo ?? ""}
</td>
<td
className={cn(
"pl-2 pr-3 whitespace-pre border-r border-surface-700",
pair.left?.type === "remove"
? "bg-red-950/50 text-red-200"
: "text-surface-300"
)}
>
{pair.left?.content ?? ""}
</td>
{/* Right column */}
<td
className={cn(
"select-none text-right text-surface-600 pr-2 pl-3 w-10 border-r border-surface-700/50",
pair.right?.type === "add" && "bg-green-950/50"
)}
>
{pair.right?.newLineNo ?? ""}
</td>
<td
className={cn(
"pl-2 pr-3 whitespace-pre",
pair.right?.type === "add"
? "bg-green-950/50 text-green-200"
: "text-surface-300"
)}
>
{pair.right?.content ?? ""}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// ─── Public component ────────────────────────────────────────────────────────
interface DiffViewProps {
oldContent: string;
newContent: string;
lang?: string;
defaultMode?: "unified" | "side-by-side";
className?: string;
}
export function DiffView({
oldContent,
newContent,
lang = "text",
defaultMode = "unified",
className,
}: DiffViewProps) {
const [mode, setMode] = useState<"unified" | "side-by-side">(defaultMode);
const lines = useMemo(
() => computeDiff(oldContent, newContent),
[oldContent, newContent]
);
const addCount = lines.filter((l) => l.type === "add").length;
const removeCount = lines.filter((l) => l.type === "remove").length;
return (
<div className={cn("rounded-lg overflow-hidden border border-surface-700", className)}>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 bg-surface-800 border-b border-surface-700">
<div className="flex items-center gap-2 text-xs">
<span className="text-green-400 font-mono">+{addCount}</span>
<span className="text-red-400 font-mono">{removeCount}</span>
</div>
<div className="flex items-center gap-1">
<CopyButton text={newContent} />
<div className="flex items-center rounded overflow-hidden border border-surface-700">
<button
onClick={() => setMode("unified")}
className={cn(
"px-2 py-1 text-xs flex items-center gap-1 transition-colors",
mode === "unified"
? "bg-brand-600 text-white"
: "text-surface-400 hover:text-surface-200 hover:bg-surface-700"
)}
>
<AlignLeft className="w-3 h-3" />
Unified
</button>
<button
onClick={() => setMode("side-by-side")}
className={cn(
"px-2 py-1 text-xs flex items-center gap-1 transition-colors",
mode === "side-by-side"
? "bg-brand-600 text-white"
: "text-surface-400 hover:text-surface-200 hover:bg-surface-700"
)}
>
<Columns2 className="w-3 h-3" />
Side by side
</button>
</div>
</div>
</div>
{/* Diff content */}
<div className="bg-surface-900 overflow-auto max-h-[480px]">
{mode === "unified" ? (
<UnifiedDiff lines={lines} lang={lang} />
) : (
<SideBySideDiff lines={lines} />
)}
</div>
</div>
);
}
+161
View File
@@ -0,0 +1,161 @@
"use client";
import { useEffect, useState, useRef } from "react";
import type { Highlighter } from "shiki";
// Singleton highlighter promise so we only init once
let highlighterPromise: Promise<Highlighter> | null = null;
async function getHighlighter(): Promise<Highlighter> {
if (!highlighterPromise) {
highlighterPromise = import("shiki").then((shiki) =>
shiki.createHighlighter({
themes: ["github-dark", "github-light"],
langs: [
"typescript",
"javascript",
"tsx",
"jsx",
"python",
"rust",
"go",
"java",
"c",
"cpp",
"ruby",
"shell",
"bash",
"json",
"yaml",
"toml",
"css",
"html",
"markdown",
"sql",
"dockerfile",
"kotlin",
"swift",
"php",
"xml",
],
})
);
}
return highlighterPromise;
}
// Map file extension to shiki language
const EXT_TO_LANG: Record<string, string> = {
ts: "typescript",
tsx: "tsx",
js: "javascript",
jsx: "jsx",
mjs: "javascript",
cjs: "javascript",
py: "python",
rs: "rust",
go: "go",
java: "java",
c: "c",
cpp: "cpp",
h: "c",
hpp: "cpp",
rb: "ruby",
sh: "bash",
bash: "bash",
zsh: "bash",
json: "json",
jsonc: "json",
yaml: "yaml",
yml: "yaml",
toml: "toml",
css: "css",
scss: "css",
html: "html",
htm: "html",
md: "markdown",
mdx: "markdown",
sql: "sql",
kt: "kotlin",
swift: "swift",
php: "php",
xml: "xml",
dockerfile: "dockerfile",
};
export function getLanguageFromPath(filePath: string): string {
const name = filePath.split("/").pop() ?? "";
if (name.toLowerCase() === "dockerfile") return "dockerfile";
const ext = name.split(".").pop()?.toLowerCase() ?? "";
return EXT_TO_LANG[ext] ?? "text";
}
interface UseHighlightedCodeOptions {
code: string;
lang: string;
theme?: "github-dark" | "github-light";
}
export function useHighlightedCode({
code,
lang,
theme = "github-dark",
}: UseHighlightedCodeOptions): string | null {
const [html, setHtml] = useState<string | null>(null);
const lastKey = useRef<string>("");
const key = `${lang}:${theme}:${code}`;
useEffect(() => {
if (lastKey.current === key) return;
lastKey.current = key;
let cancelled = false;
getHighlighter().then((hl) => {
if (cancelled) return;
try {
const highlighted = hl.codeToHtml(code, { lang, theme });
if (!cancelled) setHtml(highlighted);
} catch {
// Language not supported — fall back to plain
if (!cancelled) setHtml(null);
}
});
return () => {
cancelled = true;
};
}, [key, code, lang, theme]);
return html;
}
interface SyntaxHighlightProps {
code: string;
lang: string;
theme?: "github-dark" | "github-light";
className?: string;
}
export function SyntaxHighlight({
code,
lang,
theme = "github-dark",
className,
}: SyntaxHighlightProps) {
const html = useHighlightedCode({ code, lang, theme });
if (html) {
return (
<div
className={className}
// shiki wraps output in <pre><code> already
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
return (
<pre className={className}>
<code>{code}</code>
</pre>
);
}
+157
View File
@@ -0,0 +1,157 @@
"use client";
import { useState } from "react";
import { Copy, Check, Clock } from "lucide-react";
import { cn } from "@/lib/utils";
import { AnsiRenderer } from "./AnsiRenderer";
import { ToolUseBlock } from "./ToolUseBlock";
interface ToolBashProps {
input: {
command: string;
timeout?: number;
description?: string;
};
result?: string;
isError?: boolean;
isRunning?: boolean;
startedAt?: number;
completedAt?: number;
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
return (
<button
onClick={() => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}}
className="p-1 rounded text-surface-400 hover:text-surface-200 hover:bg-surface-700 transition-colors"
title="Copy command"
>
{copied ? (
<Check className="w-3.5 h-3.5 text-green-400" />
) : (
<Copy className="w-3.5 h-3.5" />
)}
</button>
);
}
// Parse exit code from result — bash tool often appends it
function parseExitCode(result: string): number | null {
const match = result.match(/\nExit code: (\d+)\s*$/);
if (match) return parseInt(match[1], 10);
return null;
}
function stripExitCode(result: string): string {
return result.replace(/\nExit code: \d+\s*$/, "");
}
const MAX_OUTPUT_LINES = 200;
export function ToolBash({
input,
result,
isError = false,
isRunning = false,
startedAt,
completedAt,
}: ToolBashProps) {
const [showAll, setShowAll] = useState(false);
const exitCode = result ? parseExitCode(result) : null;
const outputText = result ? stripExitCode(result) : "";
const outputLines = outputText.split("\n");
const isTruncated = !showAll && outputLines.length > MAX_OUTPUT_LINES;
const displayOutput = isTruncated
? outputLines.slice(0, MAX_OUTPUT_LINES).join("\n")
: outputText;
// Determine if it's an error (non-zero exit code or isError prop)
const hasError = isError || (exitCode !== null && exitCode !== 0);
return (
<ToolUseBlock
toolName="bash"
toolInput={input}
toolResult={result}
isError={hasError}
isRunning={isRunning}
startedAt={startedAt}
completedAt={completedAt}
>
{/* Command display */}
<div className="flex items-center gap-2 px-3 py-2 bg-surface-850 border-b border-surface-700/50">
<span className="text-brand-400 font-mono text-xs select-none">$</span>
<code className="font-mono text-xs text-surface-100 flex-1 break-all">
{input.command}
</code>
<div className="flex items-center gap-1 flex-shrink-0">
{input.timeout && (
<span
className="flex items-center gap-1 text-xs text-surface-500"
title={`Timeout: ${input.timeout}ms`}
>
<Clock className="w-3 h-3" />
{(input.timeout / 1000).toFixed(0)}s
</span>
)}
<CopyButton text={input.command} />
</div>
</div>
{/* Output */}
{isRunning ? (
<div className="px-3 py-3 font-mono text-xs text-brand-400 animate-pulse-soft">
Running
</div>
) : outputText ? (
<div>
<div
className={cn(
"overflow-auto max-h-[400px] bg-[#0d0d0d] px-3 py-3",
"font-mono text-xs leading-5 whitespace-pre"
)}
>
<AnsiRenderer text={displayOutput} />
{isTruncated && (
<button
onClick={() => setShowAll(true)}
className="mt-2 block text-brand-400 hover:text-brand-300 text-xs"
>
Show {outputLines.length - MAX_OUTPUT_LINES} more lines
</button>
)}
</div>
{/* Footer: exit code */}
{exitCode !== null && (
<div
className={cn(
"flex items-center gap-2 px-3 py-1.5 border-t border-surface-700/50",
exitCode === 0 ? "bg-surface-850" : "bg-red-950/20"
)}
>
<span className="text-xs text-surface-500">Exit code</span>
<span
className={cn(
"font-mono text-xs px-1.5 py-0.5 rounded",
exitCode === 0
? "bg-green-900/40 text-green-400"
: "bg-red-900/40 text-red-400"
)}
>
{exitCode}
</span>
</div>
)}
</div>
) : null}
</ToolUseBlock>
);
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import { ChevronRight } from "lucide-react";
import { FileIcon } from "./FileIcon";
import { DiffView } from "./DiffView";
import { getLanguageFromPath } from "./SyntaxHighlight";
import { ToolUseBlock } from "./ToolUseBlock";
interface ToolFileEditProps {
input: {
file_path: string;
old_string: string;
new_string: string;
replace_all?: boolean;
};
result?: string;
isError?: boolean;
isRunning?: boolean;
startedAt?: number;
completedAt?: number;
}
function FileBreadcrumb({ filePath }: { filePath: string }) {
const parts = filePath.replace(/^\//, "").split("/");
return (
<div className="flex items-center gap-1 font-mono text-xs text-surface-400 flex-wrap">
{parts.map((part, i) => (
<span key={i} className="flex items-center gap-1">
{i > 0 && <ChevronRight className="w-3 h-3 text-surface-600" />}
<span
className={i === parts.length - 1 ? "text-surface-200 font-medium" : ""}
>
{part}
</span>
</span>
))}
</div>
);
}
export function ToolFileEdit({
input,
result,
isError = false,
isRunning = false,
startedAt,
completedAt,
}: ToolFileEditProps) {
const lang = getLanguageFromPath(input.file_path);
return (
<ToolUseBlock
toolName="edit"
toolInput={input}
toolResult={result}
isError={isError}
isRunning={isRunning}
startedAt={startedAt}
completedAt={completedAt}
>
{/* File path header */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-surface-700/50 bg-surface-850">
<FileIcon
filePath={input.file_path}
className="w-3.5 h-3.5 text-surface-400 flex-shrink-0"
/>
<FileBreadcrumb filePath={input.file_path} />
{input.replace_all && (
<span className="ml-auto text-xs px-1.5 py-0.5 rounded bg-surface-700 text-surface-300">
replace all
</span>
)}
</div>
{/* Content */}
{isRunning ? (
<div className="px-3 py-4 text-surface-500 text-xs animate-pulse">
Editing
</div>
) : isError ? (
<div className="px-3 py-3 text-red-400 text-xs font-mono whitespace-pre-wrap">
{result}
</div>
) : (
<div className="p-3">
<DiffView
oldContent={input.old_string}
newContent={input.new_string}
lang={lang}
/>
</div>
)}
</ToolUseBlock>
);
}
+119
View File
@@ -0,0 +1,119 @@
"use client";
import { useState } from "react";
import { ChevronRight, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { FileIcon } from "./FileIcon";
import { SyntaxHighlight, getLanguageFromPath } from "./SyntaxHighlight";
import { ToolUseBlock } from "./ToolUseBlock";
interface ToolFileReadProps {
input: {
file_path: string;
offset?: number;
limit?: number;
};
result?: string;
isError?: boolean;
isRunning?: boolean;
startedAt?: number;
completedAt?: number;
}
function FileBreadcrumb({ filePath }: { filePath: string }) {
const parts = filePath.replace(/^\//, "").split("/");
return (
<div className="flex items-center gap-1 font-mono text-xs text-surface-400 flex-wrap">
{parts.map((part, i) => (
<span key={i} className="flex items-center gap-1">
{i > 0 && <ChevronRight className="w-3 h-3 text-surface-600" />}
<span
className={
i === parts.length - 1 ? "text-surface-200 font-medium" : ""
}
>
{part}
</span>
</span>
))}
</div>
);
}
const MAX_LINES_COLLAPSED = 40;
export function ToolFileRead({
input,
result,
isError = false,
isRunning = false,
startedAt,
completedAt,
}: ToolFileReadProps) {
const [showAll, setShowAll] = useState(false);
const lang = getLanguageFromPath(input.file_path);
const lines = result?.split("\n") ?? [];
const isTruncated = !showAll && lines.length > MAX_LINES_COLLAPSED;
const displayContent = isTruncated
? lines.slice(0, MAX_LINES_COLLAPSED).join("\n")
: (result ?? "");
return (
<ToolUseBlock
toolName="read"
toolInput={input}
toolResult={result}
isError={isError}
isRunning={isRunning}
startedAt={startedAt}
completedAt={completedAt}
>
{/* File path header */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-surface-700/50 bg-surface-850">
<FileIcon
filePath={input.file_path}
className="w-3.5 h-3.5 text-surface-400 flex-shrink-0"
/>
<FileBreadcrumb filePath={input.file_path} />
{(input.offset !== undefined || input.limit !== undefined) && (
<span className="ml-auto text-xs text-surface-500 flex-shrink-0">
{input.offset !== undefined && `offset: ${input.offset}`}
{input.offset !== undefined && input.limit !== undefined && " · "}
{input.limit !== undefined && `limit: ${input.limit}`}
</span>
)}
</div>
{/* Content */}
{isRunning ? (
<div className="px-3 py-4 text-surface-500 text-xs animate-pulse">
Reading
</div>
) : isError ? (
<div className="px-3 py-3 text-red-400 text-xs font-mono">{result}</div>
) : result ? (
<div className="relative">
<div className="overflow-auto max-h-[480px] [&_pre]:!bg-transparent [&_pre]:!m-0 [&_.shiki]:!bg-transparent">
<SyntaxHighlight
code={displayContent}
lang={lang}
className="text-xs [&_pre]:p-3 [&_pre]:leading-5"
/>
</div>
{isTruncated && (
<div className="flex justify-center py-2 border-t border-surface-700/50 bg-surface-850">
<button
onClick={() => setShowAll(true)}
className="text-xs text-brand-400 hover:text-brand-300 flex items-center gap-1"
>
<ChevronDown className="w-3.5 h-3.5" />
Show {lines.length - MAX_LINES_COLLAPSED} more lines
</button>
</div>
)}
</div>
) : null}
</ToolUseBlock>
);
}
+103
View File
@@ -0,0 +1,103 @@
"use client";
import { ChevronRight } from "lucide-react";
import { FileIcon } from "./FileIcon";
import { SyntaxHighlight, getLanguageFromPath } from "./SyntaxHighlight";
import { ToolUseBlock } from "./ToolUseBlock";
interface ToolFileWriteProps {
input: {
file_path: string;
content: string;
};
result?: string;
isError?: boolean;
isRunning?: boolean;
startedAt?: number;
completedAt?: number;
}
function FileBreadcrumb({ filePath }: { filePath: string }) {
const parts = filePath.replace(/^\//, "").split("/");
return (
<div className="flex items-center gap-1 font-mono text-xs text-surface-400 flex-wrap">
{parts.map((part, i) => (
<span key={i} className="flex items-center gap-1">
{i > 0 && <ChevronRight className="w-3 h-3 text-surface-600" />}
<span
className={i === parts.length - 1 ? "text-surface-200 font-medium" : ""}
>
{part}
</span>
</span>
))}
</div>
);
}
function isNewFile(result?: string): boolean {
if (!result) return false;
return /creat|new/i.test(result);
}
export function ToolFileWrite({
input,
result,
isError = false,
isRunning = false,
startedAt,
completedAt,
}: ToolFileWriteProps) {
const lang = getLanguageFromPath(input.file_path);
const lineCount = input.content.split("\n").length;
return (
<ToolUseBlock
toolName="write"
toolInput={input}
toolResult={result}
isError={isError}
isRunning={isRunning}
startedAt={startedAt}
completedAt={completedAt}
>
{/* File path header */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-surface-700/50 bg-surface-850">
<FileIcon
filePath={input.file_path}
className="w-3.5 h-3.5 text-surface-400 flex-shrink-0"
/>
<FileBreadcrumb filePath={input.file_path} />
<div className="ml-auto flex items-center gap-2 flex-shrink-0">
<span className="text-xs text-surface-500">{lineCount} lines</span>
<span
className={
isNewFile(result)
? "text-xs px-1.5 py-0.5 rounded bg-green-900/40 text-green-400 border border-green-800/50"
: "text-xs px-1.5 py-0.5 rounded bg-yellow-900/30 text-yellow-400 border border-yellow-800/40"
}
>
{isNewFile(result) ? "New file" : "Overwrite"}
</span>
</div>
</div>
{/* Content */}
{isRunning ? (
<div className="px-3 py-4 text-surface-500 text-xs animate-pulse">
Writing
</div>
) : isError ? (
<div className="px-3 py-3 text-red-400 text-xs font-mono">{result}</div>
) : (
<div className="overflow-auto max-h-[480px] [&_pre]:!bg-transparent [&_pre]:!m-0 [&_.shiki]:!bg-transparent">
<SyntaxHighlight
code={input.content}
lang={lang}
className="text-xs [&_pre]:p-3 [&_pre]:leading-5"
/>
</div>
)}
</ToolUseBlock>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
import { FileIcon } from "./FileIcon";
import { ToolUseBlock } from "./ToolUseBlock";
interface ToolGlobProps {
input: {
pattern: string;
path?: string;
};
result?: string;
isError?: boolean;
isRunning?: boolean;
startedAt?: number;
completedAt?: number;
}
function parseFilePaths(result: string): string[] {
return result
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
}
export function ToolGlob({
input,
result,
isError = false,
isRunning = false,
startedAt,
completedAt,
}: ToolGlobProps) {
const files = result ? parseFilePaths(result) : [];
const fileCount = files.length;
return (
<ToolUseBlock
toolName="glob"
toolInput={input}
toolResult={result}
isError={isError}
isRunning={isRunning}
startedAt={startedAt}
completedAt={completedAt}
>
{/* Pattern header */}
<div className="flex items-center gap-2 px-3 py-2 bg-surface-850 border-b border-surface-700/50">
<code className="font-mono text-xs text-brand-300">{input.pattern}</code>
{input.path && (
<span className="text-xs text-surface-500">in {input.path}</span>
)}
{!isRunning && fileCount > 0 && (
<span className="ml-auto text-xs text-surface-500">
{fileCount} match{fileCount !== 1 ? "es" : ""}
</span>
)}
</div>
{/* File list */}
{isRunning ? (
<div className="px-3 py-4 text-surface-500 text-xs animate-pulse">
Searching
</div>
) : isError ? (
<div className="px-3 py-3 text-red-400 text-xs font-mono">{result}</div>
) : files.length === 0 ? (
<div className="px-3 py-3 text-surface-500 text-xs">No matches found.</div>
) : (
<div className="overflow-auto max-h-[320px] py-1">
{files.map((filePath, i) => (
<div
key={i}
className="flex items-center gap-2 px-3 py-1 hover:bg-surface-800/50 transition-colors"
>
<FileIcon
filePath={filePath}
className="w-3.5 h-3.5 text-surface-400 flex-shrink-0"
/>
<span className="font-mono text-xs text-surface-200 truncate">
{filePath}
</span>
</div>
))}
</div>
)}
</ToolUseBlock>
);
}
+182
View File
@@ -0,0 +1,182 @@
"use client";
import { FileIcon } from "./FileIcon";
import { cn } from "@/lib/utils";
import { ToolUseBlock } from "./ToolUseBlock";
interface ToolGrepProps {
input: {
pattern: string;
path?: string;
glob?: string;
type?: string;
output_mode?: string;
"-i"?: boolean;
"-n"?: boolean;
context?: number;
"-A"?: number;
"-B"?: number;
"-C"?: number;
};
result?: string;
isError?: boolean;
isRunning?: boolean;
startedAt?: number;
completedAt?: number;
}
interface GrepMatch {
file: string;
lineNo?: number;
content: string;
isContext?: boolean;
}
interface GrepGroup {
file: string;
matches: GrepMatch[];
}
function parseGrepOutput(result: string): GrepGroup[] {
const lines = result.split("\n").filter(Boolean);
const groups: Map<string, GrepMatch[]> = new Map();
for (const line of lines) {
// Format: "file:lineNo:content" or "file:content" or just "file"
const colonMatch = line.match(/^([^:]+):(\d+):(.*)$/);
if (colonMatch) {
const [, file, lineNo, content] = colonMatch;
if (!groups.has(file)) groups.set(file, []);
groups.get(file)!.push({ file, lineNo: parseInt(lineNo, 10), content });
} else if (line.match(/^[^:]+$/)) {
// Files-only mode
if (!groups.has(line)) groups.set(line, []);
} else {
// fallback: treat entire line as content with unknown file
if (!groups.has("")) groups.set("", []);
groups.get("")!.push({ file: "", content: line });
}
}
return Array.from(groups.entries()).map(([file, matches]) => ({
file,
matches,
}));
}
function highlightPattern(text: string, pattern: string): React.ReactNode {
try {
const re = new RegExp(`(${pattern})`, "gi");
const parts = text.split(re);
return parts.map((part, i) =>
re.test(part) ? (
<mark key={i} className="bg-yellow-500/30 text-yellow-200 rounded-sm">
{part}
</mark>
) : (
part
)
);
} catch {
return text;
}
}
export function ToolGrep({
input,
result,
isError = false,
isRunning = false,
startedAt,
completedAt,
}: ToolGrepProps) {
const groups = result ? parseGrepOutput(result) : [];
const totalMatches = groups.reduce((sum, g) => sum + g.matches.length, 0);
const flags = [
input["-i"] && "-i",
input["-n"] !== false && "-n",
input.glob && `--glob ${input.glob}`,
input.type && `--type ${input.type}`,
input.context && `-C ${input.context}`,
]
.filter(Boolean)
.join(" ");
return (
<ToolUseBlock
toolName="grep"
toolInput={input}
toolResult={result}
isError={isError}
isRunning={isRunning}
startedAt={startedAt}
completedAt={completedAt}
>
{/* Search header */}
<div className="flex items-center gap-2 px-3 py-2 bg-surface-850 border-b border-surface-700/50 flex-wrap">
<code className="font-mono text-xs text-yellow-300">{input.pattern}</code>
{flags && <span className="text-xs text-surface-500 font-mono">{flags}</span>}
{input.path && (
<span className="text-xs text-surface-500">in {input.path}</span>
)}
{!isRunning && totalMatches > 0 && (
<span className="ml-auto text-xs text-surface-500">
{totalMatches} match{totalMatches !== 1 ? "es" : ""}
</span>
)}
</div>
{/* Results */}
{isRunning ? (
<div className="px-3 py-4 text-surface-500 text-xs animate-pulse">
Searching
</div>
) : isError ? (
<div className="px-3 py-3 text-red-400 text-xs font-mono">{result}</div>
) : groups.length === 0 ? (
<div className="px-3 py-3 text-surface-500 text-xs">No matches found.</div>
) : (
<div className="overflow-auto max-h-[400px]">
{groups.map((group, gi) => (
<div key={gi} className="border-b border-surface-700/40 last:border-0">
{/* File header */}
<div className="flex items-center gap-2 px-3 py-1.5 bg-surface-800/40 sticky top-0">
<FileIcon
filePath={group.file}
className="w-3.5 h-3.5 text-surface-400 flex-shrink-0"
/>
<span className="font-mono text-xs text-surface-300 truncate">
{group.file || "(unknown)"}
</span>
<span className="ml-auto text-xs text-surface-500 flex-shrink-0">
{group.matches.length} match{group.matches.length !== 1 ? "es" : ""}
</span>
</div>
{/* Match lines */}
{group.matches.map((match, mi) => (
<div
key={mi}
className={cn(
"flex font-mono text-xs leading-5 hover:bg-surface-800/30",
match.isContext ? "text-surface-500" : "text-surface-200"
)}
>
{match.lineNo !== undefined && (
<span className="select-none text-right text-surface-600 pr-2 pl-3 w-12 border-r border-surface-700/50 flex-shrink-0">
{match.lineNo}
</span>
)}
<span className="pl-3 pr-4 py-0.5 whitespace-pre">
{highlightPattern(match.content, input.pattern)}
</span>
</div>
))}
</div>
))}
</div>
)}
</ToolUseBlock>
);
}
+247
View File
@@ -0,0 +1,247 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
ChevronDown,
ChevronRight,
CheckCircle2,
XCircle,
Loader2,
Terminal,
FileText,
FileEdit,
FileSearch,
Search,
Globe,
BookOpen,
ClipboardList,
Bot,
Wrench,
} from "lucide-react";
import { cn } from "@/lib/utils";
// ─── Tool icon mapping ────────────────────────────────────────────────────────
const TOOL_ICONS: Record<string, React.ElementType> = {
bash: Terminal,
read: FileText,
write: FileText,
edit: FileEdit,
glob: FileSearch,
grep: Search,
webfetch: Globe,
websearch: Globe,
notebookedit: BookOpen,
todowrite: ClipboardList,
agent: Bot,
};
function getToolIcon(name: string): React.ElementType {
return TOOL_ICONS[name.toLowerCase()] ?? Wrench;
}
const TOOL_LABELS: Record<string, string> = {
bash: "Bash",
read: "Read File",
write: "Write File",
edit: "Edit File",
glob: "Glob",
grep: "Grep",
webfetch: "Web Fetch",
websearch: "Web Search",
notebookedit: "Notebook Edit",
todowrite: "Todo",
agent: "Agent",
};
function getToolLabel(name: string): string {
return TOOL_LABELS[name.toLowerCase()] ?? name;
}
// ─── Elapsed timer ────────────────────────────────────────────────────────────
function ElapsedTimer({ startMs }: { startMs: number }) {
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setElapsed(Date.now() - startMs);
}, 100);
return () => clearInterval(interval);
}, [startMs]);
if (elapsed < 1000) return <span>{elapsed}ms</span>;
return <span>{(elapsed / 1000).toFixed(1)}s</span>;
}
// ─── Status badge ─────────────────────────────────────────────────────────────
interface StatusBadgeProps {
isRunning: boolean;
isError: boolean;
startedAt: number;
completedAt?: number;
}
function StatusBadge({
isRunning,
isError,
startedAt,
completedAt,
}: StatusBadgeProps) {
if (isRunning) {
return (
<span className="flex items-center gap-1.5 text-xs text-brand-400">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<ElapsedTimer startMs={startedAt} />
</span>
);
}
const duration = completedAt ? completedAt - startedAt : null;
const durationStr = duration
? duration < 1000
? `${duration}ms`
: `${(duration / 1000).toFixed(1)}s`
: null;
if (isError) {
return (
<span className="flex items-center gap-1.5 text-xs text-red-400">
<XCircle className="w-3.5 h-3.5" />
{durationStr && <span>{durationStr}</span>}
<span>Error</span>
</span>
);
}
return (
<span className="flex items-center gap-1.5 text-xs text-green-400">
<CheckCircle2 className="w-3.5 h-3.5" />
{durationStr && <span>{durationStr}</span>}
</span>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
export interface ToolUseBlockProps {
toolName: string;
toolInput: Record<string, unknown>;
toolResult?: string | null;
isError?: boolean;
isRunning?: boolean;
startedAt?: number;
completedAt?: number;
children?: React.ReactNode;
defaultExpanded?: boolean;
}
export function ToolUseBlock({
toolName,
toolInput: _toolInput,
toolResult: _toolResult,
isError = false,
isRunning = false,
startedAt,
completedAt,
children,
defaultExpanded = false,
}: ToolUseBlockProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded || isRunning);
const startRef = useRef(startedAt ?? Date.now());
// Auto-expand while running, retain state after completion
useEffect(() => {
if (isRunning) setIsExpanded(true);
}, [isRunning]);
const Icon = getToolIcon(toolName);
const label = getToolLabel(toolName);
const borderColor = isRunning
? "border-brand-600/40"
: isError
? "border-red-800/50"
: "border-surface-700";
const headerBg = isRunning
? "bg-brand-950/30"
: isError
? "bg-red-950/20"
: "bg-surface-850";
return (
<div
className={cn(
"rounded-lg border overflow-hidden text-sm",
borderColor
)}
>
{/* Header row */}
<button
onClick={() => setIsExpanded((v) => !v)}
className={cn(
"w-full flex items-center gap-2 px-3 py-2.5 text-left transition-colors",
headerBg,
"hover:bg-surface-800"
)}
>
{/* Expand icon */}
<span className="text-surface-500 flex-shrink-0">
{isExpanded ? (
<ChevronDown className="w-3.5 h-3.5" />
) : (
<ChevronRight className="w-3.5 h-3.5" />
)}
</span>
{/* Tool icon */}
<span
className={cn(
"flex-shrink-0",
isRunning
? "text-brand-400"
: isError
? "text-red-400"
: "text-surface-400"
)}
>
<Icon className="w-4 h-4" />
</span>
{/* Tool name */}
<span className="text-surface-200 font-medium flex-1 truncate">
{label}
</span>
{/* Status */}
<StatusBadge
isRunning={isRunning}
isError={isError}
startedAt={startRef.current}
completedAt={completedAt}
/>
</button>
{/* Expandable body */}
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
key="body"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18, ease: "easeInOut" }}
style={{ overflow: "hidden" }}
>
<div className="border-t border-surface-700 bg-surface-900">
{children}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { useState } from "react";
import { ExternalLink, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
import { ToolUseBlock } from "./ToolUseBlock";
interface ToolWebFetchProps {
input: {
url: string;
prompt?: string;
};
result?: string;
isError?: boolean;
isRunning?: boolean;
startedAt?: number;
completedAt?: number;
}
// Very rough HTTP status parsing from result text
function parseStatus(result: string): number | null {
const m = result.match(/^(?:HTTP[^\n]*\s)?(\d{3})\b/m);
if (m) return parseInt(m[1], 10);
return null;
}
function StatusBadge({ code }: { code: number | null }) {
if (!code) return null;
const isOk = code >= 200 && code < 300;
return (
<span
className={cn(
"text-xs px-1.5 py-0.5 rounded font-mono",
isOk
? "bg-green-900/40 text-green-400 border border-green-800/40"
: "bg-red-900/40 text-red-400 border border-red-800/40"
)}
>
{code}
</span>
);
}
const MAX_VISIBLE = 80;
export function ToolWebFetch({
input,
result,
isError = false,
isRunning = false,
startedAt,
completedAt,
}: ToolWebFetchProps) {
const [showFull, setShowFull] = useState(false);
const status = result ? parseStatus(result) : null;
const isTruncated = !showFull && result && result.length > MAX_VISIBLE * 10;
return (
<ToolUseBlock
toolName="webfetch"
toolInput={input}
toolResult={result}
isError={isError}
isRunning={isRunning}
startedAt={startedAt}
completedAt={completedAt}
>
{/* URL header */}
<div className="flex items-center gap-2 px-3 py-2 bg-surface-850 border-b border-surface-700/50">
<a
href={input.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="font-mono text-xs text-brand-400 hover:text-brand-300 hover:underline truncate flex-1 flex items-center gap-1"
>
{input.url}
<ExternalLink className="w-3 h-3 flex-shrink-0" />
</a>
{status && <StatusBadge code={status} />}
</div>
{/* Prompt if any */}
{input.prompt && (
<div className="px-3 py-2 border-b border-surface-700/50 text-xs text-surface-400 italic">
Prompt: {input.prompt}
</div>
)}
{/* Response body */}
{isRunning ? (
<div className="px-3 py-4 text-surface-500 text-xs animate-pulse">
Fetching
</div>
) : isError ? (
<div className="px-3 py-3 text-red-400 text-xs font-mono">{result}</div>
) : result ? (
<div>
<div className="overflow-auto max-h-[400px] px-3 py-3 text-xs text-surface-300 leading-relaxed whitespace-pre-wrap font-mono">
{isTruncated ? result.slice(0, MAX_VISIBLE * 10) : result}
</div>
{isTruncated && (
<button
onClick={() => setShowFull(true)}
className="flex items-center gap-1 mx-3 mb-2 text-xs text-brand-400 hover:text-brand-300"
>
<ChevronDown className="w-3.5 h-3.5" />
Show full response ({Math.round(result.length / 1024)}KB)
</button>
)}
{showFull && (
<button
onClick={() => setShowFull(false)}
className="flex items-center gap-1 mx-3 mb-2 text-xs text-surface-400 hover:text-surface-200"
>
<ChevronUp className="w-3.5 h-3.5" />
Collapse
</button>
)}
</div>
) : null}
</ToolUseBlock>
);
}
+116
View File
@@ -0,0 +1,116 @@
"use client";
import { ExternalLink, Search } from "lucide-react";
import { ToolUseBlock } from "./ToolUseBlock";
interface SearchResult {
title: string;
url: string;
snippet: string;
}
interface ToolWebSearchProps {
input: {
query: string;
};
result?: string;
isError?: boolean;
isRunning?: boolean;
startedAt?: number;
completedAt?: number;
}
function parseSearchResults(result: string): SearchResult[] {
// Try JSON first
try {
const data = JSON.parse(result);
if (Array.isArray(data)) {
return data.map((item) => ({
title: item.title ?? item.name ?? "(no title)",
url: item.url ?? item.link ?? "",
snippet: item.snippet ?? item.description ?? item.content ?? "",
}));
}
if (data.results) return parseSearchResults(JSON.stringify(data.results));
} catch {
// not JSON
}
// Fallback: treat raw text as a single result
return [{ title: "Search Result", url: "", snippet: result }];
}
export function ToolWebSearch({
input,
result,
isError = false,
isRunning = false,
startedAt,
completedAt,
}: ToolWebSearchProps) {
const results = result && !isError ? parseSearchResults(result) : [];
return (
<ToolUseBlock
toolName="websearch"
toolInput={input}
toolResult={result}
isError={isError}
isRunning={isRunning}
startedAt={startedAt}
completedAt={completedAt}
>
{/* Query header */}
<div className="flex items-center gap-2 px-3 py-2 bg-surface-850 border-b border-surface-700/50">
<Search className="w-3.5 h-3.5 text-surface-500 flex-shrink-0" />
<span className="text-sm text-surface-200 flex-1">{input.query}</span>
{!isRunning && results.length > 0 && (
<span className="text-xs text-surface-500">
{results.length} result{results.length !== 1 ? "s" : ""}
</span>
)}
</div>
{/* Results */}
{isRunning ? (
<div className="px-3 py-4 text-surface-500 text-xs animate-pulse">
Searching
</div>
) : isError ? (
<div className="px-3 py-3 text-red-400 text-xs font-mono">{result}</div>
) : results.length === 0 ? (
<div className="px-3 py-3 text-surface-500 text-xs">No results.</div>
) : (
<div className="overflow-auto max-h-[480px] divide-y divide-surface-700/40">
{results.map((r, i) => (
<div key={i} className="px-3 py-3 hover:bg-surface-800/30 transition-colors">
{r.url ? (
<a
href={r.url}
target="_blank"
rel="noopener noreferrer"
className="group flex flex-col gap-1"
>
<div className="flex items-center gap-1">
<span className="text-sm font-medium text-brand-400 group-hover:text-brand-300 group-hover:underline">
{r.title}
</span>
<ExternalLink className="w-3 h-3 text-surface-500 flex-shrink-0" />
</div>
<span className="text-xs text-surface-500 truncate">{r.url}</span>
</a>
) : (
<span className="text-sm font-medium text-surface-200">{r.title}</span>
)}
{r.snippet && (
<p className="mt-1 text-xs text-surface-400 leading-relaxed line-clamp-3">
{r.snippet}
</p>
)}
</div>
))}
</div>
)}
</ToolUseBlock>
);
}
+4 -64
View File
@@ -1,66 +1,6 @@
"use client";
import * as Toast from "@radix-ui/react-toast";
import { createContext, useContext, useState, useCallback } from "react";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
interface ToastMessage {
id: string;
title: string;
description?: string;
variant?: "default" | "destructive";
}
interface ToastContextValue {
toast: (message: Omit<ToastMessage, "id">) => void;
}
const ToastContext = createContext<ToastContextValue>({ toast: () => {} });
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<ToastMessage[]>([]);
const toast = useCallback((message: Omit<ToastMessage, "id">) => {
const id = Math.random().toString(36).slice(2);
setToasts((prev) => [...prev, { ...message, id }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 5000);
}, []);
return (
<ToastContext.Provider value={{ toast }}>
<Toast.Provider swipeDirection="right">
{children}
{toasts.map((t) => (
<Toast.Root
key={t.id}
className={cn(
"flex items-start gap-3 p-4 rounded-lg shadow-lg border",
"bg-surface-800 border-surface-700 text-surface-100",
"data-[state=open]:animate-slide-up",
t.variant === "destructive" && "border-red-800 bg-red-950"
)}
open
>
<div className="flex-1">
<Toast.Title className="text-sm font-medium">{t.title}</Toast.Title>
{t.description && (
<Toast.Description className="text-xs text-surface-400 mt-0.5">
{t.description}
</Toast.Description>
)}
</div>
<Toast.Close className="text-surface-500 hover:text-surface-100">
<X className="w-4 h-4" />
</Toast.Close>
</Toast.Root>
))}
<Toast.Viewport className="fixed bottom-4 right-4 flex flex-col gap-2 w-80 z-50" />
</Toast.Provider>
</ToastContext.Provider>
);
}
export const useToast = () => useContext(ToastContext);
// Re-exports for backwards compatibility.
// The notification system lives in web/components/notifications/ and web/lib/notifications.ts.
export { ToastProvider } from "@/components/notifications/ToastProvider";
export { useToast } from "@/hooks/useToast";
+82
View File
@@ -0,0 +1,82 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const avatarVariants = cva(
'relative inline-flex items-center justify-center rounded-full overflow-hidden font-medium select-none flex-shrink-0',
{
variants: {
size: {
xs: 'h-6 w-6 text-[10px]',
sm: 'h-8 w-8 text-xs',
md: 'h-10 w-10 text-sm',
lg: 'h-12 w-12 text-base',
xl: 'h-16 w-16 text-lg',
},
},
defaultVariants: { size: 'md' },
}
)
export interface AvatarProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof avatarVariants> {
src?: string
alt?: string
name?: string
}
function getInitials(name: string): string {
return name
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((n) => n[0].toUpperCase())
.join('')
}
// Deterministic colour from name
function getAvatarColor(name: string): string {
const colours = [
'bg-brand-700 text-brand-200',
'bg-violet-800 text-violet-200',
'bg-indigo-800 text-indigo-200',
'bg-blue-800 text-blue-200',
'bg-cyan-800 text-cyan-200',
'bg-teal-800 text-teal-200',
'bg-emerald-800 text-emerald-200',
'bg-amber-800 text-amber-200',
'bg-rose-800 text-rose-200',
]
let hash = 0
for (let i = 0; i < name.length; i++) {
hash = (hash * 31 + name.charCodeAt(i)) & 0xffffffff
}
return colours[Math.abs(hash) % colours.length]
}
function Avatar({ className, size, src, alt, name, ...props }: AvatarProps) {
const [imgError, setImgError] = React.useState(false)
const showImage = src && !imgError
const initials = name ? getInitials(name) : '?'
const colorClass = name ? getAvatarColor(name) : 'bg-surface-700 text-surface-300'
return (
<span className={cn(avatarVariants({ size, className }))} {...props}>
{showImage ? (
<img
src={src}
alt={alt ?? name ?? 'Avatar'}
className="h-full w-full object-cover"
onError={() => setImgError(true)}
/>
) : (
<span className={cn('flex h-full w-full items-center justify-center', colorClass)} aria-label={name}>
{initials}
</span>
)}
</span>
)
}
export { Avatar, avatarVariants }
+56
View File
@@ -0,0 +1,56 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors',
{
variants: {
variant: {
default: 'bg-surface-800 text-surface-300 border border-surface-700',
success: 'bg-success-bg text-success border border-success/20',
error: 'bg-error-bg text-error border border-error/20',
warning: 'bg-warning-bg text-warning border border-warning/20',
info: 'bg-info-bg text-info border border-info/20',
brand: 'bg-brand-500/15 text-brand-300 border border-brand-500/25',
outline: 'border border-surface-600 text-surface-400',
},
},
defaultVariants: {
variant: 'default',
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof badgeVariants> {
dot?: boolean
}
function Badge({ className, variant, dot = false, children, ...props }: BadgeProps) {
const dotColors: Record<string, string> = {
default: 'bg-surface-400',
success: 'bg-success',
error: 'bg-error',
warning: 'bg-warning',
info: 'bg-info',
brand: 'bg-brand-400',
outline: 'bg-surface-500',
}
const dotColor = dotColors[variant ?? 'default'] ?? dotColors.default
return (
<span className={cn(badgeVariants({ variant, className }))} {...props}>
{dot && (
<span
className={cn('h-1.5 w-1.5 rounded-full flex-shrink-0', dotColor)}
aria-hidden="true"
/>
)}
{children}
</span>
)
}
export { Badge, badgeVariants }
+73
View File
@@ -0,0 +1,73 @@
'use client'
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors duration-[var(--transition-fast)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 select-none',
{
variants: {
variant: {
primary: 'bg-brand-600 text-white hover:bg-brand-700 active:bg-brand-800',
secondary: 'bg-surface-800 text-surface-100 border border-surface-700 hover:bg-surface-700',
ghost: 'text-surface-400 hover:bg-surface-800 hover:text-surface-100',
danger: 'bg-red-600 text-white hover:bg-red-700 active:bg-red-800',
// Legacy aliases
default: 'bg-brand-600 text-white hover:bg-brand-700',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-surface-700 bg-transparent hover:bg-surface-800 text-surface-200',
link: 'text-brand-400 underline-offset-4 hover:underline',
},
size: {
sm: 'h-7 px-3 text-xs rounded',
md: 'h-9 px-4 text-sm',
lg: 'h-11 px-6 text-base',
// Legacy aliases
default: 'h-9 px-4 py-2',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
loading?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, loading = false, children, disabled, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
const spinnerSize = size === 'sm' ? 12 : size === 'lg' ? 18 : 14
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
disabled={disabled || loading}
aria-busy={loading || undefined}
{...props}
>
{loading && (
<Loader2
className="animate-spin flex-shrink-0"
size={spinnerSize}
aria-hidden="true"
/>
)}
{children}
</Comp>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }
+131
View File
@@ -0,0 +1,131 @@
'use client'
import * as React from 'react'
import * as RadixDialog from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const dialogContentVariants = cva(
[
'relative z-50 grid w-full gap-4 rounded-lg border border-surface-700',
'bg-surface-900 p-6 shadow-lg',
'data-[state=open]:animate-scale-in data-[state=closed]:animate-scale-out',
].join(' '),
{
variants: {
size: {
sm: 'max-w-sm',
md: 'max-w-lg',
lg: 'max-w-2xl',
full: 'max-w-[calc(100vw-2rem)] h-[calc(100vh-4rem)]',
},
},
defaultVariants: {
size: 'md',
},
}
)
const Dialog = RadixDialog.Root
const DialogTrigger = RadixDialog.Trigger
const DialogPortal = RadixDialog.Portal
const DialogClose = RadixDialog.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof RadixDialog.Overlay>,
React.ComponentPropsWithoutRef<typeof RadixDialog.Overlay>
>(({ className, ...props }, ref) => (
<RadixDialog.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
'data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out',
className
)}
{...props}
/>
))
DialogOverlay.displayName = RadixDialog.Overlay.displayName
interface DialogContentProps
extends React.ComponentPropsWithoutRef<typeof RadixDialog.Content>,
VariantProps<typeof dialogContentVariants> {
showClose?: boolean
}
const DialogContent = React.forwardRef<
React.ElementRef<typeof RadixDialog.Content>,
DialogContentProps
>(({ className, children, size, showClose = true, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<RadixDialog.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%]',
dialogContentVariants({ size, className })
)}
{...props}
>
{children}
{showClose && (
<DialogClose className="absolute right-4 top-4 rounded-sm text-surface-500 hover:text-surface-100 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<X className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">Close</span>
</DialogClose>
)}
</RadixDialog.Content>
</DialogPortal>
))
DialogContent.displayName = RadixDialog.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col gap-1.5', className)} {...props} />
)
DialogHeader.displayName = 'DialogHeader'
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...props}
/>
)
DialogFooter.displayName = 'DialogFooter'
const DialogTitle = React.forwardRef<
React.ElementRef<typeof RadixDialog.Title>,
React.ComponentPropsWithoutRef<typeof RadixDialog.Title>
>(({ className, ...props }, ref) => (
<RadixDialog.Title
ref={ref}
className={cn('text-lg font-semibold text-surface-50', className)}
{...props}
/>
))
DialogTitle.displayName = RadixDialog.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof RadixDialog.Description>,
React.ComponentPropsWithoutRef<typeof RadixDialog.Description>
>(({ className, ...props }, ref) => (
<RadixDialog.Description
ref={ref}
className={cn('text-sm text-surface-400', className)}
{...props}
/>
))
DialogDescription.displayName = RadixDialog.Description.displayName
export {
Dialog,
DialogTrigger,
DialogPortal,
DialogOverlay,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
+191
View File
@@ -0,0 +1,191 @@
'use client'
import * as React from 'react'
import * as RadixDropdownMenu from '@radix-ui/react-dropdown-menu'
import { Check, ChevronRight, Circle } from 'lucide-react'
import { cn } from '@/lib/utils'
const DropdownMenu = RadixDropdownMenu.Root
const DropdownMenuTrigger = RadixDropdownMenu.Trigger
const DropdownMenuGroup = RadixDropdownMenu.Group
const DropdownMenuPortal = RadixDropdownMenu.Portal
const DropdownMenuSub = RadixDropdownMenu.Sub
const DropdownMenuRadioGroup = RadixDropdownMenu.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof RadixDropdownMenu.SubTrigger>,
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.SubTrigger> & { inset?: boolean }
>(({ className, inset, children, ...props }, ref) => (
<RadixDropdownMenu.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm',
'text-surface-200 outline-none focus:bg-surface-800 data-[state=open]:bg-surface-800',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4 text-surface-500" aria-hidden="true" />
</RadixDropdownMenu.SubTrigger>
))
DropdownMenuSubTrigger.displayName = RadixDropdownMenu.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof RadixDropdownMenu.SubContent>,
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.SubContent>
>(({ className, ...props }, ref) => (
<RadixDropdownMenu.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-surface-700',
'bg-surface-850 p-1 text-surface-100 shadow-lg',
'data-[state=open]:animate-scale-in data-[state=closed]:animate-scale-out',
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName = RadixDropdownMenu.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof RadixDropdownMenu.Content>,
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<RadixDropdownMenu.Portal>
<RadixDropdownMenu.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-surface-700',
'bg-surface-850 p-1 text-surface-100 shadow-lg',
'data-[state=open]:animate-scale-in data-[state=closed]:animate-scale-out',
className
)}
{...props}
/>
</RadixDropdownMenu.Portal>
))
DropdownMenuContent.displayName = RadixDropdownMenu.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof RadixDropdownMenu.Item>,
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.Item> & { inset?: boolean; destructive?: boolean }
>(({ className, inset, destructive, ...props }, ref) => (
<RadixDropdownMenu.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm',
'outline-none transition-colors focus:bg-surface-800',
destructive
? 'text-red-400 focus:text-red-300'
: 'text-surface-200 focus:text-surface-50',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = RadixDropdownMenu.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof RadixDropdownMenu.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<RadixDropdownMenu.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm',
'text-surface-200 outline-none transition-colors focus:bg-surface-800 focus:text-surface-50',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<RadixDropdownMenu.ItemIndicator>
<Check className="h-4 w-4 text-brand-400" aria-hidden="true" />
</RadixDropdownMenu.ItemIndicator>
</span>
{children}
</RadixDropdownMenu.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName = RadixDropdownMenu.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof RadixDropdownMenu.RadioItem>,
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.RadioItem>
>(({ className, children, ...props }, ref) => (
<RadixDropdownMenu.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm',
'text-surface-200 outline-none transition-colors focus:bg-surface-800 focus:text-surface-50',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<RadixDropdownMenu.ItemIndicator>
<Circle className="h-2 w-2 fill-brand-400 text-brand-400" aria-hidden="true" />
</RadixDropdownMenu.ItemIndicator>
</span>
{children}
</RadixDropdownMenu.RadioItem>
))
DropdownMenuRadioItem.displayName = RadixDropdownMenu.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof RadixDropdownMenu.Label>,
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.Label> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<RadixDropdownMenu.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-xs font-semibold text-surface-500 uppercase tracking-wider',
inset && 'pl-8',
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = RadixDropdownMenu.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof RadixDropdownMenu.Separator>,
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.Separator>
>(({ className, ...props }, ref) => (
<RadixDropdownMenu.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-surface-700', className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = RadixDropdownMenu.Separator.displayName
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
<span className={cn('ml-auto text-xs tracking-widest text-surface-500', className)} {...props} />
)
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
+79
View File
@@ -0,0 +1,79 @@
'use client'
import * as React from 'react'
import { Search } from 'lucide-react'
import { cn } from '@/lib/utils'
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
helper?: string
variant?: 'default' | 'search'
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, label, error, helper, variant = 'default', id, ...props }, ref) => {
const inputId = id ?? React.useId()
const errorId = `${inputId}-error`
const helperId = `${inputId}-helper`
const describedBy = [
error ? errorId : null,
helper ? helperId : null,
]
.filter(Boolean)
.join(' ')
return (
<div className="flex flex-col gap-1.5">
{label && (
<label
htmlFor={inputId}
className="text-sm font-medium text-surface-200"
>
{label}
</label>
)}
<div className="relative">
{variant === 'search' && (
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 text-surface-500 pointer-events-none"
size={15}
aria-hidden="true"
/>
)}
<input
id={inputId}
ref={ref}
aria-describedby={describedBy || undefined}
aria-invalid={error ? true : undefined}
className={cn(
'flex h-9 w-full rounded-md border bg-surface-900 px-3 py-1 text-sm text-surface-100',
'border-surface-700 placeholder:text-surface-500',
'transition-colors duration-[var(--transition-fast)]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:border-transparent',
'disabled:cursor-not-allowed disabled:opacity-50',
variant === 'search' && 'pl-9',
error && 'border-red-500 focus-visible:ring-red-500',
className
)}
{...props}
/>
</div>
{error && (
<p id={errorId} className="text-xs text-red-400" role="alert">
{error}
</p>
)}
{helper && !error && (
<p id={helperId} className="text-xs text-surface-500">
{helper}
</p>
)}
</div>
)
}
)
Input.displayName = 'Input'
export { Input }
+161
View File
@@ -0,0 +1,161 @@
'use client'
import * as React from 'react'
import * as RadixSelect from '@radix-ui/react-select'
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
import { cn } from '@/lib/utils'
const Select = RadixSelect.Root
const SelectGroup = RadixSelect.Group
const SelectValue = RadixSelect.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof RadixSelect.Trigger>,
React.ComponentPropsWithoutRef<typeof RadixSelect.Trigger>
>(({ className, children, ...props }, ref) => (
<RadixSelect.Trigger
ref={ref}
className={cn(
'flex h-9 w-full items-center justify-between rounded-md border border-surface-700',
'bg-surface-900 px-3 py-2 text-sm text-surface-100',
'placeholder:text-surface-500',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:border-transparent',
'disabled:cursor-not-allowed disabled:opacity-50',
'[&>span]:line-clamp-1',
className
)}
{...props}
>
{children}
<RadixSelect.Icon asChild>
<ChevronDown className="h-4 w-4 text-surface-500 flex-shrink-0" aria-hidden="true" />
</RadixSelect.Icon>
</RadixSelect.Trigger>
))
SelectTrigger.displayName = RadixSelect.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof RadixSelect.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof RadixSelect.ScrollUpButton>
>(({ className, ...props }, ref) => (
<RadixSelect.ScrollUpButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUp className="h-4 w-4 text-surface-500" aria-hidden="true" />
</RadixSelect.ScrollUpButton>
))
SelectScrollUpButton.displayName = RadixSelect.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof RadixSelect.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof RadixSelect.ScrollDownButton>
>(({ className, ...props }, ref) => (
<RadixSelect.ScrollDownButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDown className="h-4 w-4 text-surface-500" aria-hidden="true" />
</RadixSelect.ScrollDownButton>
))
SelectScrollDownButton.displayName = RadixSelect.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof RadixSelect.Content>,
React.ComponentPropsWithoutRef<typeof RadixSelect.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<RadixSelect.Portal>
<RadixSelect.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-surface-700',
'bg-surface-850 text-surface-100 shadow-lg',
'data-[state=open]:animate-scale-in data-[state=closed]:animate-scale-out',
position === 'popper' && [
'data-[side=bottom]:translate-y-1',
'data-[side=top]:-translate-y-1',
],
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<RadixSelect.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</RadixSelect.Viewport>
<SelectScrollDownButton />
</RadixSelect.Content>
</RadixSelect.Portal>
))
SelectContent.displayName = RadixSelect.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof RadixSelect.Label>,
React.ComponentPropsWithoutRef<typeof RadixSelect.Label>
>(({ className, ...props }, ref) => (
<RadixSelect.Label
ref={ref}
className={cn('px-2 py-1.5 text-xs font-semibold text-surface-500 uppercase tracking-wider', className)}
{...props}
/>
))
SelectLabel.displayName = RadixSelect.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof RadixSelect.Item>,
React.ComponentPropsWithoutRef<typeof RadixSelect.Item>
>(({ className, children, ...props }, ref) => (
<RadixSelect.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm',
'text-surface-200 outline-none',
'focus:bg-surface-800 focus:text-surface-50',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<RadixSelect.ItemIndicator>
<Check className="h-4 w-4 text-brand-400" aria-hidden="true" />
</RadixSelect.ItemIndicator>
</span>
<RadixSelect.ItemText>{children}</RadixSelect.ItemText>
</RadixSelect.Item>
))
SelectItem.displayName = RadixSelect.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof RadixSelect.Separator>,
React.ComponentPropsWithoutRef<typeof RadixSelect.Separator>
>(({ className, ...props }, ref) => (
<RadixSelect.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-surface-700', className)}
{...props}
/>
))
SelectSeparator.displayName = RadixSelect.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
+64
View File
@@ -0,0 +1,64 @@
'use client'
import * as React from 'react'
import * as RadixTabs from '@radix-ui/react-tabs'
import { cn } from '@/lib/utils'
const Tabs = RadixTabs.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof RadixTabs.List>,
React.ComponentPropsWithoutRef<typeof RadixTabs.List>
>(({ className, ...props }, ref) => (
<RadixTabs.List
ref={ref}
className={cn(
'relative inline-flex items-center border-b border-surface-800 w-full',
className
)}
{...props}
/>
))
TabsList.displayName = RadixTabs.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof RadixTabs.Trigger>,
React.ComponentPropsWithoutRef<typeof RadixTabs.Trigger>
>(({ className, ...props }, ref) => (
<RadixTabs.Trigger
ref={ref}
className={cn(
'relative inline-flex items-center justify-center gap-1.5 whitespace-nowrap',
'px-4 py-2.5 text-sm font-medium',
'text-surface-500 transition-colors duration-[var(--transition-fast)]',
'hover:text-surface-200',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-inset',
'disabled:pointer-events-none disabled:opacity-50',
// Animated underline via pseudo-element
'after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-full',
'after:scale-x-0 after:bg-brand-500 after:transition-transform after:duration-[var(--transition-normal)]',
'data-[state=active]:text-surface-50 data-[state=active]:after:scale-x-100',
className
)}
{...props}
/>
))
TabsTrigger.displayName = RadixTabs.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof RadixTabs.Content>,
React.ComponentPropsWithoutRef<typeof RadixTabs.Content>
>(({ className, ...props }, ref) => (
<RadixTabs.Content
ref={ref}
className={cn(
'mt-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'data-[state=active]:animate-fade-in',
className
)}
{...props}
/>
))
TabsContent.displayName = RadixTabs.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
+105
View File
@@ -0,0 +1,105 @@
'use client'
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string
error?: string
helper?: string
maxCount?: number
autoGrow?: boolean
}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, label, error, helper, maxCount, autoGrow = false, id, onChange, value, ...props }, ref) => {
const textareaId = id ?? React.useId()
const errorId = `${textareaId}-error`
const helperId = `${textareaId}-helper`
const internalRef = React.useRef<HTMLTextAreaElement>(null)
const resolvedRef = (ref as React.RefObject<HTMLTextAreaElement>) ?? internalRef
const [charCount, setCharCount] = React.useState(
typeof value === 'string' ? value.length : 0
)
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setCharCount(e.target.value.length)
if (autoGrow && resolvedRef.current) {
resolvedRef.current.style.height = 'auto'
resolvedRef.current.style.height = `${resolvedRef.current.scrollHeight}px`
}
onChange?.(e)
}
React.useEffect(() => {
if (autoGrow && resolvedRef.current) {
resolvedRef.current.style.height = 'auto'
resolvedRef.current.style.height = `${resolvedRef.current.scrollHeight}px`
}
}, [value, autoGrow, resolvedRef])
const describedBy = [error ? errorId : null, helper ? helperId : null]
.filter(Boolean)
.join(' ')
const isOverLimit = maxCount !== undefined && charCount > maxCount
return (
<div className="flex flex-col gap-1.5">
{label && (
<label htmlFor={textareaId} className="text-sm font-medium text-surface-200">
{label}
</label>
)}
<textarea
id={textareaId}
ref={resolvedRef}
value={value}
onChange={handleChange}
aria-describedby={describedBy || undefined}
aria-invalid={error ? true : undefined}
className={cn(
'flex min-h-[80px] w-full rounded-md border bg-surface-900 px-3 py-2 text-sm text-surface-100',
'border-surface-700 placeholder:text-surface-500',
'transition-colors duration-[var(--transition-fast)] resize-none',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:border-transparent',
'disabled:cursor-not-allowed disabled:opacity-50',
autoGrow && 'overflow-hidden',
error && 'border-red-500 focus-visible:ring-red-500',
className
)}
{...props}
/>
<div className="flex items-start justify-between gap-2">
<div>
{error && (
<p id={errorId} className="text-xs text-red-400" role="alert">
{error}
</p>
)}
{helper && !error && (
<p id={helperId} className="text-xs text-surface-500">
{helper}
</p>
)}
</div>
{maxCount !== undefined && (
<span
className={cn(
'text-xs tabular-nums ml-auto flex-shrink-0',
isOverLimit ? 'text-red-400' : 'text-surface-500'
)}
aria-live="polite"
>
{charCount}/{maxCount}
</span>
)}
</div>
</div>
)
}
)
Textarea.displayName = 'Textarea'
export { Textarea }
+168
View File
@@ -0,0 +1,168 @@
'use client'
import * as React from 'react'
import * as RadixToast from '@radix-ui/react-toast'
import { X, CheckCircle2, AlertCircle, AlertTriangle, Info } from 'lucide-react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
// ── Types ────────────────────────────────────────────────────────────────────
export type ToastVariant = 'default' | 'success' | 'error' | 'warning' | 'info'
export interface ToastData {
id: string
title: string
description?: string
variant?: ToastVariant
duration?: number
}
// ── Store (singleton for imperative toasts) ───────────────────────────────────
type Listener = (toasts: ToastData[]) => void
let toastList: ToastData[] = []
const listeners = new Set<Listener>()
function emit() {
listeners.forEach((fn) => fn([...toastList]))
}
export const toast = {
show(data: Omit<ToastData, 'id'>) {
const id = Math.random().toString(36).slice(2, 9)
toastList = [...toastList, { id, ...data }]
emit()
return id
},
success(title: string, description?: string) {
return this.show({ title, description, variant: 'success' })
},
error(title: string, description?: string) {
return this.show({ title, description, variant: 'error' })
},
warning(title: string, description?: string) {
return this.show({ title, description, variant: 'warning' })
},
info(title: string, description?: string) {
return this.show({ title, description, variant: 'info' })
},
dismiss(id: string) {
toastList = toastList.filter((t) => t.id !== id)
emit()
},
}
function useToastStore() {
const [toasts, setToasts] = React.useState<ToastData[]>([])
React.useEffect(() => {
setToasts([...toastList])
listeners.add(setToasts)
return () => { listeners.delete(setToasts) }
}, [])
return toasts
}
// ── Style variants ────────────────────────────────────────────────────────────
const toastVariants = cva(
[
'group pointer-events-auto relative flex w-full items-start gap-3 overflow-hidden',
'rounded-lg border p-4 shadow-lg transition-all',
'data-[state=open]:animate-slide-up data-[state=closed]:animate-slide-down-out',
'data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)]',
'data-[swipe=cancel]:translate-x-0',
'data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=end]:animate-fade-out',
].join(' '),
{
variants: {
variant: {
default: 'bg-surface-800 border-surface-700 text-surface-100',
success: 'bg-surface-800 border-green-800/60 text-surface-100',
error: 'bg-surface-800 border-red-800/60 text-surface-100',
warning: 'bg-surface-800 border-yellow-800/60 text-surface-100',
info: 'bg-surface-800 border-blue-800/60 text-surface-100',
},
},
defaultVariants: { variant: 'default' },
}
)
const variantIcons: Record<ToastVariant, React.ReactNode> = {
default: null,
success: <CheckCircle2 className="h-4 w-4 text-green-400 flex-shrink-0 mt-0.5" aria-hidden="true" />,
error: <AlertCircle className="h-4 w-4 text-red-400 flex-shrink-0 mt-0.5" aria-hidden="true" />,
warning: <AlertTriangle className="h-4 w-4 text-yellow-400 flex-shrink-0 mt-0.5" aria-hidden="true" />,
info: <Info className="h-4 w-4 text-blue-400 flex-shrink-0 mt-0.5" aria-hidden="true" />,
}
// ── Single toast item ─────────────────────────────────────────────────────────
interface ToastItemProps extends VariantProps<typeof toastVariants> {
id: string
title: string
description?: string
duration?: number
}
function ToastItem({ id, title, description, variant = 'default', duration = 5000 }: ToastItemProps) {
const [open, setOpen] = React.useState(true)
const icon = variantIcons[variant ?? 'default']
return (
<RadixToast.Root
open={open}
onOpenChange={(o) => {
setOpen(o)
if (!o) setTimeout(() => toast.dismiss(id), 300)
}}
duration={duration}
className={cn(toastVariants({ variant }))}
>
{icon}
<div className="flex-1 min-w-0">
<RadixToast.Title className="text-sm font-medium leading-snug">{title}</RadixToast.Title>
{description && (
<RadixToast.Description className="mt-0.5 text-xs text-surface-400 leading-relaxed">
{description}
</RadixToast.Description>
)}
</div>
<RadixToast.Close
className="flex-shrink-0 text-surface-500 hover:text-surface-200 transition-colors rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Dismiss notification"
>
<X className="h-4 w-4" aria-hidden="true" />
</RadixToast.Close>
{/* Progress bar */}
<div
className="absolute bottom-0 left-0 h-0.5 w-full origin-left bg-current opacity-20"
style={{ animation: `progress ${duration}ms linear forwards` }}
aria-hidden="true"
/>
</RadixToast.Root>
)
}
// ── Provider (mount once in layout) ──────────────────────────────────────────
export function ToastProvider({ children }: { children: React.ReactNode }) {
const toasts = useToastStore()
return (
<RadixToast.Provider swipeDirection="right">
{children}
{toasts.map((t) => (
<ToastItem key={t.id} {...t} />
))}
<RadixToast.Viewport className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 w-80 focus:outline-none" />
</RadixToast.Provider>
)
}
// ── Hook (alternative to imperative API) ─────────────────────────────────────
export function useToast() {
return toast
}
+57
View File
@@ -0,0 +1,57 @@
'use client'
import * as React from 'react'
import * as RadixTooltip from '@radix-ui/react-tooltip'
import { cn } from '@/lib/utils'
const TooltipProvider = RadixTooltip.Provider
const Tooltip = RadixTooltip.Root
const TooltipTrigger = RadixTooltip.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof RadixTooltip.Content>,
React.ComponentPropsWithoutRef<typeof RadixTooltip.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<RadixTooltip.Portal>
<RadixTooltip.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border border-surface-700',
'bg-surface-800 px-3 py-1.5 text-xs text-surface-100 shadow-md',
'animate-scale-in data-[state=closed]:animate-scale-out',
className
)}
{...props}
/>
</RadixTooltip.Portal>
))
TooltipContent.displayName = RadixTooltip.Content.displayName
// Convenience wrapper
interface SimpleTooltipProps {
content: React.ReactNode
children: React.ReactNode
side?: 'top' | 'right' | 'bottom' | 'left'
delayDuration?: number
asChild?: boolean
}
function SimpleTooltip({
content,
children,
side = 'top',
delayDuration = 400,
asChild = false,
}: SimpleTooltipProps) {
return (
<TooltipProvider delayDuration={delayDuration}>
<Tooltip>
<TooltipTrigger asChild={asChild}>{children}</TooltipTrigger>
<TooltipContent side={side}>{content}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, SimpleTooltip }
+65
View File
@@ -0,0 +1,65 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
interface UseAriaLiveOptions {
politeness?: "polite" | "assertive";
/** Delay in ms before the message is injected — allows the region to reset first */
delay?: number;
}
interface UseAriaLiveReturn {
/** The current announcement string — render this inside an aria-live region */
announcement: string;
/** Call this to trigger a new announcement */
announce: (message: string) => void;
/** Props to spread onto your aria-live container element */
liveRegionProps: {
role: "status";
"aria-live": "polite" | "assertive";
"aria-atomic": true;
};
}
/**
* Hook-based aria-live region manager.
* Returns an `announcement` string to render inside a visually-hidden container
* and an `announce` function to update it.
*
* @example
* const { announcement, announce, liveRegionProps } = useAriaLive();
*
* // Trigger
* announce("Message sent");
*
* // Render (visually hidden)
* <div {...liveRegionProps} className="sr-only">{announcement}</div>
*/
export function useAriaLive({
politeness = "polite",
delay = 50,
}: UseAriaLiveOptions = {}): UseAriaLiveReturn {
const [announcement, setAnnouncement] = useState("");
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const announce = useCallback(
(message: string) => {
setAnnouncement("");
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setAnnouncement(message), delay);
},
[delay]
);
useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
return {
announcement,
announce,
liveRegionProps: {
role: "status",
"aria-live": politeness,
"aria-atomic": true,
},
};
}
+348
View File
@@ -0,0 +1,348 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { CollabSocket } from "@/lib/collaboration/socket";
import type {
CollabUser,
CollabRole,
ToolUsePendingEvent,
AnnotationAddedEvent,
AnnotationReplyEvent,
} from "@/lib/collaboration/socket";
import type { CollabAnnotation, PendingToolUse } from "@/lib/collaboration/types";
// ─── Options ──────────────────────────────────────────────────────────────────
export interface UseCollaborationOptions {
sessionId: string;
currentUser: CollabUser;
wsUrl?: string;
}
// ─── State ────────────────────────────────────────────────────────────────────
export interface CollaborationState {
isConnected: boolean;
myRole: CollabRole | null;
pendingToolUses: PendingToolUse[];
annotations: Record<string, CollabAnnotation[]>; // messageId → annotations
toolApprovalPolicy: "owner-only" | "any-collaborator";
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useCollaboration({
sessionId,
currentUser,
wsUrl,
}: UseCollaborationOptions) {
const socketRef = useRef<CollabSocket | null>(null);
const [state, setState] = useState<CollaborationState>({
isConnected: false,
myRole: null,
pendingToolUses: [],
annotations: {},
toolApprovalPolicy: "any-collaborator",
});
const effectiveWsUrl =
wsUrl ??
(typeof process !== "undefined"
? process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:3001"
: "ws://localhost:3001");
useEffect(() => {
const socket = new CollabSocket(sessionId, currentUser.id);
socketRef.current = socket;
socket.onConnectionChange = (connected) => {
setState((s) => ({ ...s, isConnected: connected }));
};
const cleanup: Array<() => void> = [];
cleanup.push(
socket.on("session_state", (e) => {
const me = e.users.find((u) => u.id === currentUser.id);
setState((s) => ({
...s,
myRole: me?.role ?? null,
toolApprovalPolicy: e.toolApprovalPolicy,
}));
})
);
cleanup.push(
socket.on("tool_use_pending", (e: ToolUsePendingEvent) => {
const entry: PendingToolUse = {
id: e.toolUseId,
name: e.toolName,
input: e.toolInput,
messageId: e.messageId,
requestedAt: e.timestamp,
};
setState((s) => ({
...s,
pendingToolUses: [...s.pendingToolUses, entry],
}));
})
);
cleanup.push(
socket.on("tool_use_approved", (e) => {
setState((s) => ({
...s,
pendingToolUses: s.pendingToolUses.filter((t) => t.id !== e.toolUseId),
}));
})
);
cleanup.push(
socket.on("tool_use_denied", (e) => {
setState((s) => ({
...s,
pendingToolUses: s.pendingToolUses.filter((t) => t.id !== e.toolUseId),
}));
})
);
cleanup.push(
socket.on("role_changed", (e) => {
if (e.targetUserId === currentUser.id) {
setState((s) => ({ ...s, myRole: e.newRole }));
}
})
);
cleanup.push(
socket.on("access_revoked", (e) => {
if (e.targetUserId === currentUser.id) {
socket.disconnect();
setState((s) => ({ ...s, isConnected: false, myRole: null }));
}
})
);
cleanup.push(
socket.on("ownership_transferred", (e) => {
if (e.newOwnerId === currentUser.id) {
setState((s) => ({ ...s, myRole: "owner" }));
} else if (e.previousOwnerId === currentUser.id) {
setState((s) => ({ ...s, myRole: "collaborator" }));
}
})
);
cleanup.push(
socket.on("annotation_added", (e: AnnotationAddedEvent) => {
const ann: CollabAnnotation = { ...e.annotation };
setState((s) => ({
...s,
annotations: {
...s.annotations,
[ann.messageId]: [...(s.annotations[ann.messageId] ?? []), ann],
},
}));
})
);
cleanup.push(
socket.on("annotation_resolved", (e) => {
setState((s) => {
const next: Record<string, CollabAnnotation[]> = {};
for (const [msgId, anns] of Object.entries(s.annotations)) {
next[msgId] = anns.map((a) =>
a.id === e.annotationId ? { ...a, resolved: e.resolved } : a
);
}
return { ...s, annotations: next };
});
})
);
cleanup.push(
socket.on("annotation_reply", (e: AnnotationReplyEvent) => {
setState((s) => {
const next: Record<string, CollabAnnotation[]> = {};
for (const [msgId, anns] of Object.entries(s.annotations)) {
next[msgId] = anns.map((a) =>
a.id === e.annotationId
? { ...a, replies: [...a.replies, e.reply] }
: a
);
}
return { ...s, annotations: next };
});
})
);
socket.connect(`${effectiveWsUrl}/collab`);
return () => {
cleanup.forEach((off) => off());
socket.disconnect();
};
}, [sessionId, currentUser.id, effectiveWsUrl]);
// ─── Actions ───────────────────────────────────────────────────────────────
const approveTool = useCallback(
(toolUseId: string) => {
socketRef.current?.send({
type: "tool_use_approved",
sessionId,
userId: currentUser.id,
toolUseId,
approvedBy: currentUser,
});
},
[sessionId, currentUser]
);
const denyTool = useCallback(
(toolUseId: string) => {
socketRef.current?.send({
type: "tool_use_denied",
sessionId,
userId: currentUser.id,
toolUseId,
deniedBy: currentUser,
});
},
[sessionId, currentUser]
);
const addAnnotation = useCallback(
(messageId: string, text: string, parentId?: string) => {
const annotation: CollabAnnotation = {
id: crypto.randomUUID(),
messageId,
parentId,
text,
author: currentUser,
createdAt: Date.now(),
resolved: false,
replies: [],
};
// Optimistic update
setState((s) => ({
...s,
annotations: {
...s.annotations,
[messageId]: [...(s.annotations[messageId] ?? []), annotation],
},
}));
socketRef.current?.send({
type: "annotation_added",
sessionId,
userId: currentUser.id,
annotation,
});
},
[sessionId, currentUser]
);
const resolveAnnotation = useCallback(
(annotationId: string, resolved: boolean) => {
setState((s) => {
const next: Record<string, CollabAnnotation[]> = {};
for (const [msgId, anns] of Object.entries(s.annotations)) {
next[msgId] = anns.map((a) =>
a.id === annotationId ? { ...a, resolved } : a
);
}
return { ...s, annotations: next };
});
socketRef.current?.send({
type: "annotation_resolved",
sessionId,
userId: currentUser.id,
annotationId,
resolved,
resolvedBy: currentUser,
});
},
[sessionId, currentUser]
);
const replyAnnotation = useCallback(
(annotationId: string, text: string) => {
const reply = {
id: crypto.randomUUID(),
text,
author: currentUser,
createdAt: Date.now(),
};
// Optimistic update
setState((s) => {
const next: Record<string, CollabAnnotation[]> = {};
for (const [msgId, anns] of Object.entries(s.annotations)) {
next[msgId] = anns.map((a) =>
a.id === annotationId
? { ...a, replies: [...a.replies, reply] }
: a
);
}
return { ...s, annotations: next };
});
socketRef.current?.send({
type: "annotation_reply",
sessionId,
userId: currentUser.id,
annotationId,
reply,
});
},
[sessionId, currentUser]
);
const revokeAccess = useCallback(
(targetUserId: string) => {
socketRef.current?.send({
type: "access_revoked",
sessionId,
userId: currentUser.id,
targetUserId,
});
},
[sessionId, currentUser.id]
);
const changeRole = useCallback(
(targetUserId: string, newRole: CollabRole) => {
socketRef.current?.send({
type: "role_changed",
sessionId,
userId: currentUser.id,
targetUserId,
newRole,
});
},
[sessionId, currentUser.id]
);
const transferOwnership = useCallback(
(newOwnerId: string) => {
socketRef.current?.send({
type: "ownership_transferred",
sessionId,
userId: currentUser.id,
newOwnerId,
previousOwnerId: currentUser.id,
});
},
[sessionId, currentUser.id]
);
return {
...state,
approveTool,
denyTool,
addAnnotation,
resolveAnnotation,
replyAnnotation,
revokeAccess,
changeRole,
transferOwnership,
};
}
+137
View File
@@ -0,0 +1,137 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import type { Command } from "@/lib/shortcuts";
const RECENT_MAX = 5;
const RECENT_KEY = "claude-code-recent-commands";
interface CommandRegistryContextValue {
/** Live list of all registered commands for UI rendering */
commands: Command[];
/** Ref always pointing to the latest commands list — use in event handlers */
commandsRef: React.MutableRefObject<Command[]>;
registerCommand: (cmd: Command) => () => void;
/** Run a command by id and record it as recently used */
runCommand: (id: string) => void;
paletteOpen: boolean;
openPalette: () => void;
closePalette: () => void;
helpOpen: boolean;
openHelp: () => void;
closeHelp: () => void;
recentCommandIds: string[];
}
const CommandRegistryContext = createContext<CommandRegistryContextValue>({
commands: [],
commandsRef: { current: [] },
registerCommand: () => () => {},
runCommand: () => {},
paletteOpen: false,
openPalette: () => {},
closePalette: () => {},
helpOpen: false,
openHelp: () => {},
closeHelp: () => {},
recentCommandIds: [],
});
export function CommandRegistryProvider({
children,
}: {
children: React.ReactNode;
}) {
const [commands, setCommands] = useState<Command[]>([]);
const commandsRef = useRef<Command[]>([]);
const [paletteOpen, setPaletteOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const [recentCommandIds, setRecentCommandIds] = useState<string[]>(() => {
if (typeof window === "undefined") return [];
try {
return JSON.parse(localStorage.getItem(RECENT_KEY) ?? "[]");
} catch {
return [];
}
});
const registerCommand = useCallback((cmd: Command) => {
setCommands((prev) => {
const next = [...prev.filter((c) => c.id !== cmd.id), cmd];
commandsRef.current = next;
return next;
});
return () => {
setCommands((prev) => {
const next = prev.filter((c) => c.id !== cmd.id);
commandsRef.current = next;
return next;
});
};
}, []);
const addToRecent = useCallback((id: string) => {
setRecentCommandIds((prev) => {
const next = [id, ...prev.filter((r) => r !== id)].slice(0, RECENT_MAX);
try {
localStorage.setItem(RECENT_KEY, JSON.stringify(next));
} catch {}
return next;
});
}, []);
const runCommand = useCallback(
(id: string) => {
const cmd = commandsRef.current.find((c) => c.id === id);
if (!cmd) return;
if (cmd.when && !cmd.when()) return;
addToRecent(id);
cmd.action();
},
[addToRecent]
);
const openPalette = useCallback(() => setPaletteOpen(true), []);
const closePalette = useCallback(() => setPaletteOpen(false), []);
const openHelp = useCallback(() => setHelpOpen(true), []);
const closeHelp = useCallback(() => setHelpOpen(false), []);
// Keep commandsRef in sync when state updates
useEffect(() => {
commandsRef.current = commands;
}, [commands]);
return (
<CommandRegistryContext.Provider
value={{
commands,
commandsRef,
registerCommand,
runCommand,
paletteOpen,
openPalette,
closePalette,
helpOpen,
openHelp,
closeHelp,
recentCommandIds,
}}
>
{children}
</CommandRegistryContext.Provider>
);
}
export function useCommandRegistry() {
return useContext(CommandRegistryContext);
}
+40
View File
@@ -0,0 +1,40 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
/**
* Saves the currently focused element and returns a function to restore focus to it.
* Use when opening modals/dialogs to return focus to the trigger on close.
*
* @example
* const returnFocus = useFocusReturn();
*
* const openDialog = () => {
* returnFocus.save(); // call before showing the dialog
* setOpen(true);
* };
*
* const closeDialog = () => {
* setOpen(false);
* returnFocus.restore(); // call after hiding the dialog
* };
*/
export function useFocusReturn() {
const savedRef = useRef<HTMLElement | null>(null);
const save = useCallback(() => {
savedRef.current = document.activeElement as HTMLElement | null;
}, []);
const restore = useCallback(() => {
if (savedRef.current && typeof savedRef.current.focus === "function") {
savedRef.current.focus();
savedRef.current = null;
}
}, []);
// Safety cleanup on unmount
useEffect(() => () => { savedRef.current = null; }, []);
return { save, restore };
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
import { useEffect, useRef } from "react";
import { parseKey, matchesEvent } from "@/lib/keyParser";
import { useCommandRegistry } from "./useCommandRegistry";
const SEQUENCE_TIMEOUT_MS = 1000;
/** Tags whose focus should suppress non-global shortcuts */
const INPUT_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"]);
function isTypingTarget(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false;
if (INPUT_TAGS.has(el.tagName)) return true;
if (el.isContentEditable) return true;
return false;
}
/**
* Attaches a global keydown listener that fires registered commands.
* Supports single combos ("mod+k") and two-key sequences ("g d").
* Must be used inside a CommandRegistryProvider.
*/
export function useKeyboardShortcuts() {
const { commandsRef } = useCommandRegistry();
const pendingSequenceRef = useRef<string | null>(null);
const sequenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const clearSequence = () => {
pendingSequenceRef.current = null;
if (sequenceTimerRef.current) {
clearTimeout(sequenceTimerRef.current);
sequenceTimerRef.current = null;
}
};
const handler = (e: KeyboardEvent) => {
// Ignore bare modifier keypresses
if (["Meta", "Control", "Shift", "Alt"].includes(e.key)) return;
const inInput = isTypingTarget(e.target);
const commands = commandsRef.current;
// --- Sequence matching (e.g. "g" then "d") ---
if (pendingSequenceRef.current) {
const seq = `${pendingSequenceRef.current} ${e.key.toLowerCase()}`;
const match = commands.find(
(cmd) =>
(!inInput || cmd.global) &&
(!cmd.when || cmd.when()) &&
cmd.keys.includes(seq)
);
clearSequence();
if (match) {
e.preventDefault();
match.action();
return;
}
}
// --- Single combo matching ---
const singleMatch = commands.find((cmd) => {
if (inInput && !cmd.global) return false;
if (cmd.when && !cmd.when()) return false;
return cmd.keys.some((k) => {
// Sequence keys contain a space; skip them in the single pass
if (k.includes(" ")) return false;
return matchesEvent(parseKey(k), e);
});
});
if (singleMatch) {
e.preventDefault();
singleMatch.action();
return;
}
// --- Start-of-sequence detection (single bare key that starts a sequence) ---
// Only when not in an input and no modifier held
if (!inInput && !e.metaKey && !e.ctrlKey && !e.altKey) {
const keyLower = e.key.toLowerCase();
const startsSequence = commands.some((cmd) =>
cmd.keys.some((k) => k.includes(" ") && k.startsWith(keyLower + " "))
);
if (startsSequence) {
e.preventDefault();
clearSequence();
pendingSequenceRef.current = keyLower;
sequenceTimerRef.current = setTimeout(clearSequence, SEQUENCE_TIMEOUT_MS);
}
}
};
document.addEventListener("keydown", handler);
return () => {
document.removeEventListener("keydown", handler);
clearSequence();
};
}, [commandsRef]);
}
+32
View File
@@ -0,0 +1,32 @@
"use client";
import { useState, useEffect } from "react";
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const mq = window.matchMedia(query);
setMatches(mq.matches);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, [query]);
return matches;
}
/** < 768px */
export function useIsMobile(): boolean {
return useMediaQuery("(max-width: 767px)");
}
/** 768px 1023px */
export function useIsTablet(): boolean {
return useMediaQuery("(min-width: 768px) and (max-width: 1023px)");
}
/** >= 1024px */
export function useIsDesktop(): boolean {
return useMediaQuery("(min-width: 1024px)");
}
+54
View File
@@ -0,0 +1,54 @@
"use client";
import { useCallback } from "react";
import { useNotificationStore, type NotificationCategory } from "@/lib/notifications";
import { browserNotifications } from "@/lib/browser-notifications";
export interface NotifyOptions {
title: string;
description: string;
category: NotificationCategory;
link?: string;
browserNotification?: boolean;
}
export function useNotifications() {
const notifications = useNotificationStore((s) => s.notifications);
const browserNotificationsEnabled = useNotificationStore(
(s) => s.browserNotificationsEnabled
);
const addNotification = useNotificationStore((s) => s.addNotification);
const markRead = useNotificationStore((s) => s.markRead);
const markAllRead = useNotificationStore((s) => s.markAllRead);
const clearHistory = useNotificationStore((s) => s.clearHistory);
const notify = useCallback(
async (options: NotifyOptions) => {
addNotification({
title: options.title,
description: options.description,
category: options.category,
link: options.link,
});
if (options.browserNotification && browserNotificationsEnabled) {
await browserNotifications.send({
title: options.title,
body: options.description,
});
}
},
[addNotification, browserNotificationsEnabled]
);
const unreadCount = notifications.filter((n) => !n.read).length;
return {
notifications,
unreadCount,
notify,
markRead,
markAllRead,
clearHistory,
};
}
+154
View File
@@ -0,0 +1,154 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import {
createPresenceState,
presenceAddUser,
presenceRemoveUser,
presenceSyncUsers,
presenceUpdateCursor,
presenceSetTyping,
} from "@/lib/collaboration/presence";
import type { PresenceState } from "@/lib/collaboration/presence";
import type { CollabSocket } from "@/lib/collaboration/socket";
import type { CollabUser } from "@/lib/collaboration/socket";
// ─── Options ──────────────────────────────────────────────────────────────────
export interface UsePresenceOptions {
socket: CollabSocket | null;
sessionId: string;
currentUser: CollabUser;
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function usePresence({ socket, sessionId, currentUser }: UsePresenceOptions) {
const [presence, setPresence] = useState<PresenceState>(createPresenceState);
const typingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isTypingRef = useRef(false);
useEffect(() => {
if (!socket) return;
const cleanup: Array<() => void> = [];
cleanup.push(
socket.on("presence_sync", (e) => {
setPresence((s) => presenceSyncUsers(s, e.users));
})
);
cleanup.push(
socket.on("session_state", (e) => {
setPresence((s) => presenceSyncUsers(s, e.users));
})
);
cleanup.push(
socket.on("user_joined", (e) => {
setPresence((s) => presenceAddUser(s, e.user));
})
);
cleanup.push(
socket.on("user_left", (e) => {
setPresence((s) => presenceRemoveUser(s, e.user.id));
})
);
cleanup.push(
socket.on("cursor_update", (e) => {
if (e.userId === currentUser.id) return; // skip own cursor
setPresence((s) =>
presenceUpdateCursor(s, e.userId, e.position, e.selectionStart, e.selectionEnd)
);
})
);
cleanup.push(
socket.on("typing_start", (e) => {
if (e.userId === currentUser.id) return;
setPresence((s) => presenceSetTyping(s, e.user.id, true));
})
);
cleanup.push(
socket.on("typing_stop", (e) => {
if (e.userId === currentUser.id) return;
setPresence((s) => presenceSetTyping(s, e.user.id, false));
})
);
return () => cleanup.forEach((off) => off());
}, [socket, currentUser.id]);
// ─── Actions ─────────────────────────────────────────────────────────────
const sendCursorUpdate = useCallback(
(position: number, selectionStart?: number, selectionEnd?: number) => {
socket?.send({
type: "cursor_update",
sessionId,
userId: currentUser.id,
position,
selectionStart,
selectionEnd,
});
},
[socket, sessionId, currentUser.id]
);
// Call this whenever the user types — auto-sends typing_start + debounced typing_stop
const notifyTyping = useCallback(() => {
if (!isTypingRef.current) {
isTypingRef.current = true;
socket?.send({
type: "typing_start",
sessionId,
userId: currentUser.id,
user: currentUser,
});
}
if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
typingTimerRef.current = setTimeout(() => {
isTypingRef.current = false;
socket?.send({
type: "typing_stop",
sessionId,
userId: currentUser.id,
user: currentUser,
});
}, 2_000);
}, [socket, sessionId, currentUser]);
const stopTyping = useCallback(() => {
if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
if (isTypingRef.current) {
isTypingRef.current = false;
socket?.send({
type: "typing_stop",
sessionId,
userId: currentUser.id,
user: currentUser,
});
}
}, [socket, sessionId, currentUser]);
// Derived helpers
const otherUsers = Array.from(presence.users.values()).filter(
(u) => u.id !== currentUser.id
);
const typingUsers = Array.from(presence.typing)
.filter((id) => id !== currentUser.id)
.map((id) => presence.users.get(id))
.filter((u): u is CollabUser => u !== undefined);
return {
presence,
otherUsers,
typingUsers,
sendCursorUpdate,
notifyTyping,
stopTyping,
};
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { useEffect, useState } from "react";
/**
* Returns true when the user has requested reduced motion via OS settings.
* Use this to disable or simplify animations for users who need it.
*
* @example
* const reducedMotion = useReducedMotion();
* <div className={reducedMotion ? "" : "animate-fade-in"}>...</div>
*/
export function useReducedMotion(): boolean {
const [reducedMotion, setReducedMotion] = useState(() => {
if (typeof window === "undefined") return false;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
});
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const handler = (e: MediaQueryListEvent) => setReducedMotion(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
return reducedMotion;
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { useCallback } from "react";
import { useNotificationStore, DEFAULT_DURATIONS, type ToastVariant } from "@/lib/notifications";
export interface ToastOptions {
variant: ToastVariant;
title: string;
description?: string;
duration?: number;
action?: { label: string; onClick: () => void };
details?: string;
}
export function useToast() {
const addToast = useNotificationStore((s) => s.addToast);
const dismissToast = useNotificationStore((s) => s.dismissToast);
const dismissAllToasts = useNotificationStore((s) => s.dismissAllToasts);
const toast = useCallback(
(options: ToastOptions): string => {
const duration = options.duration ?? DEFAULT_DURATIONS[options.variant];
return addToast({ ...options, duration });
},
[addToast]
);
return { toast, dismiss: dismissToast, dismissAll: dismissAllToasts };
}

Some files were not shown because too many files have changed in this diff Show More