diff --git a/.opencode/command/ai-deps.md b/.opencode/command/ai-deps.md new file mode 100644 index 0000000000..4d23c76a4d --- /dev/null +++ b/.opencode/command/ai-deps.md @@ -0,0 +1,24 @@ +--- +description: "Bump AI sdk dependencies minor / patch versions only" +--- + +Please read @package.json and @packages/opencode/package.json. + +Your job is to look into AI SDK dependencies, figure out if they have versions that can be upgraded (minor or patch versions ONLY no major ignore major changes). + +I want a report of every dependency and the version that can be upgraded to. +What would be even better is if you can give me links to the changelog for each dependency, or at least some reference info so I can see what bugs were fixed or new features were added. + +Consider using subagents for each dep to save your context window. + +Here is a short list of some deps (please be comprehensive tho): + +- "ai" +- "@ai-sdk/openai" +- "@ai-sdk/anthropic" +- "@openrouter/ai-sdk-provider" +- etc, etc + +DO NOT upgrade the dependencies yet, just make a list of all dependencies and their versions that can be upgraded to minor or patch versions only. + +Write up your findings to ai-sdk-updates.md diff --git a/AGENTS.md b/AGENTS.md index 87d59d4c92..3138f6c5ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -- To test opencode in the `packages/opencode` directory you can run `bun dev` -- To regenerate the javascript SDK, run ./packages/sdk/js/script/build.ts +- To test opencode in `packages/opencode`, run `bun dev`. +- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`. - ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. -- the default branch in this repo is `dev` +- The default branch in this repo is `dev`. diff --git a/SECURITY.md b/SECURITY.md index 537834e4de..3a653d01c6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,3 +1,32 @@ +# Security + +## Threat Model + +### Overview + +OpenCode is an AI-powered coding assistant that runs locally on your machine. It provides an agent system with access to powerful tools including shell execution, file operations, and web access. + +### No Sandbox + +OpenCode does **not** sandbox the agent. The permission system exists as a UX feature to help users stay aware of what actions the agent is taking - it prompts for confirmation before executing commands, writing files, etc. However, it is not designed to provide security isolation. + +If you need true isolation, run OpenCode inside a Docker container or VM. + +### Server Mode + +Server mode is opt-in only. When enabled, set `OPENCODE_SERVER_PASSWORD` to require HTTP Basic Auth. Without this, the server runs unauthenticated (with a warning). It is the end user's responsibility to secure the server - any functionality it provides is not a vulnerability. + +### Out of Scope + +| Category | Rationale | +| ------------------------------- | ----------------------------------------------------------------------- | +| **Server access when opted-in** | If you enable server mode, API access is expected behavior | +| **Sandbox escapes** | The permission system is not a sandbox (see above) | +| **LLM provider data handling** | Data sent to your configured LLM provider is governed by their policies | +| **MCP server behavior** | External MCP servers you configure are outside our trust boundary | + +--- + # Reporting Security Issues We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions. diff --git a/STATS.md b/STATS.md index ac4b788bae..b6e03b01b0 100644 --- a/STATS.md +++ b/STATS.md @@ -200,3 +200,4 @@ | 2026-01-11 | 2,836,394 (+204,371) | 1,530,479 (+26,809) | 4,366,873 (+231,180) | | 2026-01-12 | 3,053,594 (+217,200) | 1,553,671 (+23,192) | 4,607,265 (+240,392) | | 2026-01-13 | 3,297,078 (+243,484) | 1,595,062 (+41,391) | 4,892,140 (+284,875) | +| 2026-01-14 | 3,568,928 (+271,850) | 1,645,362 (+50,300) | 5,214,290 (+322,150) | diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index a46ce221fb..52d012fcb9 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -1,19 +1,16 @@ ## Style Guide -- Try to keep things in one function unless composable or reusable -- AVOID unnecessary destructuring of variables. instead of doing `const { a, b } -= obj` just reference it as obj.a and obj.b. this preserves context -- AVOID `try`/`catch` where possible -- AVOID using `any` type -- PREFER single word variable names where possible -- Use as many bun apis as possible like Bun.file() +- Keep things in one function unless composable or reusable +- Avoid unnecessary destructuring. Instead of `const { a, b } = obj`, use `obj.a` and `obj.b` to preserve context +- Avoid `try`/`catch` where possible +- Avoid using the `any` type +- Prefer single word variable names where possible +- Use Bun APIs when possible, like `Bun.file()` # Avoid let statements -we don't like let statements, especially combined with if/else statements. -prefer const - -This is bad: +We don't like `let` statements, especially combined with if/else statements. +Prefer `const`. Good: @@ -32,7 +29,7 @@ else foo = 2 # Avoid else statements -Prefer early returns or even using `iife` to avoid else statements +Prefer early returns or using an `iife` to avoid else statements. Good: diff --git a/bun.lock b/bun.lock index 10001bb619..9fa7717dca 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.1.16", + "version": "1.1.21", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -70,7 +70,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.1.16", + "version": "1.1.21", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -84,22 +84,25 @@ "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", "@solidjs/start": "catalog:", + "@stripe/stripe-js": "8.6.1", "chart.js": "4.5.1", "nitro": "3.0.1-alpha.1", "solid-js": "catalog:", "solid-list": "0.3.0", + "solid-stripe": "0.8.1", "vite": "catalog:", "zod": "catalog:", }, "devDependencies": { "@typescript/native-preview": "catalog:", + "@webgpu/types": "0.1.54", "typescript": "catalog:", "wrangler": "4.50.0", }, }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.1.16", + "version": "1.1.21", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -126,7 +129,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.1.16", + "version": "1.1.21", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -150,7 +153,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.1.16", + "version": "1.1.21", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -174,7 +177,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.1.16", + "version": "1.1.21", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -203,7 +206,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.1.16", + "version": "1.1.21", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -232,7 +235,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.1.16", + "version": "1.1.21", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -248,7 +251,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.1.16", + "version": "1.1.21", "bin": { "opencode": "./bin/opencode", }, @@ -276,6 +279,7 @@ "@ai-sdk/vercel": "1.0.31", "@ai-sdk/xai": "2.0.51", "@clack/prompts": "1.0.0-alpha.1", + "@gitlab/gitlab-ai-provider": "3.1.1", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.25.2", @@ -287,8 +291,8 @@ "@opencode-ai/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", "@openrouter/ai-sdk-provider": "1.5.2", - "@opentui/core": "0.1.72", - "@opentui/solid": "0.1.72", + "@opentui/core": "0.1.73", + "@opentui/solid": "0.1.73", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", @@ -351,7 +355,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.1.16", + "version": "1.1.21", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -371,7 +375,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.1.16", + "version": "1.1.21", "devDependencies": { "@hey-api/openapi-ts": "0.88.1", "@tsconfig/node22": "catalog:", @@ -382,7 +386,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.1.16", + "version": "1.1.21", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -395,7 +399,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.1.16", + "version": "1.1.21", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -407,7 +411,7 @@ "@solid-primitives/resize-observer": "2.1.3", "@solidjs/meta": "catalog:", "@typescript/native-preview": "catalog:", - "dompurify": "catalog:", + "dompurify": "3.3.1", "fuzzysort": "catalog:", "katex": "0.16.27", "luxon": "catalog:", @@ -435,7 +439,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.1.16", + "version": "1.1.21", "dependencies": { "zod": "catalog:", }, @@ -446,7 +450,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.1.16", + "version": "1.1.21", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -502,7 +506,7 @@ "@tailwindcss/vite": "4.1.11", "@tsconfig/bun": "1.0.9", "@tsconfig/node22": "22.0.2", - "@types/bun": "1.3.4", + "@types/bun": "1.3.5", "@types/luxon": "3.7.1", "@types/node": "22.13.9", "@typescript/native-preview": "7.0.0-dev.20251207.1", @@ -586,6 +590,10 @@ "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.71.2", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ=="], + + "@anycable/core": ["@anycable/core@0.9.2", "", { "dependencies": { "nanoevents": "^7.0.1" } }, "sha512-x5ZXDcW/N4cxWl93CnbHs/u7qq4793jS2kNPWm+duPrXlrva+ml2ZGT7X9tuOBKzyIHf60zWCdIK7TUgMPAwXA=="], + "@astrojs/cloudflare": ["@astrojs/cloudflare@12.6.3", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.1", "@astrojs/underscore-redirects": "1.0.0", "@cloudflare/workers-types": "^4.20250507.0", "tinyglobby": "^0.2.13", "vite": "^6.3.5", "wrangler": "^4.14.1" }, "peerDependencies": { "astro": "^5.0.0" } }, "sha512-xhJptF5tU2k5eo70nIMyL1Udma0CqmUEnGSlGyFflLqSY82CRQI6nWZ/xZt0ZvmXuErUjIx0YYQNfZsz5CNjLQ=="], "@astrojs/compiler": ["@astrojs/compiler@2.13.0", "", {}, "sha512-mqVORhUJViA28fwHYaWmsXSzLO9osbdZ5ImUfxBarqsYdMlPbqAqGJCxsNzvppp1BEzc1mJNjOVvQqeDN8Vspw=="], @@ -906,6 +914,10 @@ "@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="], + "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.1.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-7AtFrCflq2NzC99bj7YaqbQDCZyaScM1+L4ujllV5syiRTFE239Uhnd/yEkPXa7sUAnNRfN3CWusCkQ2zK/q9g=="], + + "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="], "@hey-api/codegen-core": ["@hey-api/codegen-core@0.3.3", "", { "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-vArVDtrvdzFewu1hnjUm4jX1NBITlSCeO81EdWq676MxQbyxsGcDPAgohaSA+Wvr4HjPSvsg2/1s2zYxUtXebg=="], @@ -1204,21 +1216,21 @@ "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], - "@opentui/core": ["@opentui/core@0.1.72", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.72", "@opentui/core-darwin-x64": "0.1.72", "@opentui/core-linux-arm64": "0.1.72", "@opentui/core-linux-x64": "0.1.72", "@opentui/core-win32-arm64": "0.1.72", "@opentui/core-win32-x64": "0.1.72", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-l4WQzubBJ80Q0n77Lxuodjwwm8qj/sOa7IXxEAzzDDXY/7bsIhdSpVhRTt+KevBRlok5J+w/KMKYr8UzkA4/hA=="], + "@opentui/core": ["@opentui/core@0.1.73", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.73", "@opentui/core-darwin-x64": "0.1.73", "@opentui/core-linux-arm64": "0.1.73", "@opentui/core-linux-x64": "0.1.73", "@opentui/core-win32-arm64": "0.1.73", "@opentui/core-win32-x64": "0.1.73", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-1OqLlArzUh3QjrYXGro5WKNgoCcacGJaaFvwOHg5lAOoSigFQRiqEUEEJLbSo3pyV8u7XEdC3M0rOP6K+oThzw=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.72", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RoU48kOrhLZYDBiXaDu1LXS2bwRdlJlFle8eUQiqJjLRbMIY34J/srBuL0JnAS3qKW4J34NepUQa0l0/S43Q3w=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.73", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Xnc8S6kGIVcdwqqTq6jk50UVe1QtOXp+B0v4iH85iNW1Ljf198OoA7RcVA+edFb6o01PVwnhIIPtpkB/A4710w=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.72", "", { "os": "darwin", "cpu": "x64" }, "sha512-hHUQw8i2LWPToRW1rjAiRqmNf34iJPS9ve9CJDygvFs5JOqUxN5yrfLfKfE+1bQjfFDHnpqW1HUk96iLhkPj8Q=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.73", "", { "os": "darwin", "cpu": "x64" }, "sha512-RlgxQxu+kxsCZzeXRnpYrqbrpxbG8M/lnDf4sTPWmhXUiuDvY5BdB4YiBY5bv8eNdJ1j9HiMLtx6ZxElEviidA=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.72", "", { "os": "linux", "cpu": "arm64" }, "sha512-63yml0OQ8tVa0JuDF9lBAWiChX6Q+iDO7lKv7c2n0352n/WyPr3iAgq4uSoH49HXuKeAXY/VwHGjvPzjXD/SDA=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.73", "", { "os": "linux", "cpu": "arm64" }, "sha512-9I88BdZMB3qtDPtDzFTg1EEt6sAGFSpOEmIIMB3MhqZqoq9+WSEyJZxM0/kff5vt4RJnqG7vz4fKMVRwNrUPGA=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.72", "", { "os": "linux", "cpu": "x64" }, "sha512-51veiQXNLvzDsFzsEvt71uK7WhiRe2DnvlJSGBSe6aRRHHxjCFYHzYi7t6bitJqtDTUj+EaMPbH81oZ6xy7tyg=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.73", "", { "os": "linux", "cpu": "x64" }, "sha512-50cGZkCh/i3nzijsjUnkmtWJtnJ6l9WpdIwSJsO2Id7nZdzupT1b6AkgGZdOgNl23MHXpAitmb+MhEAjAimCRA=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.72", "", { "os": "win32", "cpu": "arm64" }, "sha512-1Ep6OcaYTy1RlLOln+LNN7DL1iNyLwLjG2M8aO0pVJKFvxeD5P7rdRzY065E4uhkHeJIHuduUqxvUjD0dyuwbw=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.73", "", { "os": "win32", "cpu": "arm64" }, "sha512-mFiEeoiim5cmi6qu8CDfeecl9ivuMilfby/GnqTsr9G8e52qfT6nWF2m9Nevh9ebhXK+D/VnVhJIbObc0WIchA=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.72", "", { "os": "win32", "cpu": "x64" }, "sha512-5QUv91UkOINlkEaPky3kaxmJvshcJMBAX7LZtIroduaKBGpWRA1aogNhPZzp+30WkvgOU7aOtUktAZuFXb9WdQ=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.73", "", { "os": "win32", "cpu": "x64" }, "sha512-vzWHUi2vgwImuyxl+hlmK0aeCbnwozeuicIcHJE0orPOwp2PAKyR9WO330szAvfIO5ZPbNkjWfh6xIYnASM0lQ=="], - "@opentui/solid": ["@opentui/solid@0.1.72", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.72", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-hytoLPboL/MTY/BQUnf/HlBuNXTVONney0X+PIQI82wT7kMx7+HHI2wnowpM3dyvA7l6NfORSud2cs9kIUBFBw=="], + "@opentui/solid": ["@opentui/solid@0.1.73", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.73", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-FBSTiuWl+hHqFxmrJfC93cbJ0PJ4QoFbvRFuD6Gzrea5rH+G7BidjyI8YZuCcNnriDuIYaXTJdvBqe15lgKR1A=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], @@ -1600,6 +1612,8 @@ "@smithy/uuid": ["@smithy/uuid@1.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw=="], + "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], + "@solid-primitives/active-element": ["@solid-primitives/active-element@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-9t5K4aR2naVDj950XU8OjnLgOg94a8k5wr6JNOPK+N5ESLsJDq42c1ZP8UKpewi1R+wplMMxiM6OPKRzbxJY7A=="], "@solid-primitives/audio": ["@solid-primitives/audio@1.4.2", "", { "dependencies": { "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-UMD3ORQfI5Ky8yuKPxidDiEazsjv/dsoiKK5yZxLnsgaeNR1Aym3/77h/qT1jBYeXUgj4DX6t7NMpFUSVr14OQ=="], @@ -1652,6 +1666,8 @@ "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], + "@stripe/stripe-js": ["@stripe/stripe-js@8.6.1", "", {}, "sha512-UJ05U2062XDgydbUcETH1AoRQLNhigQ2KmDn1BG8sC3xfzu6JKg95Qt6YozdzFpxl1Npii/02m2LEWFt1RYjVA=="], + "@swc/helpers": ["@swc/helpers@0.5.17", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A=="], "@tailwindcss/node": ["@tailwindcss/node@4.1.11", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.11" } }, "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q=="], @@ -1758,7 +1774,7 @@ "@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="], - "@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="], + "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -1888,7 +1904,7 @@ "@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="], - "@webgpu/types": ["@webgpu/types@0.1.66", "", {}, "sha512-YA2hLrwLpDsRueNDXIMqN9NTzD6bCDkuXbOSe0heS+f8YE8usA6Gbv1prj81pzVHrbaAma7zObnIC+I6/sXJgA=="], + "@webgpu/types": ["@webgpu/types@0.1.54", "", {}, "sha512-81oaalC8LFrXjhsczomEQ0u3jG+TqE6V9QHLA8GNZq/Rnot0KDugu3LhSYSlie8tSdooAN1Hov05asrUUp9qgg=="], "@zip.js/zip.js": ["@zip.js/zip.js@2.7.62", "", {}, "sha512-OaLvZ8j4gCkLn048ypkZu29KX30r8/OfFF2w4Jo5WXFr+J04J+lzJ5TKZBVgFXhlvSkqNFQdfnY1Q8TMTCyBVA=="], @@ -2060,7 +2076,7 @@ "bun-pty": ["bun-pty@0.4.4", "", {}, "sha512-WK4G6uWsZgu1v4hKIlw6G1q2AOf8Rbga2Yr7RnxArVjjyb+mtVa/CFc9GOJf+OYSJSH8k7LonAtQOVeNAddRyg=="], - "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], + "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], "bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="], @@ -2318,6 +2334,10 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "engine.io-client": ["engine.io-client@6.6.4", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.18.3", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw=="], + + "engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="], + "enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="], "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], @@ -2540,6 +2560,10 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "graphql": ["graphql@16.12.0", "", {}, "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ=="], + + "graphql-request": ["graphql-request@6.1.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.2.0", "cross-fetch": "^3.1.5" }, "peerDependencies": { "graphql": "14 - 16" } }, "sha512-p+XPfS4q7aIpKVcgmnZKhMNqhltk20hfXtkaIkTfjjmiKMJ5xrt5c743cL03y/K7y1rg3WrIC49xGiEQ4mxdNw=="], + "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], "gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="], @@ -2768,6 +2792,8 @@ "isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="], + "isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="], + "iterate-iterator": ["iterate-iterator@1.0.2", "", {}, "sha512-t91HubM4ZDQ70M9wqp+pcNpu8OyJ9UAtXntT/Bcsvp5tZMnz9vRa+IunKXeI8AnfZMTv0jNuVEmGeLSMjVvfPw=="], "iterate-value": ["iterate-value@1.0.2", "", { "dependencies": { "es-get-iterator": "^1.0.2", "iterate-iterator": "^1.0.1" } }, "sha512-A6fMAio4D2ot2r/TYzr4yUWrmwNdsN5xL7+HUiyACE4DXm+q8HtPcnFTp+NnW3k4N05tZ7FVYFFb2CR13NxyHQ=="], @@ -2800,6 +2826,8 @@ "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + "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=="], @@ -3076,6 +3104,8 @@ "named-placeholders": ["named-placeholders@1.1.3", "", { "dependencies": { "lru-cache": "^7.14.1" } }, "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w=="], + "nanoevents": ["nanoevents@7.0.1", "", {}, "sha512-o6lpKiCxLeijK4hgsqfR6CNToPyRU3keKyyI6uwuHRvpRTbZ0wXw51WRgyldVugZqoJfkGFrjrIenYH3bfEO3Q=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], @@ -3518,6 +3548,10 @@ "smol-toml": ["smol-toml@1.5.2", "", {}, "sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ=="], + "socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="], + + "socket.io-parser": ["socket.io-parser@4.2.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ=="], + "solid-js": ["solid-js@1.9.10", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew=="], "solid-list": ["solid-list@0.3.0", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-t4hx/F/l8Vmq+ib9HtZYl7Z9F1eKxq3eKJTXlvcm7P7yI4Z8O7QSOOEVHb/K6DD7M0RxzVRobK/BS5aSfLRwKg=="], @@ -3528,6 +3562,8 @@ "solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="], + "solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="], + "solid-use": ["solid-use@0.9.1", "", { "peerDependencies": { "solid-js": "^1.7" } }, "sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw=="], "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], @@ -3682,6 +3718,8 @@ "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], @@ -3874,6 +3912,8 @@ "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + "xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="], + "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], @@ -4024,6 +4064,8 @@ "@expressive-code/plugin-shiki/shiki": ["shiki@3.15.0", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/engine-javascript": "3.15.0", "@shikijs/engine-oniguruma": "3.15.0", "@shikijs/langs": "3.15.0", "@shikijs/themes": "3.15.0", "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kLdkY6iV3dYbtPwS9KXU7mjfmDm25f5m0IPNFnaXO7TBPcvbUOY72PYXSuSqDzwp+vlH/d7MXpHlKO/x+QoLXw=="], + "@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@hey-api/json-schema-ref-parser/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], @@ -4250,6 +4292,8 @@ "body-parser/qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="], + "bun-webgpu/@webgpu/types": ["@webgpu/types@0.1.66", "", {}, "sha512-YA2hLrwLpDsRueNDXIMqN9NTzD6bCDkuXbOSe0heS+f8YE8usA6Gbv1prj81pzVHrbaAma7zObnIC+I6/sXJgA=="], + "clean-css/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "compress-commons/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], @@ -4266,6 +4310,8 @@ "editorconfig/minimatch": ["minimatch@9.0.1", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w=="], + "engine.io-client/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "es-get-iterator/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], "esbuild-plugin-copy/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], diff --git a/flake.lock b/flake.lock index 3e4611cf5e..58bdca6bf6 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1768178648, - "narHash": "sha256-kz/F6mhESPvU1diB7tOM3nLcBfQe7GU7GQCymRlTi/s=", + "lastModified": 1768395095, + "narHash": "sha256-ZhuYJbwbZT32QA95tSkXd9zXHcdZj90EzHpEXBMabaw=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "3fbab70c6e69c87ea2b6e48aa6629da2aa6a23b0", + "rev": "13868c071cc73a5e9f610c47d7bb08e5da64fdd5", "type": "github" }, "original": { diff --git a/github/README.md b/github/README.md index 954710f234..8238bdc42a 100644 --- a/github/README.md +++ b/github/README.md @@ -81,12 +81,13 @@ This will walk you through installing the GitHub app, creating the workflow, and permissions: id-token: write steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - fetch-depth: 1 + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 1 + persist-credentials: false - - name: Run opencode + - name: Run opencode uses: anomalyco/opencode/github@latest env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/infra/console.ts b/infra/console.ts index 1368ef202a..17e4deab6e 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -122,6 +122,7 @@ const ZEN_MODELS = [ ] const ZEN_BLACK = new sst.Secret("ZEN_BLACK") const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY") +const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY") const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", { properties: { value: auth.url.apply((url) => url!) }, }) @@ -177,6 +178,7 @@ new sst.cloudflare.x.SolidStart("Console", { //VITE_DOCS_URL: web.url.apply((url) => url!), //VITE_API_URL: gateway.url.apply((url) => url!), VITE_AUTH_URL: auth.url.apply((url) => url!), + VITE_STRIPE_PUBLISHABLE_KEY: STRIPE_PUBLISHABLE_KEY.value, }, transform: { server: { diff --git a/install b/install index 757694481c..22b7ca39ed 100755 --- a/install +++ b/install @@ -369,7 +369,7 @@ case $current_shell in config_files="$HOME/.config/fish/config.fish" ;; zsh) - config_files="$HOME/.zshrc $HOME/.zshenv $XDG_CONFIG_HOME/zsh/.zshrc $XDG_CONFIG_HOME/zsh/.zshenv" + config_files="${ZDOTDIR:-$HOME}/.zshrc ${ZDOTDIR:-$HOME}/.zshenv $XDG_CONFIG_HOME/zsh/.zshrc $XDG_CONFIG_HOME/zsh/.zshenv" ;; bash) config_files="$HOME/.bashrc $HOME/.bash_profile $HOME/.profile $XDG_CONFIG_HOME/bash/.bashrc $XDG_CONFIG_HOME/bash/.bash_profile" diff --git a/nix/hashes.json b/nix/hashes.json index 4c953c5a87..a84eec5b47 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,6 +1,6 @@ { "nodeModules": { - "x86_64-linux": "sha256-UCPTTk4b7d2bets7KgCeYBHWAUwUAPUyKm+xDYkSexE=", - "aarch64-darwin": "sha256-Y3o6lovahSWoG9un/l1qxu7hCmIlZXm2LxOLKNiPQfQ=" + "x86_64-linux": "sha256-Fl1BdjNSg19LJVSgDMiBX8JuTaGlL2I5T+rqLfjSeO4=", + "aarch64-darwin": "sha256-7UajHu40n7JKqurU/+CGlitErsVFA2qDneUytI8+/zQ=" } } diff --git a/package.json b/package.json index d134a187a7..f1d6c4fead 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "packages/slack" ], "catalog": { - "@types/bun": "1.3.4", + "@types/bun": "1.3.5", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", "ulid": "3.0.1", diff --git a/packages/app/index.html b/packages/app/index.html index e0fbe6913d..450807a42e 100644 --- a/packages/app/index.html +++ b/packages/app/index.html @@ -13,12 +13,11 @@ - -
+
diff --git a/packages/app/package.json b/packages/app/package.json index bef67c82c8..8c9b5bee54 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.1.16", + "version": "1.1.21", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 13f2b00a37..2f85652a93 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -33,6 +33,8 @@ import { useSync } from "@/context/sync" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import type { IconName } from "@opencode-ai/ui/icons/provider" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" @@ -362,6 +364,12 @@ export const PromptInput: Component = (props) => { if (!isFocused()) setStore("popover", null) }) + // Safety: reset composing state on focus change to prevent stuck state + // This handles edge cases where compositionend event may not fire + createEffect(() => { + if (!isFocused()) setComposing(false) + }) + type AtOption = { type: "agent"; name: string; display: string } | { type: "file"; path: string; display: string } const agentList = createMemo(() => @@ -879,6 +887,14 @@ export const PromptInput: Component = (props) => { } } + // Handle Shift+Enter BEFORE IME check - Shift+Enter is never used for IME input + // and should always insert a newline regardless of composition state + if (event.key === "Enter" && event.shiftKey) { + addPart({ type: "text", content: "\n", start: 0, end: 0 }) + event.preventDefault() + return + } + if (event.key === "Enter" && isImeComposing(event)) { return } @@ -942,11 +958,7 @@ export const PromptInput: Component = (props) => { return } - if (event.key === "Enter" && event.shiftKey) { - addPart({ type: "text", content: "\n", start: 0, end: 0 }) - event.preventDefault() - return - } + // Note: Shift+Enter is handled earlier, before IME check if (event.key === "Enter" && !event.shiftKey) { handleSubmit(event) } @@ -1560,6 +1572,9 @@ export const PromptInput: Component = (props) => { fallback={ @@ -1569,6 +1584,9 @@ export const PromptInput: Component = (props) => { @@ -1583,10 +1601,10 @@ export const PromptInput: Component = (props) => { > diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index cfc6eb4387..4ba5413dfd 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -1,267 +1,206 @@ import { createMemo, createResource, Show } from "solid-js" -import { A, useNavigate, useParams } from "@solidjs/router" +import { Portal } from "solid-js/web" +import { useParams } from "@solidjs/router" import { useLayout } from "@/context/layout" import { useCommand } from "@/context/command" -import { useServer } from "@/context/server" -import { useDialog } from "@opencode-ai/ui/context/dialog" +// import { useServer } from "@/context/server" +// import { useDialog } from "@opencode-ai/ui/context/dialog" import { useSync } from "@/context/sync" import { useGlobalSDK } from "@/context/global-sdk" import { getFilename } from "@opencode-ai/util/path" -import { base64Decode, base64Encode } from "@opencode-ai/util/encode" +import { base64Decode } from "@opencode-ai/util/encode" import { iife } from "@opencode-ai/util/iife" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Button } from "@opencode-ai/ui/button" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" -import { Select } from "@opencode-ai/ui/select" import { Popover } from "@opencode-ai/ui/popover" import { TextField } from "@opencode-ai/ui/text-field" -import { DialogSelectServer } from "@/components/dialog-select-server" -import { SessionLspIndicator } from "@/components/session-lsp-indicator" -import { SessionMcpIndicator } from "@/components/session-mcp-indicator" -import type { Session } from "@opencode-ai/sdk/v2/client" -import { same } from "@/utils/same" export function SessionHeader() { const globalSDK = useGlobalSDK() const layout = useLayout() const params = useParams() - const navigate = useNavigate() const command = useCommand() - const server = useServer() - const dialog = useDialog() + // const server = useServer() + // const dialog = useDialog() const sync = useSync() const projectDirectory = createMemo(() => base64Decode(params.dir ?? "")) - - const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID)) - const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id)) - const parentSession = createMemo(() => { - const current = currentSession() - if (!current?.parentID) return undefined - return sync.data.session.find((s) => s.id === current.parentID) + const project = createMemo(() => { + const directory = projectDirectory() + if (!directory) return + return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory)) }) + const name = createMemo(() => { + const current = project() + if (current) return current.name || getFilename(current.worktree) + return getFilename(projectDirectory()) + }) + const hotkey = createMemo(() => command.keybind("file.open")) + + const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id)) const shareEnabled = createMemo(() => sync.data.config.share !== "disabled") - const worktrees = createMemo(() => layout.projects.list().map((p) => p.worktree), [], { equals: same }) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const view = createMemo(() => layout.view(sessionKey())) - function navigateToProject(directory: string) { - navigate(`/${base64Encode(directory)}`) - } - - function navigateToSession(session: Session | undefined) { - if (!session) return - // Only navigate if we're actually changing to a different session - if (session.id === params.id) return - navigate(`/${params.dir}/session/${session.id}`) - } + const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center")) + const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right")) return ( -
- -
-
-
- - - x.title} - value={(x) => x.id} - onSelect={(session) => { - // Only navigate if selecting a different session than current parent - const currentParent = parentSession() - if (session && currentParent && session.id !== currentParent.id) { - navigateToSession(session) - } - }} - class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md" - variant="ghost" - /> -
/
-
- - - -
+
+ + Search {name()}
-
-
- - - -
-
- -
- - + + {(mount) => ( + +
+ {/* */} +
+ + + + - - -
- - - - - } - > - {iife(() => { - const [url] = createResource( - () => currentSession(), - async (session) => { - if (!session) return - let shareURL = session.share?.url - if (!shareURL) { - shareURL = await globalSDK.client.session - .share({ sessionID: session.id, directory: projectDirectory() }) - .then((r) => r.data?.share?.url) - .catch((e) => { - console.error("Failed to share session", e) - return undefined - }) - } - return shareURL - }, - { initialValue: "" }, - ) - return ( - - {(shareUrl) => } - - ) - })} - - -
-
-
+ +
+ + + + + + } + > + {iife(() => { + const [url] = createResource( + () => currentSession(), + async (session) => { + if (!session) return + let shareURL = session.share?.url + if (!shareURL) { + shareURL = await globalSDK.client.session + .share({ sessionID: session.id, directory: projectDirectory() }) + .then((r) => r.data?.share?.url) + .catch((e) => { + console.error("Failed to share session", e) + return undefined + }) + } + return shareURL + }, + { initialValue: "" }, + ) + return ( + + {(shareUrl) => } + + ) + })} + + + + + )} + + ) } diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx new file mode 100644 index 0000000000..2192ed0e4a --- /dev/null +++ b/packages/app/src/components/titlebar.tsx @@ -0,0 +1,115 @@ +import { createEffect, createMemo, Show } from "solid-js" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { useTheme } from "@opencode-ai/ui/theme" + +import { useLayout } from "@/context/layout" +import { usePlatform } from "@/context/platform" +import { useCommand } from "@/context/command" + +export function Titlebar() { + const layout = useLayout() + const platform = usePlatform() + const command = useCommand() + const theme = useTheme() + + const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") + const reserve = createMemo( + () => platform.platform === "desktop" && (platform.os === "windows" || platform.os === "linux"), + ) + + const getWin = () => { + if (platform.platform !== "desktop") return + + const tauri = ( + window as unknown as { + __TAURI__?: { window?: { getCurrentWindow?: () => { startDragging?: () => Promise } } } + } + ).__TAURI__ + if (!tauri?.window?.getCurrentWindow) return + + return tauri.window.getCurrentWindow() + } + + createEffect(() => { + if (platform.platform !== "desktop") return + + const scheme = theme.colorScheme() + const value = scheme === "system" ? null : scheme + + const tauri = (window as unknown as { __TAURI__?: { webviewWindow?: { getCurrentWebviewWindow?: () => unknown } } }) + .__TAURI__ + const get = tauri?.webviewWindow?.getCurrentWebviewWindow + if (!get) return + + const win = get() as { setTheme?: (theme?: "light" | "dark" | null) => Promise } + if (!win.setTheme) return + + void win.setTheme(value).catch(() => undefined) + }) + + const interactive = (target: EventTarget | null) => { + if (!(target instanceof Element)) return false + + const selector = + "button, a, input, textarea, select, option, [role='button'], [role='menuitem'], [contenteditable='true'], [contenteditable='']" + + return !!target.closest(selector) + } + + const drag = (e: MouseEvent) => { + if (platform.platform !== "desktop") return + if (e.buttons !== 1) return + if (interactive(e.target)) return + + const win = getWin() + if (!win?.startDragging) return + + e.preventDefault() + void win.startDragging().catch(() => undefined) + } + + return ( +
+
+ +
+ + + +
+
+
+ +
+ +
+
+
+
+
+ ) +} diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index ddac1f2286..a0b2570568 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -16,6 +16,7 @@ import { type LspStatus, type VcsInfo, type PermissionRequest, + type QuestionRequest, createOpencodeClient, } from "@opencode-ai/sdk/v2/client" import { createStore, produce, reconcile } from "solid-js/store" @@ -37,6 +38,7 @@ type State = { config: Config path: Path session: Session[] + sessionTotal: number session_status: { [sessionID: string]: SessionStatus } @@ -49,6 +51,9 @@ type State = { permission: { [sessionID: string]: PermissionRequest[] } + question: { + [sessionID: string]: QuestionRequest[] + } mcp: { [name: string]: McpStatus } @@ -94,10 +99,12 @@ function createGlobalSync() { agent: [], command: [], session: [], + sessionTotal: 0, session_status: {}, session_diff: {}, todo: {}, permission: {}, + question: {}, mcp: {}, lsp: [], vcs: undefined, @@ -112,21 +119,32 @@ function createGlobalSync() { async function loadSessions(directory: string) { const [store, setStore] = child(directory) - globalSDK.client.session - .list({ directory }) + const limit = store.limit + + return globalSDK.client.session + .list({ directory, roots: true }) .then((x) => { - const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000 const nonArchived = (x.data ?? []) .filter((s) => !!s?.id) .filter((s) => !s.time?.archived) .slice() .sort((a, b) => a.id.localeCompare(b.id)) + + const sandboxWorkspace = globalStore.project.some((p) => (p.sandboxes ?? []).includes(directory)) + if (sandboxWorkspace) { + setStore("session", reconcile(nonArchived, { key: "id" })) + return + } + + const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000 // Include up to the limit, plus any updated in the last 4 hours const sessions = nonArchived.filter((s, i) => { - if (i < store.limit) return true + if (i < limit) return true const updated = new Date(s.time?.updated ?? s.time?.created).getTime() return updated > fourHoursAgo }) + // Store total session count (used for "load more" pagination) + setStore("sessionTotal", nonArchived.length) setStore("session", reconcile(sessions, { key: "id" })) }) .catch((err) => { @@ -208,6 +226,38 @@ function createGlobalSync() { } }) }), + sdk.question.list().then((x) => { + const grouped: Record = {} + for (const question of x.data ?? []) { + if (!question?.id || !question.sessionID) continue + const existing = grouped[question.sessionID] + if (existing) { + existing.push(question) + continue + } + grouped[question.sessionID] = [question] + } + + batch(() => { + for (const sessionID of Object.keys(store.question)) { + if (grouped[sessionID]) continue + setStore("question", sessionID, []) + } + for (const [sessionID, questions] of Object.entries(grouped)) { + setStore( + "question", + sessionID, + reconcile( + questions + .filter((q) => !!q?.id) + .slice() + .sort((a, b) => a.id.localeCompare(b.id)), + { key: "id" }, + ), + ) + } + }) + }), ]).then(() => { setStore("status", "complete") }) @@ -396,6 +446,44 @@ function createGlobalSync() { ) break } + case "question.asked": { + const sessionID = event.properties.sessionID + const questions = store.question[sessionID] + if (!questions) { + setStore("question", sessionID, [event.properties]) + break + } + + const result = Binary.search(questions, event.properties.id, (q) => q.id) + if (result.found) { + setStore("question", sessionID, result.index, reconcile(event.properties)) + break + } + + setStore( + "question", + sessionID, + produce((draft) => { + draft.splice(result.index, 0, event.properties) + }), + ) + break + } + case "question.replied": + case "question.rejected": { + const questions = store.question[event.properties.sessionID] + if (!questions) break + const result = Binary.search(questions, event.properties.requestID, (q) => q.id) + if (!result.found) break + setStore( + "question", + event.properties.sessionID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + break + } case "lsp.updated": { const sdk = createOpencodeClient({ baseUrl: globalSDK.url, diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 385f564fa5..a49e891bf8 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -47,12 +47,34 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const globalSdk = useGlobalSDK() const globalSync = useGlobalSync() const server = useServer() + + const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + + const migrate = (value: unknown) => { + if (!isRecord(value)) return value + const sidebar = value.sidebar + if (!isRecord(sidebar)) return value + if (typeof sidebar.workspaces !== "boolean") return value + return { + ...value, + sidebar: { + ...sidebar, + workspaces: {}, + workspacesDefault: sidebar.workspaces, + }, + } + } + + const target = Persist.global("layout", ["layout.v6"]) const [store, setStore, _, ready] = persisted( - Persist.global("layout", ["layout.v6"]), + { ...target, migrate }, createStore({ sidebar: { opened: false, width: 280, + workspaces: {} as Record, + workspacesDefault: false, }, terminal: { height: 280, @@ -304,6 +326,16 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( resize(width: number) { setStore("sidebar", "width", width) }, + workspaces(directory: string) { + return createMemo(() => store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false) + }, + setWorkspaces(directory: string, value: boolean) { + setStore("sidebar", "workspaces", directory, value) + }, + toggleWorkspaces(directory: string) { + const current = store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false + setStore("sidebar", "workspaces", directory, !current) + }, }, terminal: { height: createMemo(() => store.terminal.height), diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index b0822e7078..6d2d3db060 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -5,6 +5,9 @@ export type Platform = { /** Platform discriminator */ platform: "web" | "desktop" + /** Desktop OS (Tauri only) */ + os?: "macos" | "windows" | "linux" + /** App version */ version?: string diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index e5f2c076ed..33129e1b47 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -14,7 +14,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const sdk = useSDK() const [store, setStore] = globalSync.child(sdk.directory) const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/") - const chunk = 200 + const chunk = 400 const inflight = new Map>() const inflightDiff = new Map>() const inflightTodo = new Map>() diff --git a/packages/app/src/index.css b/packages/app/src/index.css index e40f0842b1..d9d51aa8fb 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -5,3 +5,7 @@ cursor: default; } } + +*[data-tauri-drag-region] { + app-region: drag; +} diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 39124637c2..dca02489a8 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -7,6 +7,7 @@ import { LocalProvider } from "@/context/local" import { base64Decode } from "@opencode-ai/util/encode" import { DataProvider } from "@opencode-ai/ui/context" import { iife } from "@opencode-ai/util/iife" +import type { QuestionAnswer } from "@opencode-ai/sdk/v2" export default function Layout(props: ParentProps) { const params = useParams() @@ -27,6 +28,11 @@ export default function Layout(props: ParentProps) { response: "once" | "always" | "reject" }) => sdk.client.permission.respond(input) + const replyToQuestion = (input: { requestID: string; answers: QuestionAnswer[] }) => + sdk.client.question.reply(input) + + const rejectQuestion = (input: { requestID: string }) => sdk.client.question.reject(input) + const navigateToSession = (sessionID: string) => { navigate(`/${params.dir}/session/${sessionID}`) } @@ -36,6 +42,8 @@ export default function Layout(props: ParentProps) { data={sync.data} directory={directory()} onPermissionRespond={respond} + onQuestionReply={replyToQuestion} + onQuestionReject={rejectQuestion} onNavigateToSession={navigateToSession} > {props.children} diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index fa9330fc2a..67eed26d10 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -13,7 +13,6 @@ import { untrack, type JSX, } from "solid-js" -import { DateTime } from "luxon" import { A, useNavigate, useParams } from "@solidjs/router" import { useLayout, getAvatarColors, LocalProject } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" @@ -23,12 +22,12 @@ import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { HoverCard } from "@opencode-ai/ui/hover-card" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { Spinner } from "@opencode-ai/ui/spinner" -import { Mark } from "@opencode-ai/ui/logo" import { getFilename } from "@opencode-ai/util/path" -import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Session } from "@opencode-ai/sdk/v2/client" import { usePlatform } from "@/context/platform" import { createStore, produce, reconcile } from "solid-js/store" @@ -52,28 +51,25 @@ import { retry } from "@opencode-ai/util/retry" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { DialogSelectProvider } from "@/components/dialog-select-provider" -import { DialogEditProject } from "@/components/dialog-edit-project" import { DialogSelectServer } from "@/components/dialog-select-server" import { useCommand, type CommandOption } from "@/context/command" import { ProjectIcon } from "@/components/project-icon" import { ConstrainDragXAxis } from "@/utils/solid-dnd" import { navStart } from "@/utils/perf" import { DialogSelectDirectory } from "@/components/dialog-select-directory" +import { DialogEditProject } from "@/components/dialog-edit-project" +import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" export default function Layout(props: ParentProps) { const [store, setStore] = createStore({ lastSession: {} as { [directory: string]: string }, - activeDraggable: undefined as string | undefined, - mobileProjectsExpanded: {} as Record, + activeProject: undefined as string | undefined, + activeWorkspace: undefined as string | undefined, + workspaceOrder: {} as Record, + workspaceExpanded: {} as Record, }) - const mobileProjects = { - expanded: (directory: string) => store.mobileProjectsExpanded[directory] ?? true, - expand: (directory: string) => setStore("mobileProjectsExpanded", directory, true), - collapse: (directory: string) => setStore("mobileProjectsExpanded", directory, false), - } - let scrollContainerRef: HTMLDivElement | undefined const xlQuery = window.matchMedia("(min-width: 1280px)") const [isLargeViewport, setIsLargeViewport] = createSignal(xlQuery.matches) @@ -165,53 +161,64 @@ export default function Layout(props: ParentProps) { }) onMount(() => { + const alerts = { + "permission.asked": { + title: "Permission required", + icon: "checklist" as const, + description: (sessionTitle: string, projectName: string) => + `${sessionTitle} in ${projectName} needs permission`, + }, + "question.asked": { + title: "Question", + icon: "bubble-5" as const, + description: (sessionTitle: string, projectName: string) => `${sessionTitle} in ${projectName} has a question`, + }, + } + const toastBySession = new Map() const alertedAtBySession = new Map() - const permissionAlertCooldownMs = 5000 + const cooldownMs = 5000 const unsub = globalSDK.event.listen((e) => { - if (e.details?.type !== "permission.asked") return + if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return + const config = alerts[e.details.type] const directory = e.name - const perm = e.details.properties - if (permission.autoResponds(perm, directory)) return + const props = e.details.properties + if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return const [store] = globalSync.child(directory) - const session = store.session.find((s) => s.id === perm.sessionID) - const sessionKey = `${directory}:${perm.sessionID}` + const session = store.session.find((s) => s.id === props.sessionID) + const sessionKey = `${directory}:${props.sessionID}` const sessionTitle = session?.title ?? "New session" const projectName = getFilename(directory) - const description = `${sessionTitle} in ${projectName} needs permission` - const href = `/${base64Encode(directory)}/session/${perm.sessionID}` + const description = config.description(sessionTitle, projectName) + const href = `/${base64Encode(directory)}/session/${props.sessionID}` const now = Date.now() const lastAlerted = alertedAtBySession.get(sessionKey) ?? 0 - if (now - lastAlerted < permissionAlertCooldownMs) return + if (now - lastAlerted < cooldownMs) return alertedAtBySession.set(sessionKey, now) - void platform.notify("Permission required", description, href) + void platform.notify(config.title, description, href) const currentDir = params.dir ? base64Decode(params.dir) : undefined const currentSession = params.id - if (directory === currentDir && perm.sessionID === currentSession) return + if (directory === currentDir && props.sessionID === currentSession) return if (directory === currentDir && session?.parentID === currentSession) return const existingToastId = toastBySession.get(sessionKey) - if (existingToastId !== undefined) { - toaster.dismiss(existingToastId) - } + if (existingToastId !== undefined) toaster.dismiss(existingToastId) const toastId = showToast({ persistent: true, - icon: "checklist", - title: "Permission required", + icon: config.icon, + title: config.title, description, actions: [ { label: "Go to session", - onClick: () => { - navigate(href) - }, + onClick: () => navigate(href), }, { label: "Dismiss", @@ -275,17 +282,59 @@ export default function Layout(props: ParentProps) { return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory)) }) - function projectSessions(project: LocalProject | undefined) { - if (!project) return [] - const dirs = [project.worktree, ...(project.sandboxes ?? [])] - const stores = dirs.map((dir) => globalSync.child(dir)[0]) - const sessions = stores - .flatMap((store) => store.session.filter((session) => session.directory === store.path.directory)) - .toSorted(sortSessions) - return sessions.filter((s) => !s.parentID) - } + const workspaceSetting = createMemo(() => { + const project = currentProject() + if (!project) return false + return layout.sidebar.workspaces(project.worktree)() + }) - const currentSessions = createMemo(() => projectSessions(currentProject())) + createEffect(() => { + const project = currentProject() + if (!project) return + + const dirs = [project.worktree, ...(project.sandboxes ?? [])] + const existing = store.workspaceOrder[project.worktree] + if (!existing) { + setStore("workspaceOrder", project.worktree, dirs) + return + } + + const keep = existing.filter((d) => dirs.includes(d)) + const missing = dirs.filter((d) => !existing.includes(d)) + const merged = [...keep, ...missing] + + if (merged.length !== existing.length) { + setStore("workspaceOrder", project.worktree, merged) + return + } + + if (merged.some((d, i) => d !== existing[i])) { + setStore("workspaceOrder", project.worktree, merged) + } + }) + + const currentSessions = createMemo(() => { + const project = currentProject() + if (!project) return [] as Session[] + if (workspaceSetting()) { + const dirs = workspaceIds(project) + const result: Session[] = [] + for (const dir of dirs) { + const [dirStore] = globalSync.child(dir) + const dirSessions = dirStore.session + .filter((session) => session.directory === dirStore.path.directory) + .filter((session) => !session.parentID) + .toSorted(sortSessions) + result.push(...dirSessions) + } + return result + } + const [projectStore] = globalSync.child(project.worktree) + return projectStore.session + .filter((session) => session.directory === projectStore.path.directory) + .filter((session) => !session.parentID) + .toSorted(sortSessions) + }) type PrefetchQueue = { inflight: Set @@ -325,7 +374,7 @@ export default function Layout(props: ParentProps) { return created } - const prefetchMessages = (directory: string, sessionID: string, token: number) => { + async function prefetchMessages(directory: string, sessionID: string, token: number) { const [, setStore] = globalSync.child(directory) return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk })) @@ -428,89 +477,44 @@ export default function Layout(props: ParentProps) { }) function navigateSessionByOffset(offset: number) { - const projects = layout.projects.list() - if (projects.length === 0) return - - const project = currentProject() - const projectIndex = project ? projects.findIndex((p) => p.worktree === project.worktree) : -1 - - if (projectIndex === -1) { - const targetProject = offset > 0 ? projects[0] : projects[projects.length - 1] - if (targetProject) navigateToProject(targetProject.worktree) - return - } - const sessions = currentSessions() + if (sessions.length === 0) return + const sessionIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1 let targetIndex: number if (sessionIndex === -1) { targetIndex = offset > 0 ? 0 : sessions.length - 1 } else { - targetIndex = sessionIndex + offset + targetIndex = (sessionIndex + offset + sessions.length) % sessions.length } - if (targetIndex >= 0 && targetIndex < sessions.length) { - const session = sessions[targetIndex] - const next = sessions[targetIndex + 1] - const prev = sessions[targetIndex - 1] + const session = sessions[targetIndex] + if (!session) return - if (offset > 0) { - if (next) prefetchSession(next, "high") - if (prev) prefetchSession(prev) - } - - if (offset < 0) { - if (prev) prefetchSession(prev, "high") - if (next) prefetchSession(next) - } - - if (import.meta.env.DEV) { - navStart({ - dir: base64Encode(session.directory), - from: params.id, - to: session.id, - trigger: offset > 0 ? "alt+arrowdown" : "alt+arrowup", - }) - } - navigateToSession(session) - queueMicrotask(() => scrollToSession(session.id)) - return - } - - const nextProjectIndex = projectIndex + (offset > 0 ? 1 : -1) - const nextProject = projects[nextProjectIndex] - if (!nextProject) return - - const nextProjectSessions = projectSessions(nextProject) - if (nextProjectSessions.length === 0) { - navigateToProject(nextProject.worktree) - return - } - - const index = offset > 0 ? 0 : nextProjectSessions.length - 1 - const targetSession = nextProjectSessions[index] - const nextSession = nextProjectSessions[index + 1] - const prevSession = nextProjectSessions[index - 1] + const next = sessions[(targetIndex + 1) % sessions.length] + const prev = sessions[(targetIndex - 1 + sessions.length) % sessions.length] if (offset > 0) { - if (nextSession) prefetchSession(nextSession, "high") + if (next) prefetchSession(next, "high") + if (prev) prefetchSession(prev) } if (offset < 0) { - if (prevSession) prefetchSession(prevSession, "high") + if (prev) prefetchSession(prev, "high") + if (next) prefetchSession(next) } if (import.meta.env.DEV) { navStart({ - dir: base64Encode(targetSession.directory), + dir: base64Encode(session.directory), from: params.id, - to: targetSession.id, + to: session.id, trigger: offset > 0 ? "alt+arrowdown" : "alt+arrowup", }) } - navigateToSession(targetSession) - queueMicrotask(() => scrollToSession(targetSession.id)) + navigateToSession(session) + queueMicrotask(() => scrollToSession(session.id)) } async function archiveSession(session: Session) { @@ -704,18 +708,32 @@ export default function Layout(props: ParentProps) { const id = params.id setStore("lastSession", directory, id) notification.session.markViewed(id) - const project = currentProject() - untrack(() => layout.projects.expand(project?.worktree ?? directory)) + untrack(() => setStore("workspaceExpanded", directory, true)) requestAnimationFrame(() => scrollToSession(id)) }) createEffect(() => { - if (isLargeViewport()) { - const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48 - document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`) - } else { - document.documentElement.style.setProperty("--dialog-left-margin", "0px") + const project = currentProject() + if (!project) return + + if (workspaceSetting()) { + const dirs = [project.worktree, ...(project.sandboxes ?? [])] + for (const directory of dirs) { + globalSync.project.loadSessions(directory) + } + return } + + globalSync.project.loadSessions(project.worktree) + }) + + createEffect(() => { + if (isLargeViewport()) { + const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 64 + document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`) + return + } + document.documentElement.style.setProperty("--dialog-left-margin", "0px") }) function getDraggableId(event: unknown): string | undefined { @@ -729,7 +747,7 @@ export default function Layout(props: ParentProps) { function handleDragStart(event: unknown) { const id = getDraggableId(event) if (!id) return - setStore("activeDraggable", id) + setStore("activeProject", id) } function handleDragOver(event: DragEvent) { @@ -745,20 +763,56 @@ export default function Layout(props: ParentProps) { } function handleDragEnd() { - setStore("activeDraggable", undefined) + setStore("activeProject", undefined) } - const ProjectAvatar = (props: { - project: LocalProject - class?: string - expandable?: boolean - notify?: boolean - }): JSX.Element => { + function workspaceIds(project: LocalProject | undefined) { + if (!project) return [] + const dirs = [project.worktree, ...(project.sandboxes ?? [])] + const existing = store.workspaceOrder[project.worktree] + if (!existing) return dirs + + const keep = existing.filter((d) => dirs.includes(d)) + const missing = dirs.filter((d) => !existing.includes(d)) + return [...keep, ...missing] + } + + function handleWorkspaceDragStart(event: unknown) { + const id = getDraggableId(event) + if (!id) return + setStore("activeWorkspace", id) + } + + function handleWorkspaceDragOver(event: DragEvent) { + const { draggable, droppable } = event + if (!draggable || !droppable) return + + const project = currentProject() + if (!project) return + + const ids = workspaceIds(project) + const fromIndex = ids.findIndex((dir) => dir === draggable.id.toString()) + const toIndex = ids.findIndex((dir) => dir === droppable.id.toString()) + if (fromIndex === -1 || toIndex === -1) return + if (fromIndex === toIndex) return + + const result = ids.slice() + const [item] = result.splice(fromIndex, 1) + if (!item) return + result.splice(toIndex, 0, item) + setStore("workspaceOrder", project.worktree, result) + } + + function handleWorkspaceDragEnd() { + setStore("activeWorkspace", undefined) + } + + const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => { const notification = useNotification() const notifications = createMemo(() => notification.project.unseen(props.project.worktree)) const hasError = createMemo(() => notifications().some((n) => n.type === "error")) const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) - const mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)" + const mask = "radial-gradient(circle 6px at calc(100% - 3px) 3px, transparent 6px, black 6.5px)" const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" return ( @@ -780,11 +834,10 @@ export default function Layout(props: ParentProps) { size="normal" class="hidden size-full items-center justify-center text-text-subtle group-hover/session:flex group-data-[expanded]/trigger:rotate-90 transition-transform duration-50" /> - 0 && props.notify}>
{ - const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) - const current = createMemo(() => base64Decode(params.dir ?? "")) - return ( - - - - - - - - - ) - } - - const SessionItem = (props: { - session: Session - slug: string - project: LocalProject - mobile?: boolean - }): JSX.Element => { + const SessionItem = (props: { session: Session; slug: string; mobile?: boolean; dense?: boolean }): JSX.Element => { const notification = useNotification() - const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated)) const notifications = createMemo(() => notification.session.unseen(props.session.id)) const hasError = createMemo(() => notifications().some((n) => n.type === "error")) const [sessionStore] = globalSync.child(props.session.directory) @@ -849,299 +863,427 @@ export default function Layout(props: ParentProps) { return false }) const isWorking = createMemo(() => { - if (props.session.id === params.id) return false if (hasPermissions()) return false const status = sessionStore.session_status[props.session.id] return status?.type === "busy" || status?.type === "retry" }) + + const tint = createMemo(() => { + const messages = sessionStore.message[props.session.id] + if (!messages) return undefined + const user = messages + .slice() + .reverse() + .find((m) => m.role === "user") + if (!user?.agent) return undefined + + const agent = sessionStore.agent.find((a) => a.name === user.agent) + return agent?.color + }) + return ( - <> -
- - prefetchSession(props.session, "high")} - onFocus={() => prefetchSession(props.session, "high")} - > -
- - {props.session.title} - -
- - - - - -
- - -
- - 0}> -
- - - - {Math.abs(updated().diffNow().as("seconds")) < 60 - ? "Now" - : updated() - .toRelative({ - style: "short", - unit: ["days", "hours", "minutes"], - }) - ?.replace(" ago", "") - ?.replace(/ days?/, "d") - ?.replace(" min.", "m") - ?.replace(" hr.", "h")} - - - -
+
+ + prefetchSession(props.session, "high")} + onFocus={() => prefetchSession(props.session, "high")} + > +
+
+ + + + + + ) } const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => { const sortable = createSortable(props.project.worktree) - const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened()) - const defaultWorktree = createMemo(() => base64Encode(props.project.worktree)) - const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) - const [store, setProjectStore] = globalSync.child(props.project.worktree) - const stores = createMemo(() => - [props.project.worktree, ...(props.project.sandboxes ?? [])].map((dir) => globalSync.child(dir)[0]), - ) - const sessions = createMemo(() => - stores() - .flatMap((store) => store.session.filter((session) => session.directory === store.path.directory)) - .toSorted(sortSessions), - ) - const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID)) - const hasMoreSessions = createMemo(() => store.session.length >= store.limit) - const loadMoreSessions = async () => { - setProjectStore("limit", (limit) => limit + 5) - await globalSync.project.loadSessions(props.project.worktree) - } - const isExpanded = createMemo(() => - props.mobile ? mobileProjects.expanded(props.project.worktree) : props.project.expanded, - ) - const isActive = createMemo(() => { + const selected = createMemo(() => { const current = params.dir ? base64Decode(params.dir) : "" return props.project.worktree === current || props.project.sandboxes?.includes(current) }) - const handleOpenChange = (open: boolean) => { - if (props.mobile) { - if (open) mobileProjects.expand(props.project.worktree) - else mobileProjects.collapse(props.project.worktree) - } else { - if (open) layout.projects.expand(props.project.worktree) - else layout.projects.collapse(props.project.worktree) - } + + const workspaces = createMemo(() => workspaceIds(props.project).slice(0, 2)) + const workspaceEnabled = createMemo(() => layout.sidebar.workspaces(props.project.worktree)()) + const label = (directory: string) => { + const [data] = globalSync.child(directory) + const kind = directory === props.project.worktree ? "local" : "sandbox" + const name = data.vcs?.branch ?? getFilename(directory) + return `${kind} : ${name}` } + + const sessions = (directory: string) => { + const [data] = globalSync.child(directory) + return data.session + .filter((session) => session.directory === data.path.directory) + .filter((session) => !session.parentID) + .toSorted(sortSessions) + .slice(0, 2) + } + + const projectSessions = () => { + const [data] = globalSync.child(props.project.worktree) + return data.session + .filter((session) => session.directory === data.path.directory) + .filter((session) => !session.parentID) + .toSorted(sortSessions) + .slice(0, 2) + } + + const trigger = ( + + ) + return ( // @ts-ignore
- - - - - - - - - - - - - - - + )} + + +
+ +
+ +
+
+
+
) } const ProjectDragOverlay = (): JSX.Element => { - const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeDraggable)) + const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeProject)) return ( {(p) => ( -
- +
+
)} ) } - const SidebarContent = (sidebarProps: { mobile?: boolean }) => { - const expanded = () => sidebarProps.mobile || layout.sidebar.opened() + const WorkspaceDragOverlay = (): JSX.Element => { + const label = createMemo(() => { + const project = currentProject() + if (!project) return + const directory = store.activeWorkspace + if (!directory) return + + const [workspaceStore] = globalSync.child(directory) + const kind = directory === project.worktree ? "local" : "sandbox" + const name = workspaceStore.vcs?.branch ?? getFilename(directory) + return `${kind} : ${name}` + }) + return ( -
-
- -
- - - + + {(value) => ( +
{value()}
+ )} +
+ ) + } + + const SortableWorkspace = (props: { directory: string; project: LocalProject; mobile?: boolean }): JSX.Element => { + const sortable = createSortable(props.directory) + const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory) + const slug = createMemo(() => base64Encode(props.directory)) + const sessions = createMemo(() => + workspaceStore.session + .filter((session) => session.directory === workspaceStore.path.directory) + .filter((session) => !session.parentID) + .toSorted(sortSessions), + ) + const local = createMemo(() => props.directory === props.project.worktree) + const title = createMemo(() => { + const kind = local() ? "local" : "sandbox" + const name = workspaceStore.vcs?.branch ?? getFilename(props.directory) + return `${kind} : ${name}` + }) + const open = createMemo(() => store.workspaceExpanded[props.directory] ?? true) + const hasMore = createMemo(() => local() && workspaceStore.sessionTotal > workspaceStore.session.length) + const loadMore = async () => { + if (!local()) return + setWorkspaceStore("limit", (limit) => limit + 5) + await globalSync.project.loadSessions(props.directory) + } + + return ( + // @ts-ignore +
+ setStore("workspaceExpanded", props.directory, value)} + > +
+
+ +
+
+ +
+ {title()} + +
+
+ +
+
+ + + +
+
+ ) + } + + const LocalWorkspace = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => { + const [workspaceStore, setWorkspaceStore] = globalSync.child(props.project.worktree) + const slug = createMemo(() => base64Encode(props.project.worktree)) + const sessions = createMemo(() => + workspaceStore.session + .filter((session) => session.directory === workspaceStore.path.directory) + .filter((session) => !session.parentID) + .toSorted(sortSessions), + ) + const hasMore = createMemo(() => workspaceStore.sessionTotal > workspaceStore.session.length) + const loadMore = async () => { + setWorkspaceStore("limit", (limit) => limit + 5) + await globalSync.project.loadSessions(props.project.worktree) + } + + return ( +
{ + if (!props.mobile) scrollContainerRef = el + }} + class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar" + > + +
+ ) + } + + const SidebarContent = (sidebarProps: { mobile?: boolean }) => { + const expanded = () => sidebarProps.mobile || layout.sidebar.opened() + + const sync = useGlobalSync() + const project = createMemo(() => currentProject()) + const projectName = createMemo(() => { + const current = project() + if (!current) return "" + return current.name || getFilename(current.worktree) + }) + const workspaces = createMemo(() => workspaceIds(project())) + + const errorMessage = (err: unknown) => { + if (err && typeof err === "object" && "data" in err) { + const data = (err as { data?: { message?: string } }).data + if (data?.message) return data.message + } + if (err instanceof Error) return err.message + return "Request failed" + } + + const createWorkspace = async () => { + const current = project() + if (!current) return + + const created = await globalSDK.client.worktree + .create({ directory: current.worktree }) + .then((x) => x.data) + .catch((err) => { + showToast({ + title: "Failed to create workspace", + description: errorMessage(err), + }) + return undefined + }) + + if (!created?.directory) return + + globalSync.child(created.directory) + navigate(`/${base64Encode(created.directory)}/session`) + } + + const homedir = createMemo(() => sync.data.path.home) + + return ( +
+
+
-
{ - if (!sidebarProps.mobile) scrollContainerRef = el - }} - class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar" - > +
p.worktree)}> {(project) => } + + Open project + + {command.keybind("project.open")} + +
+ } + > + +
+
+ + + + + platform.openLink("https://opencode.ai/desktop-feedback")} + /> + +
-
- - 0 && !providers.paid().length && expanded()}> -
-
-
Getting started
-
OpenCode includes free models so you can start immediately.
-
Connect any provider to use models, inc. Claude, GPT, Gemini etc.
-
- + + +
+ + {(p) => ( + <> +
+
+
+ {projectName()} + + + {project()?.worktree.replace(homedir(), "~")} + + +
+ + + + + dialog.show(() => )}> + Edit + + layout.sidebar.toggleWorkspaces(p.worktree)}> + + {layout.sidebar.workspaces(p.worktree)() ? "Disable workspaces" : "Enable workspaces"} + + + + closeProject(p.worktree)}> + Close + + + + +
+
+ + +
+ +
+
+ +
+ + } + > + <> +
+ +
+
+ + + +
{ + if (!sidebarProps.mobile) scrollContainerRef = el + }} + class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar" + > + + + {(directory) => ( + + )} + + +
+ + + +
+
+ +
+ + )} +
+ 0 && providers.paid().length === 0}> +
+
+
+
Getting started
+
OpenCode includes free models so you can start immediately.
+
Connect any provider to use models, inc. Claude, GPT, Gemini etc.
+
- +
- - 0}> - - - - - - - Open project - - {command.keybind("project.open")} - -
- } - inactive={expanded()} - > - -
- - - -
+ +
+
) } return ( -
+
+
-
+
@@ -1277,7 +1496,7 @@ export default function Layout(props: ParentProps) {
e.stopPropagation()} > -
-
{props.children}
+
+ {props.children} +
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index aec9a5604b..043518f6f9 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -885,6 +885,19 @@ export default function Page() { window.history.replaceState(null, "", `#${anchor(id)}`) } + const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => { + const root = scroller + if (!root) { + el.scrollIntoView({ behavior, block: "start" }) + return + } + + const a = el.getBoundingClientRect() + const b = root.getBoundingClientRect() + const top = a.top - b.top + root.scrollTop + root.scrollTo({ top, behavior }) + } + const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => { setActiveMessage(message) @@ -896,7 +909,7 @@ export default function Page() { requestAnimationFrame(() => { const el = document.getElementById(anchor(message.id)) - if (el) el.scrollIntoView({ behavior, block: "start" }) + if (el) scrollToElement(el, behavior) }) updateHash(message.id) @@ -904,7 +917,7 @@ export default function Page() { } const el = document.getElementById(anchor(message.id)) - if (el) el.scrollIntoView({ behavior, block: "start" }) + if (el) scrollToElement(el, behavior) updateHash(message.id) } @@ -956,7 +969,7 @@ export default function Page() { const hashTarget = document.getElementById(hash) if (hashTarget) { - hashTarget.scrollIntoView({ behavior: "auto", block: "start" }) + scrollToElement(hashTarget, "auto") return } diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 9557f83104..a09fd104af 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,12 +1,12 @@ { "name": "@opencode-ai/console-app", - "version": "1.1.16", + "version": "1.1.21", "type": "module", "license": "MIT", "scripts": { "typecheck": "tsgo --noEmit", "dev": "vite dev --host 0.0.0.0", - "dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev", + "dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51RtuLNE7fOCwHSD4mewwzFejyytjdGoSDK7CAvhbffwaZnPbNb2rwJICw6LTOXCmWO320fSNXvb5NzI08RZVkAxd00syfqrW7t bun sst shell --stage=dev bun dev", "build": "./script/generate-sitemap.ts && vite build && ../../opencode/script/schema.ts ./.output/public/config.json", "start": "vite start" }, @@ -23,15 +23,18 @@ "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", "@solidjs/start": "catalog:", + "@stripe/stripe-js": "8.6.1", "chart.js": "4.5.1", "nitro": "3.0.1-alpha.1", "solid-js": "catalog:", "solid-list": "0.3.0", + "solid-stripe": "0.8.1", "vite": "catalog:", "zod": "catalog:" }, "devDependencies": { "@typescript/native-preview": "catalog:", + "@webgpu/types": "0.1.54", "typescript": "catalog:", "wrangler": "4.50.0" }, diff --git a/packages/console/app/public/social-share-black.png b/packages/console/app/public/social-share-black.png new file mode 120000 index 0000000000..5baa00483b --- /dev/null +++ b/packages/console/app/public/social-share-black.png @@ -0,0 +1 @@ +../../../ui/src/assets/images/social-share-black.png \ No newline at end of file diff --git a/packages/console/app/src/component/footer.tsx b/packages/console/app/src/component/footer.tsx index 5eac75967a..27f8ddd65f 100644 --- a/packages/console/app/src/component/footer.tsx +++ b/packages/console/app/src/component/footer.tsx @@ -24,6 +24,9 @@ export function Footer() { +
+ Changelog +
diff --git a/packages/console/app/src/component/light-rays.css b/packages/console/app/src/component/light-rays.css new file mode 100644 index 0000000000..b688e6d9e3 --- /dev/null +++ b/packages/console/app/src/component/light-rays.css @@ -0,0 +1,186 @@ +.light-rays-container { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + overflow: hidden; +} + +.light-rays-container canvas { + display: block; + width: 100%; + height: 100%; +} + +.light-rays-controls { + position: fixed; + top: 16px; + right: 16px; + z-index: 9999; + font-family: var(--font-mono, monospace); + font-size: 12px; + color: #fff; +} + +.light-rays-controls-toggle { + background: rgba(0, 0, 0, 0.8); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + padding: 8px 12px; + color: #fff; + cursor: pointer; + font-family: inherit; + font-size: inherit; + width: 100%; + text-align: left; +} + +.light-rays-controls-toggle:hover { + background: rgba(0, 0, 0, 0.9); + border-color: rgba(255, 255, 255, 0.3); +} + +.light-rays-controls-panel { + background: rgba(0, 0, 0, 0.85); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + padding: 12px; + margin-top: 4px; + display: flex; + flex-direction: column; + gap: 10px; + min-width: 240px; + max-height: calc(100vh - 100px); + overflow-y: auto; + backdrop-filter: blur(8px); +} + +.control-group { + display: flex; + flex-direction: column; + gap: 4px; +} + +.control-group label { + color: rgba(255, 255, 255, 0.7); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.control-group.checkbox { + flex-direction: row; + align-items: center; +} + +.control-group.checkbox label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + text-transform: none; +} + +.control-group input[type="range"] { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 4px; + background: rgba(255, 255, 255, 0.2); + border-radius: 2px; + outline: none; +} + +.control-group input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + background: #fff; + border-radius: 50%; + cursor: pointer; + transition: transform 0.1s; +} + +.control-group input[type="range"]::-webkit-slider-thumb:hover { + transform: scale(1.1); +} + +.control-group input[type="range"]::-moz-range-thumb { + width: 14px; + height: 14px; + background: #fff; + border-radius: 50%; + cursor: pointer; + border: none; +} + +.control-group input[type="color"] { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 32px; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + background: transparent; + cursor: pointer; + padding: 2px; +} + +.control-group input[type="color"]::-webkit-color-swatch-wrapper { + padding: 0; +} + +.control-group input[type="color"]::-webkit-color-swatch { + border: none; + border-radius: 2px; +} + +.control-group select { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + padding: 6px 8px; + color: #fff; + font-family: inherit; + font-size: inherit; + cursor: pointer; + outline: none; +} + +.control-group select:hover { + border-color: rgba(255, 255, 255, 0.3); +} + +.control-group select option { + background: #1a1a1a; + color: #fff; +} + +.control-group input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: #fff; + cursor: pointer; +} + +.reset-button { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + padding: 8px 12px; + color: rgba(255, 255, 255, 0.7); + cursor: pointer; + font-family: inherit; + font-size: inherit; + margin-top: 4px; + transition: all 0.15s; +} + +.reset-button:hover { + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.3); + color: #fff; +} diff --git a/packages/console/app/src/component/light-rays.tsx b/packages/console/app/src/component/light-rays.tsx new file mode 100644 index 0000000000..36b47a4777 --- /dev/null +++ b/packages/console/app/src/component/light-rays.tsx @@ -0,0 +1,924 @@ +import { createSignal, createEffect, onMount, onCleanup, Show, For, Accessor, Setter } from "solid-js" +import "./light-rays.css" + +export type RaysOrigin = + | "top-center" + | "top-left" + | "top-right" + | "right" + | "left" + | "bottom-center" + | "bottom-right" + | "bottom-left" + +export interface LightRaysConfig { + raysOrigin: RaysOrigin + raysColor: string + raysSpeed: number + lightSpread: number + rayLength: number + sourceWidth: number + pulsating: boolean + pulsatingMin: number + pulsatingMax: number + fadeDistance: number + saturation: number + followMouse: boolean + mouseInfluence: number + noiseAmount: number + distortion: number + opacity: number +} + +export const defaultConfig: LightRaysConfig = { + raysOrigin: "top-center", + raysColor: "#ffffff", + raysSpeed: 1.0, + lightSpread: 1.15, + rayLength: 4.0, + sourceWidth: 0.1, + pulsating: true, + pulsatingMin: 0.9, + pulsatingMax: 1.0, + fadeDistance: 1.15, + saturation: 0.325, + followMouse: false, + mouseInfluence: 0.05, + noiseAmount: 0.5, + distortion: 0.0, + opacity: 0.35, +} + +export interface LightRaysAnimationState { + time: number + intensity: number + pulseValue: number +} + +interface LightRaysProps { + config: Accessor + class?: string + onAnimationFrame?: (state: LightRaysAnimationState) => void +} + +const hexToRgb = (hex: string): [number, number, number] => { + const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) + return m ? [parseInt(m[1], 16) / 255, parseInt(m[2], 16) / 255, parseInt(m[3], 16) / 255] : [1, 1, 1] +} + +const getAnchorAndDir = ( + origin: RaysOrigin, + w: number, + h: number, +): { anchor: [number, number]; dir: [number, number] } => { + const outside = 0.2 + switch (origin) { + case "top-left": + return { anchor: [0, -outside * h], dir: [0, 1] } + case "top-right": + return { anchor: [w, -outside * h], dir: [0, 1] } + case "left": + return { anchor: [-outside * w, 0.5 * h], dir: [1, 0] } + case "right": + return { anchor: [(1 + outside) * w, 0.5 * h], dir: [-1, 0] } + case "bottom-left": + return { anchor: [0, (1 + outside) * h], dir: [0, -1] } + case "bottom-center": + return { anchor: [0.5 * w, (1 + outside) * h], dir: [0, -1] } + case "bottom-right": + return { anchor: [w, (1 + outside) * h], dir: [0, -1] } + default: // "top-center" + return { anchor: [0.5 * w, -outside * h], dir: [0, 1] } + } +} + +interface UniformData { + iTime: number + iResolution: [number, number] + rayPos: [number, number] + rayDir: [number, number] + raysColor: [number, number, number] + raysSpeed: number + lightSpread: number + rayLength: number + sourceWidth: number + pulsating: number + pulsatingMin: number + pulsatingMax: number + fadeDistance: number + saturation: number + mousePos: [number, number] + mouseInfluence: number + noiseAmount: number + distortion: number +} + +const WGSL_SHADER = ` + struct Uniforms { + iTime: f32, + _pad0: f32, + iResolution: vec2, + rayPos: vec2, + rayDir: vec2, + raysColor: vec3, + raysSpeed: f32, + lightSpread: f32, + rayLength: f32, + sourceWidth: f32, + pulsating: f32, + pulsatingMin: f32, + pulsatingMax: f32, + fadeDistance: f32, + saturation: f32, + mousePos: vec2, + mouseInfluence: f32, + noiseAmount: f32, + distortion: f32, + _pad1: f32, + _pad2: f32, + _pad3: f32, + }; + + @group(0) @binding(0) var uniforms: Uniforms; + + struct VertexOutput { + @builtin(position) position: vec4, + @location(0) vUv: vec2, + }; + + @vertex + fn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput { + var positions = array, 3>( + vec2(-1.0, -1.0), + vec2(3.0, -1.0), + vec2(-1.0, 3.0) + ); + + var output: VertexOutput; + let pos = positions[vertexIndex]; + output.position = vec4(pos, 0.0, 1.0); + output.vUv = pos * 0.5 + 0.5; + return output; + } + + fn noise(st: vec2) -> f32 { + return fract(sin(dot(st, vec2(12.9898, 78.233))) * 43758.5453123); + } + + fn rayStrength(raySource: vec2, rayRefDirection: vec2, coord: vec2, + seedA: f32, seedB: f32, speed: f32) -> f32 { + let sourceToCoord = coord - raySource; + let dirNorm = normalize(sourceToCoord); + let cosAngle = dot(dirNorm, rayRefDirection); + + let distortedAngle = cosAngle + uniforms.distortion * sin(uniforms.iTime * 2.0 + length(sourceToCoord) * 0.01) * 0.2; + + let spreadFactor = pow(max(distortedAngle, 0.0), 1.0 / max(uniforms.lightSpread, 0.001)); + + let distance = length(sourceToCoord); + let maxDistance = uniforms.iResolution.x * uniforms.rayLength; + let lengthFalloff = clamp((maxDistance - distance) / maxDistance, 0.0, 1.0); + + let fadeFalloff = clamp((uniforms.iResolution.x * uniforms.fadeDistance - distance) / (uniforms.iResolution.x * uniforms.fadeDistance), 0.5, 1.0); + let pulseCenter = (uniforms.pulsatingMin + uniforms.pulsatingMax) * 0.5; + let pulseAmplitude = (uniforms.pulsatingMax - uniforms.pulsatingMin) * 0.5; + var pulse: f32; + if (uniforms.pulsating > 0.5) { + pulse = pulseCenter + pulseAmplitude * sin(uniforms.iTime * speed * 3.0); + } else { + pulse = 1.0; + } + + let baseStrength = clamp( + (0.45 + 0.15 * sin(distortedAngle * seedA + uniforms.iTime * speed)) + + (0.3 + 0.2 * cos(-distortedAngle * seedB + uniforms.iTime * speed)), + 0.0, 1.0 + ); + + return baseStrength * lengthFalloff * fadeFalloff * spreadFactor * pulse; + } + + @fragment + fn fragmentMain(@builtin(position) fragCoord: vec4, @location(0) vUv: vec2) -> @location(0) vec4 { + let coord = vec2(fragCoord.x, fragCoord.y); + + let normalizedX = (coord.x / uniforms.iResolution.x) - 0.5; + let widthOffset = -normalizedX * uniforms.sourceWidth * uniforms.iResolution.x; + + let perpDir = vec2(-uniforms.rayDir.y, uniforms.rayDir.x); + let adjustedRayPos = uniforms.rayPos + perpDir * widthOffset; + + var finalRayDir = uniforms.rayDir; + if (uniforms.mouseInfluence > 0.0) { + let mouseScreenPos = uniforms.mousePos * uniforms.iResolution; + let mouseDirection = normalize(mouseScreenPos - adjustedRayPos); + finalRayDir = normalize(mix(uniforms.rayDir, mouseDirection, uniforms.mouseInfluence)); + } + + let rays1 = vec4(1.0) * + rayStrength(adjustedRayPos, finalRayDir, coord, 36.2214, 21.11349, + 1.5 * uniforms.raysSpeed); + let rays2 = vec4(1.0) * + rayStrength(adjustedRayPos, finalRayDir, coord, 22.3991, 18.0234, + 1.1 * uniforms.raysSpeed); + + var fragColor = rays1 * 0.5 + rays2 * 0.4; + + if (uniforms.noiseAmount > 0.0) { + let n = noise(coord * 0.01 + uniforms.iTime * 0.1); + fragColor = vec4(fragColor.rgb * (1.0 - uniforms.noiseAmount + uniforms.noiseAmount * n), fragColor.a); + } + + let brightness = 1.0 - (coord.y / uniforms.iResolution.y); + fragColor.x = fragColor.x * (0.1 + brightness * 0.8); + fragColor.y = fragColor.y * (0.3 + brightness * 0.6); + fragColor.z = fragColor.z * (0.5 + brightness * 0.5); + + if (uniforms.saturation != 1.0) { + let gray = dot(fragColor.rgb, vec3(0.299, 0.587, 0.114)); + fragColor = vec4(mix(vec3(gray), fragColor.rgb, uniforms.saturation), fragColor.a); + } + + fragColor = vec4(fragColor.rgb * uniforms.raysColor, fragColor.a); + + return fragColor; + } +` + +const UNIFORM_BUFFER_SIZE = 96 + +function createUniformBuffer(data: UniformData): Float32Array { + const buffer = new Float32Array(24) + buffer[0] = data.iTime + buffer[1] = 0 + buffer[2] = data.iResolution[0] + buffer[3] = data.iResolution[1] + buffer[4] = data.rayPos[0] + buffer[5] = data.rayPos[1] + buffer[6] = data.rayDir[0] + buffer[7] = data.rayDir[1] + buffer[8] = data.raysColor[0] + buffer[9] = data.raysColor[1] + buffer[10] = data.raysColor[2] + buffer[11] = data.raysSpeed + buffer[12] = data.lightSpread + buffer[13] = data.rayLength + buffer[14] = data.sourceWidth + buffer[15] = data.pulsating + buffer[16] = data.pulsatingMin + buffer[17] = data.pulsatingMax + buffer[18] = data.fadeDistance + buffer[19] = data.saturation + buffer[20] = data.mousePos[0] + buffer[21] = data.mousePos[1] + buffer[22] = data.mouseInfluence + buffer[23] = data.noiseAmount + return buffer +} + +const UNIFORM_BUFFER_SIZE_CORRECTED = 112 + +function createUniformBufferCorrected(data: UniformData): Float32Array { + const buffer = new Float32Array(28) + buffer[0] = data.iTime + buffer[1] = 0 + buffer[2] = data.iResolution[0] + buffer[3] = data.iResolution[1] + buffer[4] = data.rayPos[0] + buffer[5] = data.rayPos[1] + buffer[6] = data.rayDir[0] + buffer[7] = data.rayDir[1] + buffer[8] = data.raysColor[0] + buffer[9] = data.raysColor[1] + buffer[10] = data.raysColor[2] + buffer[11] = data.raysSpeed + buffer[12] = data.lightSpread + buffer[13] = data.rayLength + buffer[14] = data.sourceWidth + buffer[15] = data.pulsating + buffer[16] = data.pulsatingMin + buffer[17] = data.pulsatingMax + buffer[18] = data.fadeDistance + buffer[19] = data.saturation + buffer[20] = data.mousePos[0] + buffer[21] = data.mousePos[1] + buffer[22] = data.mouseInfluence + buffer[23] = data.noiseAmount + buffer[24] = data.distortion + buffer[25] = 0 + buffer[26] = 0 + buffer[27] = 0 + return buffer +} + +export default function LightRays(props: LightRaysProps) { + let containerRef: HTMLDivElement | undefined + let canvasRef: HTMLCanvasElement | null = null + let deviceRef: GPUDevice | null = null + let contextRef: GPUCanvasContext | null = null + let pipelineRef: GPURenderPipeline | null = null + let uniformBufferRef: GPUBuffer | null = null + let bindGroupRef: GPUBindGroup | null = null + let animationIdRef: number | null = null + let cleanupFunctionRef: (() => void) | null = null + let uniformDataRef: UniformData | null = null + + const mouseRef = { x: 0.5, y: 0.5 } + const smoothMouseRef = { x: 0.5, y: 0.5 } + + const [isVisible, setIsVisible] = createSignal(false) + + onMount(() => { + if (!containerRef) return + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0] + setIsVisible(entry.isIntersecting) + }, + { threshold: 0.1 }, + ) + + observer.observe(containerRef) + + onCleanup(() => { + observer.disconnect() + }) + }) + + createEffect(() => { + const visible = isVisible() + const config = props.config() + if (!visible || !containerRef) { + return + } + + if (cleanupFunctionRef) { + cleanupFunctionRef() + cleanupFunctionRef = null + } + + const initializeWebGPU = async () => { + if (!containerRef) { + return + } + + await new Promise((resolve) => setTimeout(resolve, 10)) + + if (!containerRef) { + return + } + + if (!navigator.gpu) { + console.warn("WebGPU is not supported in this browser") + return + } + + const adapter = await navigator.gpu.requestAdapter() + if (!adapter) { + console.warn("Failed to get WebGPU adapter") + return + } + + const device = await adapter.requestDevice() + deviceRef = device + + const canvas = document.createElement("canvas") + canvas.style.width = "100%" + canvas.style.height = "100%" + canvasRef = canvas + + while (containerRef.firstChild) { + containerRef.removeChild(containerRef.firstChild) + } + containerRef.appendChild(canvas) + + const context = canvas.getContext("webgpu") + if (!context) { + console.warn("Failed to get WebGPU context") + return + } + contextRef = context + + const presentationFormat = navigator.gpu.getPreferredCanvasFormat() + context.configure({ + device, + format: presentationFormat, + alphaMode: "premultiplied", + }) + + const shaderModule = device.createShaderModule({ + code: WGSL_SHADER, + }) + + const uniformBuffer = device.createBuffer({ + size: UNIFORM_BUFFER_SIZE_CORRECTED, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }) + uniformBufferRef = uniformBuffer + + const bindGroupLayout = device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, + buffer: { type: "uniform" }, + }, + ], + }) + + const bindGroup = device.createBindGroup({ + layout: bindGroupLayout, + entries: [ + { + binding: 0, + resource: { buffer: uniformBuffer }, + }, + ], + }) + bindGroupRef = bindGroup + + const pipelineLayout = device.createPipelineLayout({ + bindGroupLayouts: [bindGroupLayout], + }) + + const pipeline = device.createRenderPipeline({ + layout: pipelineLayout, + vertex: { + module: shaderModule, + entryPoint: "vertexMain", + }, + fragment: { + module: shaderModule, + entryPoint: "fragmentMain", + targets: [ + { + format: presentationFormat, + blend: { + color: { + srcFactor: "src-alpha", + dstFactor: "one-minus-src-alpha", + operation: "add", + }, + alpha: { + srcFactor: "one", + dstFactor: "one-minus-src-alpha", + operation: "add", + }, + }, + }, + ], + }, + primitive: { + topology: "triangle-list", + }, + }) + pipelineRef = pipeline + + const { clientWidth: wCSS, clientHeight: hCSS } = containerRef + const dpr = Math.min(window.devicePixelRatio, 2) + const w = wCSS * dpr + const h = hCSS * dpr + const { anchor, dir } = getAnchorAndDir(config.raysOrigin, w, h) + + uniformDataRef = { + iTime: 0, + iResolution: [w, h], + rayPos: anchor, + rayDir: dir, + raysColor: hexToRgb(config.raysColor), + raysSpeed: config.raysSpeed, + lightSpread: config.lightSpread, + rayLength: config.rayLength, + sourceWidth: config.sourceWidth, + pulsating: config.pulsating ? 1.0 : 0.0, + pulsatingMin: config.pulsatingMin, + pulsatingMax: config.pulsatingMax, + fadeDistance: config.fadeDistance, + saturation: config.saturation, + mousePos: [0.5, 0.5], + mouseInfluence: config.mouseInfluence, + noiseAmount: config.noiseAmount, + distortion: config.distortion, + } + + const updatePlacement = () => { + if (!containerRef || !canvasRef || !uniformDataRef) { + return + } + + const dpr = Math.min(window.devicePixelRatio, 2) + const { clientWidth: wCSS, clientHeight: hCSS } = containerRef + const w = Math.floor(wCSS * dpr) + const h = Math.floor(hCSS * dpr) + + canvasRef.width = w + canvasRef.height = h + + uniformDataRef.iResolution = [w, h] + + const currentConfig = props.config() + const { anchor, dir } = getAnchorAndDir(currentConfig.raysOrigin, w, h) + uniformDataRef.rayPos = anchor + uniformDataRef.rayDir = dir + } + + const loop = (t: number) => { + if (!deviceRef || !contextRef || !pipelineRef || !uniformBufferRef || !bindGroupRef || !uniformDataRef) { + return + } + + const currentConfig = props.config() + const timeSeconds = t * 0.001 + uniformDataRef.iTime = timeSeconds + + if (currentConfig.followMouse && currentConfig.mouseInfluence > 0.0) { + const smoothing = 0.92 + + smoothMouseRef.x = smoothMouseRef.x * smoothing + mouseRef.x * (1 - smoothing) + smoothMouseRef.y = smoothMouseRef.y * smoothing + mouseRef.y * (1 - smoothing) + + uniformDataRef.mousePos = [smoothMouseRef.x, smoothMouseRef.y] + } + + if (props.onAnimationFrame) { + const pulseCenter = (currentConfig.pulsatingMin + currentConfig.pulsatingMax) * 0.5 + const pulseAmplitude = (currentConfig.pulsatingMax - currentConfig.pulsatingMin) * 0.5 + const pulseValue = currentConfig.pulsating + ? pulseCenter + pulseAmplitude * Math.sin(timeSeconds * currentConfig.raysSpeed * 3.0) + : 1.0 + + const baseIntensity1 = 0.45 + 0.15 * Math.sin(timeSeconds * currentConfig.raysSpeed * 1.5) + const baseIntensity2 = 0.3 + 0.2 * Math.cos(timeSeconds * currentConfig.raysSpeed * 1.1) + const intensity = (baseIntensity1 + baseIntensity2) * pulseValue + + props.onAnimationFrame({ + time: timeSeconds, + intensity, + pulseValue, + }) + } + + try { + const uniformData = createUniformBufferCorrected(uniformDataRef) + deviceRef.queue.writeBuffer(uniformBufferRef, 0, uniformData.buffer) + + const commandEncoder = deviceRef.createCommandEncoder() + + const textureView = contextRef.getCurrentTexture().createView() + + const renderPass = commandEncoder.beginRenderPass({ + colorAttachments: [ + { + view: textureView, + clearValue: { r: 0, g: 0, b: 0, a: 0 }, + loadOp: "clear", + storeOp: "store", + }, + ], + }) + + renderPass.setPipeline(pipelineRef) + renderPass.setBindGroup(0, bindGroupRef) + renderPass.draw(3) + renderPass.end() + + deviceRef.queue.submit([commandEncoder.finish()]) + + animationIdRef = requestAnimationFrame(loop) + } catch (error) { + console.warn("WebGPU rendering error:", error) + return + } + } + + window.addEventListener("resize", updatePlacement) + updatePlacement() + animationIdRef = requestAnimationFrame(loop) + + cleanupFunctionRef = () => { + if (animationIdRef) { + cancelAnimationFrame(animationIdRef) + animationIdRef = null + } + + window.removeEventListener("resize", updatePlacement) + + if (uniformBufferRef) { + uniformBufferRef.destroy() + uniformBufferRef = null + } + + if (deviceRef) { + deviceRef.destroy() + deviceRef = null + } + + if (canvasRef && canvasRef.parentNode) { + canvasRef.parentNode.removeChild(canvasRef) + } + + canvasRef = null + contextRef = null + pipelineRef = null + bindGroupRef = null + uniformDataRef = null + } + } + + initializeWebGPU() + + onCleanup(() => { + if (cleanupFunctionRef) { + cleanupFunctionRef() + cleanupFunctionRef = null + } + }) + }) + + createEffect(() => { + if (!uniformDataRef || !containerRef) { + return + } + + const config = props.config() + + uniformDataRef.raysColor = hexToRgb(config.raysColor) + uniformDataRef.raysSpeed = config.raysSpeed + uniformDataRef.lightSpread = config.lightSpread + uniformDataRef.rayLength = config.rayLength + uniformDataRef.sourceWidth = config.sourceWidth + uniformDataRef.pulsating = config.pulsating ? 1.0 : 0.0 + uniformDataRef.pulsatingMin = config.pulsatingMin + uniformDataRef.pulsatingMax = config.pulsatingMax + uniformDataRef.fadeDistance = config.fadeDistance + uniformDataRef.saturation = config.saturation + uniformDataRef.mouseInfluence = config.mouseInfluence + uniformDataRef.noiseAmount = config.noiseAmount + uniformDataRef.distortion = config.distortion + + const dpr = Math.min(window.devicePixelRatio, 2) + const { clientWidth: wCSS, clientHeight: hCSS } = containerRef + const { anchor, dir } = getAnchorAndDir(config.raysOrigin, wCSS * dpr, hCSS * dpr) + uniformDataRef.rayPos = anchor + uniformDataRef.rayDir = dir + }) + + createEffect(() => { + const config = props.config() + if (!config.followMouse) { + return + } + + const handleMouseMove = (e: MouseEvent) => { + if (!containerRef) { + return + } + const rect = containerRef.getBoundingClientRect() + const x = (e.clientX - rect.left) / rect.width + const y = (e.clientY - rect.top) / rect.height + mouseRef.x = x + mouseRef.y = y + } + + window.addEventListener("mousemove", handleMouseMove) + + onCleanup(() => { + window.removeEventListener("mousemove", handleMouseMove) + }) + }) + + return ( +
+ ) +} + +interface LightRaysControlsProps { + config: Accessor + setConfig: Setter +} + +export function LightRaysControls(props: LightRaysControlsProps) { + const [isOpen, setIsOpen] = createSignal(true) + + const updateConfig = (key: K, value: LightRaysConfig[K]) => { + props.setConfig((prev) => ({ ...prev, [key]: value })) + } + + const origins: RaysOrigin[] = [ + "top-center", + "top-left", + "top-right", + "left", + "right", + "bottom-center", + "bottom-left", + "bottom-right", + ] + + return ( +
+ + +
+
+ + +
+ +
+ + updateConfig("raysColor", e.currentTarget.value)} + /> +
+ +
+ + updateConfig("raysSpeed", parseFloat(e.currentTarget.value))} + /> +
+ +
+ + updateConfig("lightSpread", parseFloat(e.currentTarget.value))} + /> +
+ +
+ + updateConfig("rayLength", parseFloat(e.currentTarget.value))} + /> +
+ +
+ + updateConfig("sourceWidth", parseFloat(e.currentTarget.value))} + /> +
+ +
+ + updateConfig("fadeDistance", parseFloat(e.currentTarget.value))} + /> +
+ +
+ + updateConfig("saturation", parseFloat(e.currentTarget.value))} + /> +
+ +
+ + updateConfig("mouseInfluence", parseFloat(e.currentTarget.value))} + /> +
+ +
+ + updateConfig("noiseAmount", parseFloat(e.currentTarget.value))} + /> +
+ +
+ + updateConfig("distortion", parseFloat(e.currentTarget.value))} + /> +
+ +
+ + updateConfig("opacity", parseFloat(e.currentTarget.value))} + /> +
+ +
+ +
+ + +
+ + updateConfig("pulsatingMin", parseFloat(e.currentTarget.value))} + /> +
+ +
+ + updateConfig("pulsatingMax", parseFloat(e.currentTarget.value))} + /> +
+
+ +
+ +
+ + +
+
+
+ ) +} diff --git a/packages/console/app/src/lib/github.ts b/packages/console/app/src/lib/github.ts index cc266f58c4..ccde5972d3 100644 --- a/packages/console/app/src/lib/github.ts +++ b/packages/console/app/src/lib/github.ts @@ -14,13 +14,14 @@ export const github = query(async () => { fetch(`${apiBaseUrl}/releases`, { headers }).then((res) => res.json()), fetch(`${apiBaseUrl}/contributors?per_page=1`, { headers }), ]) + if (!Array.isArray(releases) || releases.length === 0) { + return undefined + } const [release] = releases - const contributorCount = Number.parseInt( - contributors.headers - .get("Link")! - .match(/&page=(\d+)>; rel="last"/)! - .at(1)!, - ) + const linkHeader = contributors.headers.get("Link") + const contributorCount = linkHeader + ? Number.parseInt(linkHeader.match(/&page=(\d+)>; rel="last"/)?.at(1) ?? "0") + : 0 return { stars: meta.stargazers_count, release: { diff --git a/packages/console/app/src/routes/auth/callback.ts b/packages/console/app/src/routes/auth/[...callback].ts similarity index 91% rename from packages/console/app/src/routes/auth/callback.ts rename to packages/console/app/src/routes/auth/[...callback].ts index 9b7296791d..36a9c5194d 100644 --- a/packages/console/app/src/routes/auth/callback.ts +++ b/packages/console/app/src/routes/auth/[...callback].ts @@ -5,6 +5,7 @@ import { useAuthSession } from "~/context/auth" export async function GET(input: APIEvent) { const url = new URL(input.request.url) + try { const code = url.searchParams.get("code") if (!code) throw new Error("No code found") @@ -27,7 +28,7 @@ export async function GET(input: APIEvent) { current: id, } }) - return redirect("/auth") + return redirect(url.pathname === "/auth/callback" ? "/auth" : url.pathname.replace("/auth/callback", "")) } catch (e: any) { return new Response( JSON.stringify({ diff --git a/packages/console/app/src/routes/auth/authorize.ts b/packages/console/app/src/routes/auth/authorize.ts index 166466ef85..0f0651ae36 100644 --- a/packages/console/app/src/routes/auth/authorize.ts +++ b/packages/console/app/src/routes/auth/authorize.ts @@ -2,6 +2,9 @@ import type { APIEvent } from "@solidjs/start/server" import { AuthClient } from "~/context/auth" export async function GET(input: APIEvent) { - const result = await AuthClient.authorize(new URL("./callback", input.request.url).toString(), "code") + const url = new URL(input.request.url) + const cont = url.searchParams.get("continue") ?? "" + const callbackUrl = new URL(`./callback${cont}`, input.request.url) + const result = await AuthClient.authorize(callbackUrl.toString(), "code") return Response.redirect(result.url, 302) } diff --git a/packages/console/app/src/routes/black.css b/packages/console/app/src/routes/black.css new file mode 100644 index 0000000000..a0cd5712b3 --- /dev/null +++ b/packages/console/app/src/routes/black.css @@ -0,0 +1,849 @@ +::view-transition-group(*) { + animation-duration: 250ms; + animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); +} + +::view-transition-old(root), +::view-transition-new(root) { + animation-duration: 250ms; + animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); +} + +::view-transition-image-pair(root) { + isolation: isolate; +} + +::view-transition-old(root) { + animation: none; + mix-blend-mode: normal; +} + +::view-transition-new(root) { + animation: none; + mix-blend-mode: normal; +} + +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes fade-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +@keyframes fade-in-up { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes reveal-terms { + from { + mask-position: 0% 200%; + } + to { + mask-position: 0% 50%; + } +} + +@keyframes hide-terms { + from { + mask-position: 0% 50%; + } + to { + mask-position: 0% 200%; + } +} + +::view-transition-old(terms-20), +::view-transition-old(terms-100), +::view-transition-old(terms-200) { + mask-image: linear-gradient(to bottom, transparent, black 25% 75%, transparent); + mask-repeat: no-repeat; + mask-size: 100% 200%; + animation: hide-terms 200ms cubic-bezier(0.25, 0, 0.5, 1) forwards; +} + +::view-transition-new(terms-20), +::view-transition-new(terms-100), +::view-transition-new(terms-200) { + mask-image: linear-gradient(to bottom, transparent, black 25% 75%, transparent); + mask-repeat: no-repeat; + mask-position: 0% 200%; + mask-size: 100% 200%; + animation: reveal-terms 300ms cubic-bezier(0.25, 0, 0.5, 1) 50ms forwards; +} + +::view-transition-old(action-20), +::view-transition-old(action-100), +::view-transition-old(action-200) { + animation: fade-out 100ms cubic-bezier(0.4, 0, 0.2, 1) forwards; +} + +::view-transition-new(action-20), +::view-transition-new(action-100), +::view-transition-new(action-200) { + animation: fade-in-up 200ms cubic-bezier(0.16, 1, 0.3, 1) 250ms forwards; + opacity: 0; +} + +::view-transition-group(plan-card-20), +::view-transition-group(plan-card-100), +::view-transition-group(plan-card-200) { + animation-duration: 200ms; + animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); +} + +::view-transition-image-pair(plan-card-20), +::view-transition-image-pair(plan-card-100), +::view-transition-image-pair(plan-card-200) { + isolation: isolate; +} + +::view-transition-old(plan-card-20), +::view-transition-old(plan-card-100), +::view-transition-old(plan-card-200) { + animation: fade-out 120ms cubic-bezier(0.4, 0, 0.2, 1) forwards; + mix-blend-mode: normal; +} + +::view-transition-new(plan-card-20), +::view-transition-new(plan-card-100), +::view-transition-new(plan-card-200) { + animation: fade-in 150ms cubic-bezier(0.4, 0, 0.2, 1) 50ms forwards; + opacity: 0; + mix-blend-mode: normal; +} + +[data-page="black"] { + background: #000; + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: stretch; + font-family: var(--font-mono); + color: #fff; + + [data-component="header-logo"] { + filter: drop-shadow(0 8px 24px rgba(0, 0, 0, 0.25)) drop-shadow(0 4px 16px rgba(0, 0, 0, 0.1)); + position: relative; + z-index: 1; + } + + .header-light-rays { + position: absolute; + inset: 0 0 auto 0; + height: 30dvh; + pointer-events: none; + z-index: 0; + } + + [data-component="header"] { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding-top: 40px; + flex-shrink: 0; + } + + [data-component="content"] { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + flex-grow: 1; + + [data-slot="hero"] { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 8px; + margin-top: 40px; + padding: 0 20px; + + @media (min-width: 768px) { + margin-top: 60px; + } + + h1 { + color: rgba(255, 255, 255, 0.92); + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 1.45; + margin: 0; + + @media (min-width: 768px) { + font-size: 20px; + } + + @media (max-width: 480px) { + font-size: 14px; + } + } + + p { + color: rgba(255, 255, 255, 0.59); + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 1.45; + margin: 0; + + @media (min-width: 768px) { + font-size: 20px; + } + + @media (max-width: 480px) { + font-size: 14px; + } + } + } + + [data-slot="hero-black"] { + margin-top: 40px; + padding: 0 20px; + position: relative; + + @media (min-width: 768px) { + margin-top: 60px; + } + + svg { + width: 100%; + max-width: 590px; + height: auto; + overflow: visible; + filter: drop-shadow(0 0 20px rgba(255, 255, 255, calc(0.1 + var(--hero-black-glow-intensity, 0) * 0.15))) + drop-shadow(0 -5px 30px rgba(255, 255, 255, calc(var(--hero-black-glow-intensity, 0) * 0.2))); + mask-image: linear-gradient(to bottom, black, transparent); + stroke-width: 1.5; + + [data-slot="black-base"] { + fill: url(#hero-black-fill-gradient); + stroke: url(#hero-black-stroke-gradient); + } + + [data-slot="black-glow"] { + fill: url(#hero-black-top-glow); + pointer-events: none; + } + + [data-slot="black-shimmer"] { + fill: url(#hero-black-shimmer-gradient); + pointer-events: none; + mix-blend-mode: overlay; + } + } + } + + [data-slot="cta"] { + display: flex; + flex-direction: column; + gap: 16px; + align-items: center; + text-align: center; + margin-top: -40px; + width: 100%; + + @media (min-width: 768px) { + margin-top: -20px; + } + + [data-slot="heading"] { + color: rgba(255, 255, 255, 0.92); + text-align: center; + font-size: 18px; + font-style: normal; + font-weight: 400; + line-height: 160%; + + span { + display: inline-block; + } + } + [data-slot="subheading"] { + color: rgba(255, 255, 255, 0.59); + font-size: 15px; + font-style: normal; + font-weight: 400; + line-height: 160%; + + @media (min-width: 768px) { + font-size: 18px; + line-height: 160%; + } + } + [data-slot="button"] { + display: inline-flex; + height: 40px; + padding: 0 12px; + justify-content: center; + align-items: center; + gap: 8px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.92); + text-decoration: none; + color: #000; + font-family: "JetBrains Mono Nerd Font"; + font-size: 16px; + font-style: normal; + font-weight: 500; + line-height: normal; + + &:hover { + background: #e0e0e0; + } + + &:active { + transform: scale(0.98); + } + } + [data-slot="back-soon"] { + color: rgba(255, 255, 255, 0.59); + text-align: center; + font-size: 13px; + font-style: normal; + font-weight: 400; + line-height: 160%; /* 20.8px */ + } + [data-slot="follow-us"] { + display: inline-flex; + height: 40px; + padding: 0 12px; + justify-content: center; + align-items: center; + gap: 8px; + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.17); + color: rgba(255, 255, 255, 0.59); + font-family: var(--font-mono); + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: normal; + text-decoration: none; + } + + [data-slot="pricing"] { + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; + max-width: 660px; + padding: 0 20px; + + @media (min-width: 768px) { + padding: 0; + } + } + + [data-slot="pricing-card"] { + display: flex; + flex-direction: column; + gap: 12px; + padding: 24px; + border: 1px solid rgba(255, 255, 255, 0.17); + background-color: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(4px); + background-clip: padding-box; + border-radius: 4px; + text-decoration: none; + transition: border-color 0.15s ease; + cursor: pointer; + text-align: left; + + @media (max-width: 480px) { + padding: 16px; + } + + &:hover:not(:active) { + border-color: rgba(255, 255, 255, 0.35); + } + + [data-slot="icon"] { + color: rgba(255, 255, 255, 0.59); + } + + [data-slot="price"] { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 8px; + } + + [data-slot="amount"] { + color: rgba(255, 255, 255, 0.92); + font-size: 24px; + font-weight: 500; + } + + [data-slot="period"] { + color: rgba(255, 255, 255, 0.59); + font-size: 14px; + } + + [data-slot="multiplier"] { + color: rgba(255, 255, 255, 0.39); + font-size: 14px; + + &::before { + content: "·"; + margin-right: 8px; + } + } + } + + [data-slot="selected-plan"] { + display: flex; + flex-direction: column; + gap: 32px; + width: 100%; + max-width: 660px; + margin: 0 auto; + position: relative; + background-color: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(4px); + z-index: 1; + + @media (max-width: 480px) { + margin: 0 20px; + width: calc(100% - 40px); + } + } + + [data-slot="selected-card"] { + display: flex; + flex-direction: column; + gap: 12px; + padding: 24px; + border: 1px solid rgba(255, 255, 255, 0.17); + border-radius: 4px; + width: 100%; + + [data-slot="icon"] { + color: rgba(255, 255, 255, 0.59); + } + + [data-slot="price"] { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 8px; + } + + [data-slot="amount"] { + color: rgba(255, 255, 255, 0.92); + font-size: 24px; + font-weight: 500; + } + + [data-slot="period"] { + color: rgba(255, 255, 255, 0.59); + font-size: 14px; + } + + [data-slot="multiplier"] { + color: rgba(255, 255, 255, 0.39); + font-size: 14px; + + &::before { + content: "·"; + margin-right: 8px; + } + } + + [data-slot="terms"] { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 8px; + text-align: left; + + li { + color: rgba(255, 255, 255, 0.59); + font-size: 14px; + line-height: 1.5; + padding-left: 16px; + position: relative; + + &::before { + content: "▪"; + position: absolute; + left: 0; + color: rgba(255, 255, 255, 0.39); + } + + @media (max-width: 768px) { + font-size: 12px; + } + } + } + + [data-slot="actions"] { + display: flex; + gap: 16px; + margin-top: 8px; + + button, + a { + flex: 1; + display: inline-flex; + height: 48px; + padding: 0 16px; + justify-content: center; + align-items: center; + border-radius: 4px; + font-family: var(--font-mono); + font-size: 16px; + font-weight: 400; + text-decoration: none; + cursor: pointer; + } + + [data-slot="cancel"] { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.17); + color: rgba(255, 255, 255, 0.92); + transition-property: background-color, border-color; + transition-duration: 150ms; + transition-timing-function: cubic-bezier(0.25, 0, 0.5, 1); + + &:hover { + background-color: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.25); + } + } + + [data-slot="continue"] { + background: rgb(255, 255, 255); + color: rgb(0, 0, 0); + transition: background-color 150ms cubic-bezier(0.25, 0, 0.5, 1); + + &:hover { + background: rgba(255, 255, 255, 0.9); + } + } + } + } + + [data-slot="fine-print"] { + color: rgba(255, 255, 255, 0.39); + text-align: center; + font-size: 13px; + font-style: normal; + font-weight: 400; + line-height: 160%; /* 20.8px */ + font-style: italic; + + a { + color: rgba(255, 255, 255, 0.39); + text-decoration: underline; + } + } + } + + /* Subscribe page styles */ + [data-slot="subscribe-form"] { + display: flex; + flex-direction: column; + gap: 32px; + align-items: center; + margin-top: -18px; + width: 100%; + max-width: 660px; + padding: 0 20px; + + @media (min-width: 768px) { + margin-top: 40px; + padding: 0; + } + + [data-slot="form-card"] { + width: 100%; + border: 1px solid rgba(255, 255, 255, 0.17); + border-radius: 4px; + padding: 24px; + display: flex; + flex-direction: column; + gap: 20px; + } + + [data-slot="plan-header"] { + display: flex; + flex-direction: column; + gap: 8px; + } + + [data-slot="title"] { + color: rgba(255, 255, 255, 0.92); + font-size: 16px; + font-weight: 400; + margin-bottom: 8px; + } + + [data-slot="icon"] { + color: rgba(255, 255, 255, 0.59); + } + + [data-slot="price"] { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 8px; + } + + [data-slot="amount"] { + color: rgba(255, 255, 255, 0.92); + font-size: 24px; + font-weight: 500; + } + + [data-slot="period"] { + color: rgba(255, 255, 255, 0.59); + font-size: 14px; + } + + [data-slot="multiplier"] { + color: rgba(255, 255, 255, 0.39); + font-size: 14px; + + &::before { + content: "·"; + margin: 0 8px; + } + } + + [data-slot="divider"] { + height: 1px; + background: rgba(255, 255, 255, 0.17); + } + + [data-slot="section-title"] { + color: rgba(255, 255, 255, 0.92); + font-size: 16px; + font-weight: 400; + } + + [data-slot="checkout-form"] { + display: flex; + flex-direction: column; + gap: 20px; + } + + [data-slot="error"] { + color: #ff6b6b; + font-size: 14px; + } + + [data-slot="submit-button"] { + width: 100%; + height: 48px; + background: rgba(255, 255, 255, 0.92); + border: none; + border-radius: 4px; + color: #000; + font-family: var(--font-mono); + font-size: 16px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s ease; + + &:hover:not(:disabled) { + background: #e0e0e0; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + + [data-slot="charge-notice"] { + color: #d4a500; + font-size: 14px; + text-align: center; + } + + [data-slot="loading"] { + display: flex; + justify-content: center; + padding: 40px 0; + + p { + color: rgba(255, 255, 255, 0.59); + font-size: 14px; + } + } + + [data-slot="fine-print"] { + color: rgba(255, 255, 255, 0.39); + text-align: center; + font-size: 13px; + font-style: italic; + view-transition-name: fine-print; + + a { + color: rgba(255, 255, 255, 0.39); + text-decoration: underline; + } + } + + [data-slot="workspace-picker"] { + [data-slot="workspace-list"] { + width: 100%; + padding: 0; + margin: 0; + list-style: none; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + align-self: stretch; + outline: none; + overflow-y: auto; + max-height: 240px; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + + [data-slot="workspace-item"] { + width: 100%; + display: flex; + padding: 8px 12px; + align-items: center; + gap: 8px; + align-self: stretch; + cursor: pointer; + + [data-slot="selected-icon"] { + visibility: hidden; + color: rgba(255, 255, 255, 0.39); + font-family: "IBM Plex Mono", monospace; + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 160%; + } + + span:last-child { + color: rgba(255, 255, 255, 0.92); + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 160%; + } + + &:hover, + &[data-active="true"] { + background: #161616; + + [data-slot="selected-icon"] { + visibility: visible; + } + } + } + } + } + } + } + + [data-component="footer"] { + display: flex; + flex-direction: column; + width: 100%; + justify-content: center; + align-items: center; + gap: 24px; + flex-shrink: 0; + + @media (min-width: 768px) { + height: 120px; + } + + [data-slot="footer-content"] { + display: flex; + gap: 24px; + align-items: center; + justify-content: center; + + @media (min-width: 768px) { + gap: 40px; + } + + span, + a { + color: rgba(255, 255, 255, 0.39); + font-family: "JetBrains Mono Nerd Font"; + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: normal; + text-decoration: none; + } + + [data-slot="github-stars"] { + color: rgba(255, 255, 255, 0.25); + font-family: "JetBrains Mono Nerd Font"; + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: normal; + } + + [data-slot="anomaly"] { + display: none; + + @media (min-width: 768px) { + display: block; + } + } + } + [data-slot="anomaly-alt"] { + color: rgba(255, 255, 255, 0.39); + font-family: "JetBrains Mono Nerd Font"; + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: normal; + text-decoration: none; + margin-bottom: 24px; + + a { + color: rgba(255, 255, 255, 0.39); + font-family: "JetBrains Mono Nerd Font"; + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: normal; + text-decoration: none; + } + + @media (min-width: 768px) { + display: none; + } + } + } +} diff --git a/packages/console/app/src/routes/black.tsx b/packages/console/app/src/routes/black.tsx new file mode 100644 index 0000000000..36c9d1eaf0 --- /dev/null +++ b/packages/console/app/src/routes/black.tsx @@ -0,0 +1,284 @@ +import { A, createAsync, RouteSectionProps } from "@solidjs/router" +import { Title, Meta, Link } from "@solidjs/meta" +import { createMemo, createSignal } from "solid-js" +import { github } from "~/lib/github" +import { config } from "~/config" +import LightRays, { defaultConfig, type LightRaysConfig, type LightRaysAnimationState } from "~/component/light-rays" +import "./black.css" + +export default function BlackLayout(props: RouteSectionProps) { + const githubData = createAsync(() => github()) + const starCount = createMemo(() => + githubData()?.stars + ? new Intl.NumberFormat("en-US", { + notation: "compact", + compactDisplay: "short", + }).format(githubData()!.stars!) + : config.github.starsFormatted.compact, + ) + + const [lightRaysConfig, setLightRaysConfig] = createSignal(defaultConfig) + const [rayAnimationState, setRayAnimationState] = createSignal({ + time: 0, + intensity: 0.5, + pulseValue: 1, + }) + + const svgLightingValues = createMemo(() => { + const state = rayAnimationState() + const t = state.time + + const wave1 = Math.sin(t * 1.5) * 0.5 + 0.5 + const wave2 = Math.sin(t * 2.3 + 1.2) * 0.5 + 0.5 + const wave3 = Math.sin(t * 0.8 + 2.5) * 0.5 + 0.5 + + const shimmerPos = Math.sin(t * 0.7) * 0.5 + 0.5 + const glowIntensity = state.intensity * state.pulseValue * 0.35 + const fillOpacity = 0.1 + wave1 * 0.08 * state.pulseValue + const strokeBrightness = 55 + wave2 * 25 * state.pulseValue + + const shimmerIntensity = wave3 * 0.15 * state.pulseValue + + return { + glowIntensity, + fillOpacity, + strokeBrightness, + shimmerPos, + shimmerIntensity, + } + }) + + const svgLightingStyle = createMemo(() => { + const values = svgLightingValues() + return { + "--hero-black-glow-intensity": values.glowIntensity.toFixed(3), + "--hero-black-stroke-brightness": `${values.strokeBrightness.toFixed(0)}%`, + } as Record + }) + + const handleAnimationFrame = (state: LightRaysAnimationState) => { + setRayAnimationState(state) + } + + return ( +
+ OpenCode Black | Access all the world's best coding models + + + + + + + + + + + + + + +
+ + + opencode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+

Access all the world's best coding models

+

Including Claude, GPT, Gemini and more

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {props.children} +
+ +
+ ) +} diff --git a/packages/console/app/src/routes/black/common.tsx b/packages/console/app/src/routes/black/common.tsx new file mode 100644 index 0000000000..39844abee3 --- /dev/null +++ b/packages/console/app/src/routes/black/common.tsx @@ -0,0 +1,62 @@ +import { Match, Switch } from "solid-js" + +export const plans = [ + { id: "20", multiplier: null }, + { id: "100", multiplier: "5x more usage than Black 20" }, + { id: "200", multiplier: "20x more usage than Black 20" }, +] as const + +export type PlanID = (typeof plans)[number]["id"] +export type Plan = (typeof plans)[number] + +export function PlanIcon(props: { plan: string }) { + return ( + + + + Black 20 plan + + + + + + Black 100 plan + + + + + + + + + Black 200 plan + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/packages/console/app/src/routes/black/index.css b/packages/console/app/src/routes/black/index.css deleted file mode 100644 index 418598792f..0000000000 --- a/packages/console/app/src/routes/black/index.css +++ /dev/null @@ -1,409 +0,0 @@ -[data-page="black"] { - background: #000; - min-height: 100vh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: stretch; - font-family: var(--font-mono); - color: #fff; - - [data-component="header-gradient"] { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 288px; - background: linear-gradient(180deg, rgba(255, 255, 255, 0.16) 0%, rgba(0, 0, 0, 0) 100%); - } - - [data-component="header"] { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding-top: 40px; - flex-shrink: 0; - - /* [data-component="header-logo"] { */ - /* } */ - } - - [data-component="content"] { - display: flex; - flex-direction: column; - align-items: center; - width: 100%; - flex-grow: 1; - - [data-slot="hero-black"] { - margin-top: 110px; - - @media (min-width: 768px) { - margin-top: 150px; - } - } - - [data-slot="cta"] { - display: flex; - flex-direction: column; - gap: 32px; - align-items: center; - text-align: center; - margin-top: -18px; - - @media (min-width: 768px) { - margin-top: 40px; - } - - [data-slot="heading"] { - color: rgba(255, 255, 255, 0.92); - text-align: center; - font-size: 18px; - font-style: normal; - font-weight: 400; - line-height: 160%; /* 28.8px */ - - span { - display: inline-block; - } - } - [data-slot="subheading"] { - color: rgba(255, 255, 255, 0.59); - font-size: 15px; - font-style: normal; - font-weight: 400; - line-height: 160%; - - @media (min-width: 768px) { - font-size: 18px; - line-height: 160%; - } - } - [data-slot="button"] { - display: inline-flex; - height: 40px; - padding: 0 12px; - justify-content: center; - align-items: center; - gap: 8px; - border-radius: 4px; - background: rgba(255, 255, 255, 0.92); - text-decoration: none; - color: #000; - font-family: "JetBrains Mono Nerd Font"; - font-size: 16px; - font-style: normal; - font-weight: 500; - line-height: normal; - - &:hover { - background: #e0e0e0; - } - - &:active { - transform: scale(0.98); - } - } - [data-slot="back-soon"] { - color: rgba(255, 255, 255, 0.59); - text-align: center; - font-size: 13px; - font-style: normal; - font-weight: 400; - line-height: 160%; /* 20.8px */ - } - [data-slot="follow-us"] { - display: inline-flex; - height: 40px; - padding: 0 12px; - justify-content: center; - align-items: center; - gap: 8px; - border-radius: 4px; - border: 1px solid rgba(255, 255, 255, 0.17); - color: rgba(255, 255, 255, 0.59); - font-family: "JetBrains Mono Nerd Font"; - font-size: 14px; - font-style: normal; - font-weight: 400; - line-height: normal; - text-decoration: none; - } - - [data-slot="pricing"] { - display: flex; - flex-direction: column; - gap: 16px; - width: 100%; - max-width: 540px; - padding: 0 20px; - - @media (min-width: 768px) { - padding: 0; - } - } - - [data-slot="pricing-card"] { - display: flex; - flex-direction: column; - gap: 12px; - padding: 20px; - border: 1px solid rgba(255, 255, 255, 0.17); - border-radius: 4px; - text-decoration: none; - transition: border-color 0.15s ease; - background: transparent; - cursor: pointer; - text-align: left; - - &:hover { - border-color: rgba(255, 255, 255, 0.35); - } - - [data-slot="icon"] { - color: rgba(255, 255, 255, 0.59); - } - - [data-slot="price"] { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: 8px; - } - - [data-slot="amount"] { - color: rgba(255, 255, 255, 0.92); - font-size: 24px; - font-weight: 500; - } - - [data-slot="period"] { - color: rgba(255, 255, 255, 0.59); - font-size: 14px; - } - - [data-slot="multiplier"] { - color: rgba(255, 255, 255, 0.39); - font-size: 14px; - - &::before { - content: "·"; - margin-right: 8px; - } - } - } - - [data-slot="selected-plan"] { - display: flex; - flex-direction: column; - gap: 32px; - width: fit-content; - max-width: calc(100% - 40px); - margin: 0 auto; - } - - [data-slot="selected-card"] { - display: flex; - flex-direction: column; - gap: 16px; - padding: 20px; - border: 1px solid rgba(255, 255, 255, 0.17); - border-radius: 4px; - width: fit-content; - - [data-slot="icon"] { - color: rgba(255, 255, 255, 0.59); - } - - [data-slot="price"] { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: 8px; - } - - [data-slot="amount"] { - color: rgba(255, 255, 255, 0.92); - font-size: 24px; - font-weight: 500; - } - - [data-slot="period"] { - color: rgba(255, 255, 255, 0.59); - font-size: 14px; - } - - [data-slot="multiplier"] { - color: rgba(255, 255, 255, 0.39); - font-size: 14px; - - &::before { - content: "·"; - margin-right: 8px; - } - } - - [data-slot="terms"] { - list-style: none; - padding: 0; - margin: 0; - display: flex; - flex-direction: column; - gap: 12px; - text-align: left; - - li { - color: rgba(255, 255, 255, 0.59); - font-size: 13px; - line-height: 1.5; - padding-left: 16px; - position: relative; - white-space: nowrap; - - &::before { - content: "▪"; - position: absolute; - left: 0; - color: rgba(255, 255, 255, 0.39); - } - } - } - - [data-slot="actions"] { - display: flex; - gap: 16px; - margin-top: 8px; - - button, - a { - flex: 1; - display: inline-flex; - height: 48px; - padding: 0 16px; - justify-content: center; - align-items: center; - border-radius: 4px; - font-family: var(--font-mono); - font-size: 16px; - font-weight: 400; - text-decoration: none; - cursor: pointer; - } - - [data-slot="cancel"] { - background: transparent; - border: 1px solid rgba(255, 255, 255, 0.17); - color: rgba(255, 255, 255, 0.92); - - &:hover { - border-color: rgba(255, 255, 255, 0.35); - } - } - - [data-slot="continue"] { - background: rgba(255, 255, 255, 0.17); - border: 1px solid rgba(255, 255, 255, 0.17); - color: rgba(255, 255, 255, 0.59); - - &:hover { - background: rgba(255, 255, 255, 0.25); - } - } - } - } - - [data-slot="fine-print"] { - color: rgba(255, 255, 255, 0.39); - text-align: center; - font-size: 13px; - font-style: normal; - font-weight: 400; - line-height: 160%; /* 20.8px */ - font-style: italic; - - a { - color: rgba(255, 255, 255, 0.39); - text-decoration: underline; - } - } - } - } - - [data-component="footer"] { - display: flex; - flex-direction: column; - width: 100%; - justify-content: center; - align-items: center; - gap: 24px; - flex-shrink: 0; - - @media (min-width: 768px) { - height: 120px; - } - - [data-slot="footer-content"] { - display: flex; - gap: 24px; - align-items: center; - justify-content: center; - - @media (min-width: 768px) { - gap: 40px; - } - - span, - a { - color: rgba(255, 255, 255, 0.39); - font-family: "JetBrains Mono Nerd Font"; - font-size: 16px; - font-style: normal; - font-weight: 400; - line-height: normal; - text-decoration: none; - } - - [data-slot="github-stars"] { - color: rgba(255, 255, 255, 0.25); - font-family: "JetBrains Mono Nerd Font"; - font-size: 16px; - font-style: normal; - font-weight: 400; - line-height: normal; - } - - [data-slot="anomaly"] { - display: none; - - @media (min-width: 768px) { - display: block; - } - } - } - [data-slot="anomaly-alt"] { - color: rgba(255, 255, 255, 0.39); - font-family: "JetBrains Mono Nerd Font"; - font-size: 16px; - font-style: normal; - font-weight: 400; - line-height: normal; - text-decoration: none; - margin-bottom: 24px; - - a { - color: rgba(255, 255, 255, 0.39); - font-family: "JetBrains Mono Nerd Font"; - font-size: 16px; - font-style: normal; - font-weight: 400; - line-height: normal; - text-decoration: none; - } - - @media (min-width: 768px) { - display: none; - } - } - } -} diff --git a/packages/console/app/src/routes/black/index.tsx b/packages/console/app/src/routes/black/index.tsx index f5a375adf8..8f7ee95f3a 100644 --- a/packages/console/app/src/routes/black/index.tsx +++ b/packages/console/app/src/routes/black/index.tsx @@ -1,276 +1,178 @@ -import { A, createAsync, useSearchParams } from "@solidjs/router" -import "./index.css" +import { A, useSearchParams } from "@solidjs/router" import { Title } from "@solidjs/meta" -import { github } from "~/lib/github" -import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js" -import { config } from "~/config" - -const plans = [ - { id: "20", amount: 20, multiplier: null }, - { id: "100", amount: 100, multiplier: "6x more usage than Black 20" }, - { id: "200", amount: 200, multiplier: "21x more usage than Black 20" }, -] as const - -function PlanIcon(props: { plan: string }) { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) -} +import { createMemo, createSignal, For, Match, onMount, Show, Switch } from "solid-js" +import { PlanIcon, plans } from "./common" export default function Black() { const [params] = useSearchParams() - const [selected, setSelected] = createSignal(params.plan as string | null) + const [selected, setSelected] = createSignal((params.plan as string) || null) + const [mounted, setMounted] = createSignal(false) const selectedPlan = createMemo(() => plans.find((p) => p.id === selected())) - const githubData = createAsync(() => github()) - const starCount = createMemo(() => - githubData()?.stars - ? new Intl.NumberFormat("en-US", { - notation: "compact", - compactDisplay: "short", - }).format(githubData()!.stars!) - : config.github.starsFormatted.compact, - ) + onMount(() => { + requestAnimationFrame(() => setMounted(true)) + }) + + const transition = (action: () => void) => { + if (mounted() && "startViewTransition" in document) { + ;(document as any).startViewTransition(action) + return + } + + action() + } + + const select = (planId: string) => { + if (selected() === planId) { + return + } + + transition(() => setSelected(planId)) + } + + const cancel = () => { + transition(() => setSelected(null)) + } return ( -
+ <> opencode -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
- - - - - - - - - - - - - - - - - - -
-
-
-

- Access all the world's best coding models -

-

Including Claude, GPT, Gemini, and more

-
- - -
- - {(plan) => ( - - )} - -
-

- Prices shown don't include applicable tax · Terms of Service -

-
- - {(plan) => ( -
-
-
- +
+ + +
+ + {(plan) => ( + - - Continue - -
+ + )} + +
+ + + {(plan) => ( +
+
+
+
-

- Prices shown don't include applicable tax · Terms of Service +

+ ${plan().id}{" "} + per person billed monthly + + {plan().multiplier} +

+
    +
  • Your subscription will not start immediately
  • +
  • You will be added to the waitlist and activated soon
  • +
  • Your card will be only charged when your subscription is activated
  • +
  • Usage limits apply, heavily automated use may reach limits sooner
  • +
  • Subscriptions for individuals, contact Enterprise for teams
  • +
  • Limits may be adjusted and plans may be discontinued in the future
  • +
  • Cancel your subscription at anytime
  • +
+
+ + + Continue + +
- )} - - -
-
- -
+
+ )} + + + {(plan) => ( +
+
+
+ +
+

+ ${plan().id}{" "} + per person billed monthly + + {plan().multiplier} + +

+
    +
  • Your subscription will not start immediately
  • +
  • You will be added to the waitlist and activated soon
  • +
  • Your card will be only charged when your subscription is activated
  • +
  • Usage limits apply, heavily automated use may reach limits sooner
  • +
  • Subscriptions for individuals, contact Enterprise for teams
  • +
  • Limits may be adjusted and plans may be discontinued in the future
  • +
  • Cancel your subscription at anytime
  • +
+
+ + + Continue + +
+
+
+ )} +
+ + {(plan) => ( +
+
+
+ +
+

+ ${plan().id}{" "} + per person billed monthly + + {plan().multiplier} + +

+
    +
  • Your subscription will not start immediately
  • +
  • You will be added to the waitlist and activated soon
  • +
  • Your card will be only charged when your subscription is activated
  • +
  • Usage limits apply, heavily automated use may reach limits sooner
  • +
  • Subscriptions for individuals, contact Enterprise for teams
  • +
  • Limits may be adjusted and plans may be discontinued in the future
  • +
  • Cancel your subscription at anytime
  • +
+
+ + + Continue + +
+
+
+ )} +
+ +

+ Prices shown don't include applicable tax · Terms of Service +

+ + ) } diff --git a/packages/console/app/src/routes/black/subscribe/[plan].tsx b/packages/console/app/src/routes/black/subscribe/[plan].tsx new file mode 100644 index 0000000000..b2b9b32e15 --- /dev/null +++ b/packages/console/app/src/routes/black/subscribe/[plan].tsx @@ -0,0 +1,451 @@ +import { A, createAsync, query, redirect, useParams } from "@solidjs/router" +import { Title } from "@solidjs/meta" +import { createEffect, createSignal, For, Match, Show, Switch } from "solid-js" +import { type Stripe, type PaymentMethod, loadStripe } from "@stripe/stripe-js" +import { Elements, PaymentElement, useStripe, useElements, AddressElement } from "solid-stripe" +import { PlanID, plans } from "../common" +import { getActor, useAuthSession } from "~/context/auth" +import { withActor } from "~/context/auth.withActor" +import { Actor } from "@opencode-ai/console-core/actor.js" +import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js" +import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js" +import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js" +import { createList } from "solid-list" +import { Modal } from "~/component/modal" +import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js" +import { Billing } from "@opencode-ai/console-core/billing.js" + +const plansMap = Object.fromEntries(plans.map((p) => [p.id, p])) as Record +const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY!) + +const getWorkspaces = query(async (plan: string) => { + "use server" + const actor = await getActor() + if (actor.type === "public") throw redirect("/auth/authorize?continue=/black/subscribe/" + plan) + return withActor(async () => { + return Database.use((tx) => + tx + .select({ + id: WorkspaceTable.id, + name: WorkspaceTable.name, + slug: WorkspaceTable.slug, + billing: { + customerID: BillingTable.customerID, + paymentMethodID: BillingTable.paymentMethodID, + paymentMethodType: BillingTable.paymentMethodType, + paymentMethodLast4: BillingTable.paymentMethodLast4, + subscriptionID: BillingTable.subscriptionID, + timeSubscriptionBooked: BillingTable.timeSubscriptionBooked, + }, + }) + .from(UserTable) + .innerJoin(WorkspaceTable, eq(UserTable.workspaceID, WorkspaceTable.id)) + .innerJoin(BillingTable, eq(WorkspaceTable.id, BillingTable.workspaceID)) + .where( + and( + eq(UserTable.accountID, Actor.account()), + isNull(WorkspaceTable.timeDeleted), + isNull(UserTable.timeDeleted), + ), + ), + ) + }) +}, "black.subscribe.workspaces") + +const createSetupIntent = async (input: { plan: string; workspaceID: string }) => { + "use server" + const { plan, workspaceID } = input + + if (!plan || !["20", "100", "200"].includes(plan)) return { error: "Invalid plan" } + if (!workspaceID) return { error: "Workspace ID is required" } + + return withActor(async () => { + const session = await useAuthSession() + const account = session.data.account?.[session.data.current ?? ""] + const email = account?.email + + const customer = await Database.use((tx) => + tx + .select({ + customerID: BillingTable.customerID, + subscriptionID: BillingTable.subscriptionID, + }) + .from(BillingTable) + .where(eq(BillingTable.workspaceID, workspaceID)) + .then((rows) => rows[0]), + ) + if (customer?.subscriptionID) { + return { error: "This workspace already has a subscription" } + } + + let customerID = customer?.customerID + if (!customerID) { + const customer = await Billing.stripe().customers.create({ + email, + metadata: { + workspaceID, + }, + }) + customerID = customer.id + await Database.use((tx) => + tx + .update(BillingTable) + .set({ + customerID, + }) + .where(eq(BillingTable.workspaceID, workspaceID)), + ) + } + + const intent = await Billing.stripe().setupIntents.create({ + customer: customerID, + payment_method_types: ["card"], + metadata: { + workspaceID, + }, + }) + + return { clientSecret: intent.client_secret ?? undefined } + }, workspaceID) +} + +const bookSubscription = async (input: { + workspaceID: string + plan: PlanID + paymentMethodID: string + paymentMethodType: string + paymentMethodLast4?: string +}) => { + "use server" + return withActor( + () => + Database.use((tx) => + tx + .update(BillingTable) + .set({ + paymentMethodID: input.paymentMethodID, + paymentMethodType: input.paymentMethodType, + paymentMethodLast4: input.paymentMethodLast4, + subscriptionPlan: input.plan, + timeSubscriptionBooked: new Date(), + }) + .where(eq(BillingTable.workspaceID, input.workspaceID)), + ), + input.workspaceID, + ) +} + +interface SuccessData { + plan: string + paymentMethodType: string + paymentMethodLast4?: string +} + +function Failure(props: { message: string }) { + return ( +
+

Uh oh! {props.message}

+
+ ) +} + +function Success(props: SuccessData) { + return ( +
+

You're on the OpenCode Black waitlist

+
+
+
Subscription plan
+
OpenCode Black {props.plan}
+
+
+
Amount
+
${props.plan} per month
+
+
+
Payment method
+
+ {props.paymentMethodType}}> + + {props.paymentMethodType} - {props.paymentMethodLast4} + + +
+
+
+
Date joined
+
{new Date().toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
+
+
+

Your card will be charged when your subscription is activated

+
+ ) +} + +function IntentForm(props: { plan: PlanID; workspaceID: string; onSuccess: (data: SuccessData) => void }) { + const stripe = useStripe() + const elements = useElements() + const [error, setError] = createSignal(undefined) + const [loading, setLoading] = createSignal(false) + + const handleSubmit = async (e: Event) => { + e.preventDefault() + if (!stripe() || !elements()) return + + setLoading(true) + setError(undefined) + + const result = await elements()!.submit() + if (result.error) { + setError(result.error.message ?? "An error occurred") + setLoading(false) + return + } + + const { error: confirmError, setupIntent } = await stripe()!.confirmSetup({ + elements: elements()!, + confirmParams: { + expand: ["payment_method"], + payment_method_data: { + allow_redisplay: "always", + }, + }, + redirect: "if_required", + }) + + if (confirmError) { + setError(confirmError.message ?? "An error occurred") + setLoading(false) + return + } + + // TODO + console.log(setupIntent) + if (setupIntent?.status === "succeeded") { + const pm = setupIntent.payment_method as PaymentMethod + + await bookSubscription({ + workspaceID: props.workspaceID, + plan: props.plan, + paymentMethodID: pm.id, + paymentMethodType: pm.type, + paymentMethodLast4: pm.card?.last4, + }) + + props.onSuccess({ + plan: props.plan, + paymentMethodType: pm.type, + paymentMethodLast4: pm.card?.last4, + }) + } + + setLoading(false) + } + + return ( +
+ + + +

{error()}

+
+ +

You will only be charged when your subscription is activated

+ + ) +} + +export default function BlackSubscribe() { + const params = useParams() + const planData = plansMap[(params.plan as PlanID) ?? "20"] ?? plansMap["20"] + const plan = planData.id + + const workspaces = createAsync(() => getWorkspaces(plan)) + const [selectedWorkspace, setSelectedWorkspace] = createSignal(undefined) + const [success, setSuccess] = createSignal(undefined) + const [failure, setFailure] = createSignal(undefined) + const [clientSecret, setClientSecret] = createSignal(undefined) + const [stripe, setStripe] = createSignal(undefined) + + // Resolve stripe promise once + createEffect(() => { + stripePromise.then((s) => { + if (s) setStripe(s) + }) + }) + + // Auto-select if only one workspace + createEffect(() => { + const ws = workspaces() + if (ws?.length === 1 && !selectedWorkspace()) { + setSelectedWorkspace(ws[0].id) + } + }) + + // Fetch setup intent when workspace is selected (unless workspace already has payment method) + createEffect(async () => { + const id = selectedWorkspace() + if (!id) return + + const ws = workspaces()?.find((w) => w.id === id) + if (ws?.billing?.subscriptionID) { + setFailure("This workspace already has a subscription") + return + } + if (ws?.billing?.paymentMethodID) { + if (!ws?.billing?.timeSubscriptionBooked) { + await bookSubscription({ + workspaceID: id, + plan: planData.id, + paymentMethodID: ws.billing.paymentMethodID!, + paymentMethodType: ws.billing.paymentMethodType!, + paymentMethodLast4: ws.billing.paymentMethodLast4 ?? undefined, + }) + } + setSuccess({ + plan: planData.id, + paymentMethodType: ws.billing.paymentMethodType!, + paymentMethodLast4: ws.billing.paymentMethodLast4 ?? undefined, + }) + return + } + + const result = await createSetupIntent({ plan, workspaceID: id }) + if (result.error) { + setFailure(result.error) + } else if ("clientSecret" in result) { + setClientSecret(result.clientSecret) + } + }) + + // Keyboard navigation for workspace picker + const { active, setActive, onKeyDown } = createList({ + items: () => workspaces()?.map((w) => w.id) ?? [], + initialActive: null, + }) + + const handleSelectWorkspace = (id: string) => { + setSelectedWorkspace(id) + } + + let listRef: HTMLUListElement | undefined + + // Show workspace picker if multiple workspaces and none selected + const showWorkspacePicker = () => { + const ws = workspaces() + return ws && ws.length > 1 && !selectedWorkspace() + } + + return ( + <> + Subscribe to OpenCode Black +
+
+ + {(data) => } + {(data) => } + + <> +
+

Subscribe to OpenCode Black

+

+ ${planData.id} per month + + {planData.multiplier} + +

+
+
+

Payment method

+ + +

{selectedWorkspace() ? "Loading payment form..." : "Select a workspace to continue"}

+
+ } + > + + + + + +
+
+
+ + {/* Workspace picker modal */} + {}} title="Select a workspace for this plan"> +
+
    { + if (e.key === "Enter" && active()) { + handleSelectWorkspace(active()!) + } else { + onKeyDown(e) + } + }} + > + + {(workspace) => ( +
  • setActive(workspace.id)} + onClick={() => handleSelectWorkspace(workspace.id)} + > + [*] + {workspace.name || workspace.slug} +
  • + )} +
    +
+
+
+

+ Prices shown don't include applicable tax · Terms of Service +

+
+ + ) +} diff --git a/packages/console/app/src/routes/changelog/index.css b/packages/console/app/src/routes/changelog/index.css new file mode 100644 index 0000000000..29020a924e --- /dev/null +++ b/packages/console/app/src/routes/changelog/index.css @@ -0,0 +1,477 @@ +::selection { + background: var(--color-background-interactive); + color: var(--color-text-strong); + + @media (prefers-color-scheme: dark) { + background: var(--color-background-interactive); + color: var(--color-text-inverted); + } +} + +[data-page="changelog"] { + --color-background: hsl(0, 20%, 99%); + --color-background-weak: hsl(0, 8%, 97%); + --color-background-weak-hover: hsl(0, 8%, 94%); + --color-background-strong: hsl(0, 5%, 12%); + --color-background-strong-hover: hsl(0, 5%, 18%); + --color-background-interactive: hsl(62, 84%, 88%); + --color-background-interactive-weaker: hsl(64, 74%, 95%); + + --color-text: hsl(0, 1%, 39%); + --color-text-weak: hsl(0, 1%, 60%); + --color-text-weaker: hsl(30, 2%, 81%); + --color-text-strong: hsl(0, 5%, 12%); + --color-text-inverted: hsl(0, 20%, 99%); + + --color-border: hsl(30, 2%, 81%); + --color-border-weak: hsl(0, 1%, 85%); + + --color-icon: hsl(0, 1%, 55%); + + background: var(--color-background); + font-family: var(--font-mono); + color: var(--color-text); + padding-bottom: 5rem; + + @media (prefers-color-scheme: dark) { + --color-background: hsl(0, 9%, 7%); + --color-background-weak: hsl(0, 6%, 10%); + --color-background-weak-hover: hsl(0, 6%, 15%); + --color-background-strong: hsl(0, 15%, 94%); + --color-background-strong-hover: hsl(0, 15%, 97%); + --color-background-interactive: hsl(62, 100%, 90%); + --color-background-interactive-weaker: hsl(60, 20%, 8%); + + --color-text: hsl(0, 4%, 71%); + --color-text-weak: hsl(0, 2%, 49%); + --color-text-weaker: hsl(0, 3%, 28%); + --color-text-strong: hsl(0, 15%, 94%); + --color-text-inverted: hsl(0, 9%, 7%); + + --color-border: hsl(0, 3%, 28%); + --color-border-weak: hsl(0, 4%, 23%); + + --color-icon: hsl(10, 3%, 43%); + } + + /* Header styles - copied from download */ + [data-component="top"] { + padding: 24px 5rem; + height: 80px; + position: sticky; + top: 0; + display: flex; + justify-content: space-between; + align-items: center; + background: var(--color-background); + border-bottom: 1px solid var(--color-border-weak); + z-index: 10; + + @media (max-width: 60rem) { + padding: 24px 1.5rem; + } + + img { + height: 34px; + width: auto; + } + + [data-component="nav-desktop"] { + ul { + display: flex; + justify-content: space-between; + align-items: center; + gap: 48px; + + @media (max-width: 55rem) { + gap: 32px; + } + + @media (max-width: 48rem) { + gap: 24px; + } + li { + display: inline-block; + a { + text-decoration: none; + span { + color: var(--color-text-weak); + } + } + a:hover { + text-decoration: underline; + text-underline-offset: 2px; + text-decoration-thickness: 1px; + } + [data-slot="cta-button"] { + background: var(--color-background-strong); + color: var(--color-text-inverted); + padding: 8px 16px; + border-radius: 4px; + font-weight: 500; + text-decoration: none; + + @media (max-width: 55rem) { + display: none; + } + } + [data-slot="cta-button"]:hover { + background: var(--color-background-strong-hover); + text-decoration: none; + } + } + } + + @media (max-width: 40rem) { + display: none; + } + } + + [data-component="nav-mobile"] { + button > svg { + color: var(--color-icon); + } + } + + [data-component="nav-mobile-toggle"] { + border: none; + background: none; + outline: none; + height: 40px; + width: 40px; + cursor: pointer; + margin-right: -8px; + } + + [data-component="nav-mobile-toggle"]:hover { + background: var(--color-background-weak); + } + + [data-component="nav-mobile"] { + display: none; + + @media (max-width: 40rem) { + display: block; + + [data-component="nav-mobile-icon"] { + cursor: pointer; + height: 40px; + width: 40px; + display: flex; + align-items: center; + justify-content: center; + } + + [data-component="nav-mobile-menu-list"] { + position: fixed; + background: var(--color-background); + top: 80px; + left: 0; + right: 0; + height: 100vh; + + ul { + list-style: none; + padding: 20px 0; + + li { + a { + text-decoration: none; + padding: 20px; + display: block; + + span { + color: var(--color-text-weak); + } + } + + a:hover { + background: var(--color-background-weak); + } + } + } + } + } + } + + [data-slot="logo dark"] { + display: none; + } + + @media (prefers-color-scheme: dark) { + [data-slot="logo light"] { + display: none; + } + [data-slot="logo dark"] { + display: block; + } + } + } + + [data-component="footer"] { + border-top: 1px solid var(--color-border-weak); + display: flex; + flex-direction: row; + margin-top: 4rem; + + @media (max-width: 65rem) { + border-bottom: 1px solid var(--color-border-weak); + } + + [data-slot="cell"] { + flex: 1; + text-align: center; + + a { + text-decoration: none; + padding: 2rem 0; + width: 100%; + display: block; + + span { + color: var(--color-text-weak); + + @media (max-width: 40rem) { + display: none; + } + } + } + + a:hover { + background: var(--color-background-weak); + text-decoration: underline; + text-underline-offset: 2px; + text-decoration-thickness: 1px; + } + } + + [data-slot="cell"] + [data-slot="cell"] { + border-left: 1px solid var(--color-border-weak); + + @media (max-width: 40rem) { + border-left: none; + } + } + + @media (max-width: 25rem) { + flex-wrap: wrap; + + [data-slot="cell"] { + flex: 1 0 100%; + border-left: none; + border-top: 1px solid var(--color-border-weak); + } + + [data-slot="cell"]:nth-child(1) { + border-top: none; + } + } + } + + [data-component="container"] { + max-width: 67.5rem; + margin: 0 auto; + border: 1px solid var(--color-border-weak); + border-top: none; + + @media (max-width: 65rem) { + border: none; + } + } + + [data-component="content"] { + padding: 6rem 5rem; + + @media (max-width: 60rem) { + padding: 4rem 1.5rem; + } + } + + [data-component="legal"] { + color: var(--color-text-weak); + text-align: center; + padding: 2rem 5rem; + display: flex; + gap: 32px; + justify-content: center; + + @media (max-width: 60rem) { + padding: 2rem 1.5rem; + } + + a { + color: var(--color-text-weak); + text-decoration: none; + } + + a:hover { + color: var(--color-text); + text-decoration: underline; + } + } + + /* Changelog Hero */ + [data-component="changelog-hero"] { + margin-bottom: 4rem; + padding-bottom: 2rem; + border-bottom: 1px solid var(--color-border-weak); + + @media (max-width: 50rem) { + margin-bottom: 2rem; + padding-bottom: 1.5rem; + } + + h1 { + font-size: 1.5rem; + font-weight: 700; + color: var(--color-text-strong); + margin-bottom: 8px; + } + + p { + color: var(--color-text); + } + } + + /* Releases */ + [data-component="releases"] { + display: flex; + flex-direction: column; + gap: 0; + } + + [data-component="release"] { + display: grid; + grid-template-columns: 180px 1fr; + gap: 3rem; + padding: 2rem 0; + border-bottom: 1px solid var(--color-border-weak); + + @media (max-width: 50rem) { + grid-template-columns: 1fr; + gap: 1rem; + } + + &:first-child { + padding-top: 0; + } + + &:last-child { + border-bottom: none; + } + + header { + display: flex; + flex-direction: column; + gap: 4px; + + @media (max-width: 50rem) { + flex-direction: row; + align-items: center; + gap: 12px; + } + + [data-slot="version"] { + a { + font-weight: 600; + color: var(--color-text-strong); + text-decoration: none; + + &:hover { + text-decoration: underline; + text-underline-offset: 2px; + text-decoration-thickness: 1px; + } + } + } + + time { + color: var(--color-text-weak); + font-size: 14px; + } + } + + [data-slot="content"] { + display: flex; + flex-direction: column; + gap: 1.5rem; + } + + [data-component="section"] { + h3 { + font-size: 14px; + font-weight: 600; + color: var(--color-text-strong); + margin-bottom: 8px; + } + + ul { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 6px; + + li { + color: var(--color-text); + line-height: 1.5; + padding-left: 16px; + position: relative; + + &::before { + content: "-"; + position: absolute; + left: 0; + color: var(--color-text-weak); + } + + [data-slot="author"] { + color: var(--color-text-weak); + font-size: 13px; + margin-left: 4px; + text-decoration: none; + + &:hover { + text-decoration: underline; + text-underline-offset: 2px; + text-decoration-thickness: 1px; + } + } + } + } + } + + [data-component="contributors"] { + font-size: 13px; + color: var(--color-text-weak); + padding-top: 0.5rem; + + span { + color: var(--color-text-weak); + } + + a { + color: var(--color-text); + text-decoration: none; + + &:hover { + text-decoration: underline; + text-underline-offset: 2px; + text-decoration-thickness: 1px; + } + } + } + } + + a { + color: var(--color-text-strong); + text-decoration: underline; + text-underline-offset: 2px; + text-decoration-thickness: 1px; + + &:hover { + text-decoration-thickness: 2px; + } + } +} diff --git a/packages/console/app/src/routes/changelog/index.tsx b/packages/console/app/src/routes/changelog/index.tsx new file mode 100644 index 0000000000..cf71d021cc --- /dev/null +++ b/packages/console/app/src/routes/changelog/index.tsx @@ -0,0 +1,147 @@ +import "./index.css" +import { Title, Meta, Link } from "@solidjs/meta" +import { createAsync, query } from "@solidjs/router" +import { Header } from "~/component/header" +import { Footer } from "~/component/footer" +import { Legal } from "~/component/legal" +import { config } from "~/config" +import { For, Show } from "solid-js" + +type Release = { + tag_name: string + name: string + body: string + published_at: string + html_url: string +} + +const getReleases = query(async () => { + "use server" + const response = await fetch("https://api.github.com/repos/anomalyco/opencode/releases?per_page=20", { + headers: { + Accept: "application/vnd.github.v3+json", + "User-Agent": "OpenCode-Console", + }, + cf: { + cacheTtl: 60 * 5, + cacheEverything: true, + }, + } as any) + if (!response.ok) return [] + return response.json() as Promise +}, "releases.get") + +function formatDate(dateString: string) { + const date = new Date(dateString) + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }) +} + +function parseMarkdown(body: string) { + const lines = body.split("\n") + const sections: { title: string; items: string[] }[] = [] + let current: { title: string; items: string[] } | null = null + let skip = false + + for (const line of lines) { + if (line.startsWith("## ")) { + if (current) sections.push(current) + const title = line.slice(3).trim() + current = { title, items: [] } + skip = false + } else if (line.startsWith("**Thank you")) { + skip = true + } else if (line.startsWith("- ") && !skip) { + current?.items.push(line.slice(2).trim()) + } + } + if (current) sections.push(current) + + return { sections } +} + +function ReleaseItem(props: { item: string }) { + const parts = () => { + const match = props.item.match(/^(.+?)(\s*\(@([\w-]+)\))?$/) + if (match) { + return { + text: match[1], + username: match[3], + } + } + return { text: props.item, username: undefined } + } + + return ( +
  • + {parts().text} + + + (@{parts().username}) + + +
  • + ) +} + +export default function Changelog() { + const releases = createAsync(() => getReleases()) + + return ( +
    + OpenCode | Changelog + + + +
    +
    + +
    +
    +

    Changelog

    +

    New updates and improvements to OpenCode

    +
    + +
    + + {(release) => { + const parsed = () => parseMarkdown(release.body || "") + return ( +
    +
    + + +
    +
    + + {(section) => ( +
    +

    {section.title}

    +
      + {(item) => } +
    +
    + )} +
    +
    +
    + ) + }} +
    +
    + +
    +
    +
    + + +
    + ) +} diff --git a/packages/console/app/src/routes/download/[platform].ts b/packages/console/app/src/routes/download/[platform].ts index d8b72aba10..2c30a80362 100644 --- a/packages/console/app/src/routes/download/[platform].ts +++ b/packages/console/app/src/routes/download/[platform].ts @@ -24,7 +24,7 @@ export async function GET({ params: { platform } }: APIEvent) { const resp = await fetch(`https://github.com/anomalyco/opencode/releases/latest/download/${assetName}`, { cf: { // in case gh releases has rate limits - cacheTtl: 60 * 60 * 24, + cacheTtl: 60 * 5, cacheEverything: true, }, } as any) diff --git a/packages/console/app/src/routes/download/index.css b/packages/console/app/src/routes/download/index.css index 5178a6e55b..a5ca692a2d 100644 --- a/packages/console/app/src/routes/download/index.css +++ b/packages/console/app/src/routes/download/index.css @@ -34,7 +34,6 @@ font-family: var(--font-mono); color: var(--color-text); padding-bottom: 5rem; - overflow-x: hidden; @media (prefers-color-scheme: dark) { --color-background: hsl(0, 9%, 7%); diff --git a/packages/console/app/src/routes/download/index.tsx b/packages/console/app/src/routes/download/index.tsx index 4290efd168..8b8f50968c 100644 --- a/packages/console/app/src/routes/download/index.tsx +++ b/packages/console/app/src/routes/download/index.tsx @@ -441,7 +441,8 @@ export default function Download() {
  • - Not anymore! OpenCode is now available as an app for your desktop. + Not anymore! OpenCode is now available as an app for your desktop and{" "} + web!
  • diff --git a/packages/console/app/src/routes/index.tsx b/packages/console/app/src/routes/index.tsx index 830bd9c957..6bcf9dbb53 100644 --- a/packages/console/app/src/routes/index.tsx +++ b/packages/console/app/src/routes/index.tsx @@ -692,7 +692,8 @@ export default function Home() {
  • - Not anymore! OpenCode is now available as an app for your desktop. + Not anymore! OpenCode is now available as an app for your desktop and{" "} + web!
  • diff --git a/packages/console/app/src/routes/workspace/[id]/billing/black-section.module.css b/packages/console/app/src/routes/workspace/[id]/billing/black-section.module.css index c189f0d646..766cff6848 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/black-section.module.css +++ b/packages/console/app/src/routes/workspace/[id]/billing/black-section.module.css @@ -5,4 +5,58 @@ align-items: center; gap: var(--space-4); } + + [data-slot="usage"] { + display: flex; + gap: var(--space-6); + margin-top: var(--space-4); + + @media (max-width: 40rem) { + flex-direction: column; + gap: var(--space-4); + } + } + + [data-slot="usage-item"] { + flex: 1; + display: flex; + flex-direction: column; + gap: var(--space-2); + } + + [data-slot="usage-header"] { + display: flex; + justify-content: space-between; + align-items: baseline; + } + + [data-slot="usage-label"] { + font-size: var(--font-size-md); + font-weight: 500; + color: var(--color-text); + } + + [data-slot="usage-value"] { + font-size: var(--font-size-sm); + color: var(--color-text-muted); + } + + [data-slot="progress"] { + height: 8px; + background-color: var(--color-bg-surface); + border-radius: var(--border-radius-sm); + overflow: hidden; + } + + [data-slot="progress-bar"] { + height: 100%; + background-color: var(--color-accent); + border-radius: var(--border-radius-sm); + transition: width 0.3s ease; + } + + [data-slot="reset-time"] { + font-size: var(--font-size-sm); + color: var(--color-text-muted); + } } diff --git a/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx index 2eece1b62d..beb5adbfcc 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx @@ -1,10 +1,58 @@ -import { action, useParams, useAction, useSubmission, json } from "@solidjs/router" +import { action, useParams, useAction, useSubmission, json, query, createAsync } from "@solidjs/router" import { createStore } from "solid-js/store" +import { Show } from "solid-js" import { Billing } from "@opencode-ai/console-core/billing.js" +import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.js" +import { SubscriptionTable } from "@opencode-ai/console-core/schema/billing.sql.js" +import { Actor } from "@opencode-ai/console-core/actor.js" +import { Black } from "@opencode-ai/console-core/black.js" import { withActor } from "~/context/auth.withActor" import { queryBillingInfo } from "../../common" import styles from "./black-section.module.css" +const querySubscription = query(async (workspaceID: string) => { + "use server" + return withActor(async () => { + const row = await Database.use((tx) => + tx + .select({ + rollingUsage: SubscriptionTable.rollingUsage, + fixedUsage: SubscriptionTable.fixedUsage, + timeRollingUpdated: SubscriptionTable.timeRollingUpdated, + timeFixedUpdated: SubscriptionTable.timeFixedUpdated, + }) + .from(SubscriptionTable) + .where(and(eq(SubscriptionTable.workspaceID, Actor.workspace()), isNull(SubscriptionTable.timeDeleted))) + .then((r) => r[0]), + ) + if (!row) return null + + return { + rollingUsage: Black.analyzeRollingUsage({ + usage: row.rollingUsage ?? 0, + timeUpdated: row.timeRollingUpdated ?? new Date(), + }), + weeklyUsage: Black.analyzeWeeklyUsage({ + usage: row.fixedUsage ?? 0, + timeUpdated: row.timeFixedUpdated ?? new Date(), + }), + } + }, workspaceID) +}, "subscription.get") + +function formatResetTime(seconds: number) { + const days = Math.floor(seconds / 86400) + if (days >= 1) { + const hours = Math.floor((seconds % 86400) / 3600) + return `${days} ${days === 1 ? "day" : "days"} ${hours} ${hours === 1 ? "hour" : "hours"}` + } + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + if (hours >= 1) return `${hours} ${hours === 1 ? "hour" : "hours"} ${minutes} ${minutes === 1 ? "minute" : "minutes"}` + if (minutes === 0) return "a few seconds" + return `${minutes} ${minutes === 1 ? "minute" : "minutes"}` +} + const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => { "use server" return json( @@ -26,6 +74,7 @@ export function BlackSection() { const params = useParams() const sessionAction = useAction(createSessionUrl) const sessionSubmission = useSubmission(createSessionUrl) + const subscription = createAsync(() => querySubscription(params.id!)) const [store, setStore] = createStore({ sessionRedirecting: false, }) @@ -53,6 +102,32 @@ export function BlackSection() {
  • + + {(sub) => ( +
    +
    +
    + 5-hour Usage + {sub().rollingUsage.usagePercent}% +
    +
    +
    +
    + Resets in {formatResetTime(sub().rollingUsage.resetInSec)} +
    +
    +
    + Weekly Usage + {sub().weeklyUsage.usagePercent}% +
    +
    +
    +
    + Resets in {formatResetTime(sub().weeklyUsage.resetInSec)} +
    +
    + )} + ) } diff --git a/packages/console/app/src/routes/workspace/common.tsx b/packages/console/app/src/routes/workspace/common.tsx index a6eaaeb1eb..d97bf9e604 100644 --- a/packages/console/app/src/routes/workspace/common.tsx +++ b/packages/console/app/src/routes/workspace/common.tsx @@ -3,7 +3,6 @@ import { Actor } from "@opencode-ai/console-core/actor.js" import { action, json, query } from "@solidjs/router" import { withActor } from "~/context/auth.withActor" import { Billing } from "@opencode-ai/console-core/billing.js" -import { User } from "@opencode-ai/console-core/user.js" import { and, Database, desc, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js" import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js" import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js" @@ -96,11 +95,22 @@ export const queryBillingInfo = query(async (workspaceID: string) => { return withActor(async () => { const billing = await Billing.get() return { - ...billing, + customerID: billing.customerID, + paymentMethodID: billing.paymentMethodID, + paymentMethodType: billing.paymentMethodType, + paymentMethodLast4: billing.paymentMethodLast4, + balance: billing.balance, + reload: billing.reload, reloadAmount: billing.reloadAmount ?? Billing.RELOAD_AMOUNT, reloadAmountMin: Billing.RELOAD_AMOUNT_MIN, reloadTrigger: billing.reloadTrigger ?? Billing.RELOAD_TRIGGER, reloadTriggerMin: Billing.RELOAD_TRIGGER_MIN, + monthlyLimit: billing.monthlyLimit, + monthlyUsage: billing.monthlyUsage, + timeMonthlyUsageUpdated: billing.timeMonthlyUsageUpdated, + reloadError: billing.reloadError, + timeReloadError: billing.timeReloadError, + subscriptionID: billing.subscriptionID, } }, workspaceID) }, "billing.get") diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 56efe451ca..2ecc4220a1 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -9,7 +9,7 @@ import { Billing } from "@opencode-ai/console-core/billing.js" import { Actor } from "@opencode-ai/console-core/actor.js" import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js" import { ZenData } from "@opencode-ai/console-core/model.js" -import { BlackData } from "@opencode-ai/console-core/black.js" +import { Black, BlackData } from "@opencode-ai/console-core/black.js" import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js" import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js" import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js" @@ -495,27 +495,28 @@ export async function handler( // Check weekly limit if (sub.fixedUsage && sub.timeFixedUpdated) { - const week = getWeekBounds(now) - if (sub.timeFixedUpdated >= week.start && sub.fixedUsage >= centsToMicroCents(black.fixedLimit * 100)) { - const retryAfter = Math.ceil((week.end.getTime() - now.getTime()) / 1000) + const result = Black.analyzeWeeklyUsage({ + usage: sub.fixedUsage, + timeUpdated: sub.timeFixedUpdated, + }) + if (result.status === "rate-limited") throw new SubscriptionError( - `Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`, - retryAfter, + `Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`, + result.resetInSec, ) - } } // Check rolling limit if (sub.rollingUsage && sub.timeRollingUpdated) { - const rollingWindowMs = black.rollingWindow * 3600 * 1000 - const windowStart = new Date(now.getTime() - rollingWindowMs) - if (sub.timeRollingUpdated >= windowStart && sub.rollingUsage >= centsToMicroCents(black.rollingLimit * 100)) { - const retryAfter = Math.ceil((sub.timeRollingUpdated.getTime() + rollingWindowMs - now.getTime()) / 1000) + const result = Black.analyzeRollingUsage({ + usage: sub.rollingUsage, + timeUpdated: sub.timeRollingUpdated, + }) + if (result.status === "rate-limited") throw new SubscriptionError( - `Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`, - retryAfter, + `Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`, + result.resetInSec, ) - } } return diff --git a/packages/console/app/tsconfig.json b/packages/console/app/tsconfig.json index e30a673892..e5fb212de5 100644 --- a/packages/console/app/tsconfig.json +++ b/packages/console/app/tsconfig.json @@ -12,7 +12,7 @@ "allowJs": true, "strict": true, "noEmit": true, - "types": ["vite/client"], + "types": ["vite/client", "@webgpu/types"], "isolatedModules": true, "paths": { "~/*": ["./src/*"] diff --git a/packages/console/core/migrations/0051_jazzy_green_goblin.sql b/packages/console/core/migrations/0051_jazzy_green_goblin.sql new file mode 100644 index 0000000000..cadb4a709e --- /dev/null +++ b/packages/console/core/migrations/0051_jazzy_green_goblin.sql @@ -0,0 +1 @@ +ALTER TABLE `billing` ADD `time_subscription_booked` timestamp(3); \ No newline at end of file diff --git a/packages/console/core/migrations/0052_aromatic_agent_zero.sql b/packages/console/core/migrations/0052_aromatic_agent_zero.sql new file mode 100644 index 0000000000..c53ba5e2b9 --- /dev/null +++ b/packages/console/core/migrations/0052_aromatic_agent_zero.sql @@ -0,0 +1 @@ +ALTER TABLE `billing` ADD `subscription_plan` enum('20','100','200'); \ No newline at end of file diff --git a/packages/console/core/migrations/meta/0051_snapshot.json b/packages/console/core/migrations/meta/0051_snapshot.json new file mode 100644 index 0000000000..0f90479162 --- /dev/null +++ b/packages/console/core/migrations/meta/0051_snapshot.json @@ -0,0 +1,1228 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "14cbf4c8-55f1-4488-956f-56fb5ccb3a5a", + "prevId": "a0d18802-c390-47d4-98f1-d1f83869cf0c", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "account_id_pk": { + "name": "account_id_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "auth": { + "name": "auth", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "enum('email','github','google')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "provider": { + "name": "provider", + "columns": ["provider", "subject"], + "isUnique": true + }, + "account_id": { + "name": "account_id", + "columns": ["account_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "auth_id_pk": { + "name": "auth_id_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "benchmark": { + "name": "benchmark", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent": { + "name": "agent", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "time_created": { + "name": "time_created", + "columns": ["time_created"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "benchmark_id_pk": { + "name": "benchmark_id_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "billing": { + "name": "billing", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_type": { + "name": "payment_method_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_last4": { + "name": "payment_method_last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "balance": { + "name": "balance", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_usage": { + "name": "monthly_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_monthly_usage_updated": { + "name": "time_monthly_usage_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload": { + "name": "reload", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_trigger": { + "name": "reload_trigger", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_amount": { + "name": "reload_amount", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_error": { + "name": "reload_error", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_reload_error": { + "name": "time_reload_error", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_reload_locked_till": { + "name": "time_reload_locked_till", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "varchar(28)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_coupon_id": { + "name": "subscription_coupon_id", + "type": "varchar(28)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_subscription_booked": { + "name": "time_subscription_booked", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "global_customer_id": { + "name": "global_customer_id", + "columns": ["customer_id"], + "isUnique": true + }, + "global_subscription_id": { + "name": "global_subscription_id", + "columns": ["subscription_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "billing_workspace_id_id_pk": { + "name": "billing_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "payment": { + "name": "payment", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoice_id": { + "name": "invoice_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_id": { + "name": "payment_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_refunded": { + "name": "time_refunded", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enrichment": { + "name": "enrichment", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "payment_workspace_id_id_pk": { + "name": "payment_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "subscription": { + "name": "subscription", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rolling_usage": { + "name": "rolling_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fixed_usage": { + "name": "fixed_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_rolling_updated": { + "name": "time_rolling_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_fixed_updated": { + "name": "time_fixed_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspace_user_id": { + "name": "workspace_user_id", + "columns": ["workspace_id", "user_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "subscription_workspace_id_id_pk": { + "name": "subscription_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "usage": { + "name": "usage", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_5m_tokens": { + "name": "cache_write_5m_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_1h_tokens": { + "name": "cache_write_1h_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost": { + "name": "cost", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_id": { + "name": "key_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enrichment": { + "name": "enrichment", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "usage_time_created": { + "name": "usage_time_created", + "columns": ["workspace_id", "time_created"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_workspace_id_id_pk": { + "name": "usage_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ip_rate_limit": { + "name": "ip_rate_limit", + "columns": { + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "interval": { + "name": "interval", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ip_rate_limit_ip_interval_pk": { + "name": "ip_rate_limit_ip_interval_pk", + "columns": ["ip", "interval"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ip": { + "name": "ip", + "columns": { + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage": { + "name": "usage", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ip_ip_pk": { + "name": "ip_ip_pk", + "columns": ["ip"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "key": { + "name": "key", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_used": { + "name": "time_used", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "global_key": { + "name": "global_key", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "key_workspace_id_id_pk": { + "name": "key_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "model": { + "name": "model", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "model_workspace_model": { + "name": "model_workspace_model", + "columns": ["workspace_id", "model"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "model_workspace_id_id_pk": { + "name": "model_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "provider": { + "name": "provider", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_provider": { + "name": "workspace_provider", + "columns": ["workspace_id", "provider"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "provider_workspace_id_id_pk": { + "name": "provider_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_seen": { + "name": "time_seen", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('admin','member')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_usage": { + "name": "monthly_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_monthly_usage_updated": { + "name": "time_monthly_usage_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_account_id": { + "name": "user_account_id", + "columns": ["workspace_id", "account_id"], + "isUnique": true + }, + "user_email": { + "name": "user_email", + "columns": ["workspace_id", "email"], + "isUnique": true + }, + "global_account_id": { + "name": "global_account_id", + "columns": ["account_id"], + "isUnique": false + }, + "global_email": { + "name": "global_email", + "columns": ["email"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_workspace_id_id_pk": { + "name": "user_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "workspace": { + "name": "workspace", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "slug": { + "name": "slug", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "workspace_id": { + "name": "workspace_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} diff --git a/packages/console/core/migrations/meta/0052_snapshot.json b/packages/console/core/migrations/meta/0052_snapshot.json new file mode 100644 index 0000000000..339e153eba --- /dev/null +++ b/packages/console/core/migrations/meta/0052_snapshot.json @@ -0,0 +1,1235 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "00774acd-a1e5-49c0-b296-cacc9506a566", + "prevId": "14cbf4c8-55f1-4488-956f-56fb5ccb3a5a", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "account_id_pk": { + "name": "account_id_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "auth": { + "name": "auth", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "enum('email','github','google')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "provider": { + "name": "provider", + "columns": ["provider", "subject"], + "isUnique": true + }, + "account_id": { + "name": "account_id", + "columns": ["account_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "auth_id_pk": { + "name": "auth_id_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "benchmark": { + "name": "benchmark", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent": { + "name": "agent", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "time_created": { + "name": "time_created", + "columns": ["time_created"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "benchmark_id_pk": { + "name": "benchmark_id_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "billing": { + "name": "billing", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_type": { + "name": "payment_method_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_last4": { + "name": "payment_method_last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "balance": { + "name": "balance", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_usage": { + "name": "monthly_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_monthly_usage_updated": { + "name": "time_monthly_usage_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload": { + "name": "reload", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_trigger": { + "name": "reload_trigger", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_amount": { + "name": "reload_amount", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_error": { + "name": "reload_error", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_reload_error": { + "name": "time_reload_error", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_reload_locked_till": { + "name": "time_reload_locked_till", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "varchar(28)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_coupon_id": { + "name": "subscription_coupon_id", + "type": "varchar(28)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_plan": { + "name": "subscription_plan", + "type": "enum('20','100','200')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_subscription_booked": { + "name": "time_subscription_booked", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "global_customer_id": { + "name": "global_customer_id", + "columns": ["customer_id"], + "isUnique": true + }, + "global_subscription_id": { + "name": "global_subscription_id", + "columns": ["subscription_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "billing_workspace_id_id_pk": { + "name": "billing_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "payment": { + "name": "payment", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoice_id": { + "name": "invoice_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_id": { + "name": "payment_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_refunded": { + "name": "time_refunded", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enrichment": { + "name": "enrichment", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "payment_workspace_id_id_pk": { + "name": "payment_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "subscription": { + "name": "subscription", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rolling_usage": { + "name": "rolling_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fixed_usage": { + "name": "fixed_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_rolling_updated": { + "name": "time_rolling_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_fixed_updated": { + "name": "time_fixed_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspace_user_id": { + "name": "workspace_user_id", + "columns": ["workspace_id", "user_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "subscription_workspace_id_id_pk": { + "name": "subscription_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "usage": { + "name": "usage", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_5m_tokens": { + "name": "cache_write_5m_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_1h_tokens": { + "name": "cache_write_1h_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost": { + "name": "cost", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_id": { + "name": "key_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enrichment": { + "name": "enrichment", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "usage_time_created": { + "name": "usage_time_created", + "columns": ["workspace_id", "time_created"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_workspace_id_id_pk": { + "name": "usage_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ip_rate_limit": { + "name": "ip_rate_limit", + "columns": { + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "interval": { + "name": "interval", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ip_rate_limit_ip_interval_pk": { + "name": "ip_rate_limit_ip_interval_pk", + "columns": ["ip", "interval"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ip": { + "name": "ip", + "columns": { + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage": { + "name": "usage", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ip_ip_pk": { + "name": "ip_ip_pk", + "columns": ["ip"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "key": { + "name": "key", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_used": { + "name": "time_used", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "global_key": { + "name": "global_key", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "key_workspace_id_id_pk": { + "name": "key_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "model": { + "name": "model", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "model_workspace_model": { + "name": "model_workspace_model", + "columns": ["workspace_id", "model"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "model_workspace_id_id_pk": { + "name": "model_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "provider": { + "name": "provider", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_provider": { + "name": "workspace_provider", + "columns": ["workspace_id", "provider"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "provider_workspace_id_id_pk": { + "name": "provider_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_seen": { + "name": "time_seen", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('admin','member')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_usage": { + "name": "monthly_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_monthly_usage_updated": { + "name": "time_monthly_usage_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_account_id": { + "name": "user_account_id", + "columns": ["workspace_id", "account_id"], + "isUnique": true + }, + "user_email": { + "name": "user_email", + "columns": ["workspace_id", "email"], + "isUnique": true + }, + "global_account_id": { + "name": "global_account_id", + "columns": ["account_id"], + "isUnique": false + }, + "global_email": { + "name": "global_email", + "columns": ["email"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_workspace_id_id_pk": { + "name": "user_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "workspace": { + "name": "workspace", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "slug": { + "name": "slug", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "workspace_id": { + "name": "workspace_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} diff --git a/packages/console/core/migrations/meta/_journal.json b/packages/console/core/migrations/meta/_journal.json index 9982daef5e..cdf4f63906 100644 --- a/packages/console/core/migrations/meta/_journal.json +++ b/packages/console/core/migrations/meta/_journal.json @@ -358,6 +358,20 @@ "when": 1767931290031, "tag": "0050_bumpy_mephistopheles", "breakpoints": true + }, + { + "idx": 51, + "version": "5", + "when": 1768341152722, + "tag": "0051_jazzy_green_goblin", + "breakpoints": true + }, + { + "idx": 52, + "version": "5", + "when": 1768343920467, + "tag": "0052_aromatic_agent_zero", + "breakpoints": true } ] } diff --git a/packages/console/core/package.json b/packages/console/core/package.json index ecfb200079..2668a82f69 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.1.16", + "version": "1.1.21", "private": true, "type": "module", "license": "MIT", @@ -32,6 +32,7 @@ "promote-models-to-dev": "script/promote-models.ts dev", "promote-models-to-prod": "script/promote-models.ts production", "pull-models-from-dev": "script/pull-models.ts dev", + "pull-models-from-prod": "script/pull-models.ts production", "update-black": "script/update-black.ts", "promote-black-to-dev": "script/promote-black.ts dev", "promote-black-to-prod": "script/promote-black.ts production", diff --git a/packages/console/core/script/black-cancel-waitlist.ts b/packages/console/core/script/black-cancel-waitlist.ts new file mode 100644 index 0000000000..ab2aa16d5d --- /dev/null +++ b/packages/console/core/script/black-cancel-waitlist.ts @@ -0,0 +1,41 @@ +import { subscribe } from "diagnostics_channel" +import { Billing } from "../src/billing.js" +import { and, Database, eq } from "../src/drizzle/index.js" +import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js" + +const workspaceID = process.argv[2] + +if (!workspaceID) { + console.error("Usage: bun script/foo.ts ") + process.exit(1) +} + +console.log(`Removing from Black waitlist`) + +const billing = await Database.use((tx) => + tx + .select({ + subscriptionPlan: BillingTable.subscriptionPlan, + timeSubscriptionBooked: BillingTable.timeSubscriptionBooked, + }) + .from(BillingTable) + .where(eq(BillingTable.workspaceID, workspaceID)) + .then((rows) => rows[0]), +) + +if (!billing?.timeSubscriptionBooked) { + console.error(`Error: Workspace is not on the waitlist`) + process.exit(1) +} + +await Database.use((tx) => + tx + .update(BillingTable) + .set({ + subscriptionPlan: null, + timeSubscriptionBooked: null, + }) + .where(eq(BillingTable.workspaceID, workspaceID)), +) + +console.log(`Done`) diff --git a/packages/console/core/script/credit-workspace.ts b/packages/console/core/script/credit-workspace.ts index 29fb1fa648..ad2404f799 100644 --- a/packages/console/core/script/credit-workspace.ts +++ b/packages/console/core/script/credit-workspace.ts @@ -1,4 +1,6 @@ import { Billing } from "../src/billing.js" +import { Database, eq } from "../src/drizzle/index.js" +import { WorkspaceTable } from "../src/schema/workspace.sql.js" // get input from command line const workspaceID = process.argv[2] @@ -9,6 +11,19 @@ if (!workspaceID || !dollarAmount) { process.exit(1) } +// check workspace exists +const workspace = await Database.use((tx) => + tx + .select() + .from(WorkspaceTable) + .where(eq(WorkspaceTable.id, workspaceID)) + .then((rows) => rows[0]), +) +if (!workspace) { + console.error("Error: Workspace not found") + process.exit(1) +} + const amountInDollars = parseFloat(dollarAmount) if (isNaN(amountInDollars) || amountInDollars <= 0) { console.error("Error: dollarAmount must be a positive number") diff --git a/packages/console/core/script/lookup-user.ts b/packages/console/core/script/lookup-user.ts index b06fb5654c..3c97600ec0 100644 --- a/packages/console/core/script/lookup-user.ts +++ b/packages/console/core/script/lookup-user.ts @@ -113,8 +113,13 @@ async function printWorkspace(workspaceID: string) { .select({ balance: BillingTable.balance, customerID: BillingTable.customerID, - subscriptionID: BillingTable.subscriptionID, - subscriptionCouponID: BillingTable.subscriptionCouponID, + reload: BillingTable.reload, + subscription: { + id: BillingTable.subscriptionID, + couponID: BillingTable.subscriptionCouponID, + plan: BillingTable.subscriptionPlan, + booked: BillingTable.timeSubscriptionBooked, + }, }) .from(BillingTable) .where(eq(BillingTable.workspaceID, workspace.id)) @@ -123,6 +128,11 @@ async function printWorkspace(workspaceID: string) { rows.map((row) => ({ ...row, balance: `$${(row.balance / 100000000).toFixed(2)}`, + subscription: row.subscription.id + ? `Subscribed ${row.subscription.couponID ? `(coupon: ${row.subscription.couponID}) ` : ""}` + : row.subscription.booked + ? `Waitlist ${row.subscription.plan} plan` + : undefined, }))[0], ), ) diff --git a/packages/console/core/src/billing.ts b/packages/console/core/src/billing.ts index 181055c4e0..f052e6fc6f 100644 --- a/packages/console/core/src/billing.ts +++ b/packages/console/core/src/billing.ts @@ -25,22 +25,7 @@ export namespace Billing { export const get = async () => { return Database.use(async (tx) => tx - .select({ - customerID: BillingTable.customerID, - subscriptionID: BillingTable.subscriptionID, - paymentMethodID: BillingTable.paymentMethodID, - paymentMethodType: BillingTable.paymentMethodType, - paymentMethodLast4: BillingTable.paymentMethodLast4, - balance: BillingTable.balance, - reload: BillingTable.reload, - reloadAmount: BillingTable.reloadAmount, - reloadTrigger: BillingTable.reloadTrigger, - monthlyLimit: BillingTable.monthlyLimit, - monthlyUsage: BillingTable.monthlyUsage, - timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated, - reloadError: BillingTable.reloadError, - timeReloadError: BillingTable.timeReloadError, - }) + .select() .from(BillingTable) .where(eq(BillingTable.workspaceID, Actor.workspace())) .then((r) => r[0]), diff --git a/packages/console/core/src/black.ts b/packages/console/core/src/black.ts index 0ecbc94a45..753d25808f 100644 --- a/packages/console/core/src/black.ts +++ b/packages/console/core/src/black.ts @@ -1,6 +1,8 @@ import { z } from "zod" import { fn } from "./util/fn" import { Resource } from "@opencode-ai/console-resource" +import { centsToMicroCents } from "./util/price" +import { getWeekBounds } from "./util/date" export namespace BlackData { const Schema = z.object({ @@ -18,3 +20,73 @@ export namespace BlackData { return Schema.parse(json) }) } + +export namespace Black { + export const analyzeRollingUsage = fn( + z.object({ + usage: z.number().int(), + timeUpdated: z.date(), + }), + ({ usage, timeUpdated }) => { + const now = new Date() + const black = BlackData.get() + const rollingWindowMs = black.rollingWindow * 3600 * 1000 + const rollingLimitInMicroCents = centsToMicroCents(black.rollingLimit * 100) + const windowStart = new Date(now.getTime() - rollingWindowMs) + if (timeUpdated < windowStart) { + return { + status: "ok" as const, + resetInSec: black.rollingWindow * 3600, + usagePercent: 0, + } + } + + const windowEnd = new Date(timeUpdated.getTime() + rollingWindowMs) + if (usage < rollingLimitInMicroCents) { + return { + status: "ok" as const, + resetInSec: Math.ceil((windowEnd.getTime() - now.getTime()) / 1000), + usagePercent: Math.ceil(Math.min(100, (usage / rollingLimitInMicroCents) * 100)), + } + } + return { + status: "rate-limited" as const, + resetInSec: Math.ceil((windowEnd.getTime() - now.getTime()) / 1000), + usagePercent: 100, + } + }, + ) + + export const analyzeWeeklyUsage = fn( + z.object({ + usage: z.number().int(), + timeUpdated: z.date(), + }), + ({ usage, timeUpdated }) => { + const black = BlackData.get() + const now = new Date() + const week = getWeekBounds(now) + const fixedLimitInMicroCents = centsToMicroCents(black.fixedLimit * 100) + if (timeUpdated < week.start) { + return { + status: "ok" as const, + resetInSec: Math.ceil((week.end.getTime() - now.getTime()) / 1000), + usagePercent: 0, + } + } + if (usage < fixedLimitInMicroCents) { + return { + status: "ok" as const, + resetInSec: Math.ceil((week.end.getTime() - now.getTime()) / 1000), + usagePercent: Math.ceil(Math.min(100, (usage / fixedLimitInMicroCents) * 100)), + } + } + + return { + status: "rate-limited" as const, + resetInSec: Math.ceil((week.end.getTime() - now.getTime()) / 1000), + usagePercent: 100, + } + }, + ) +} diff --git a/packages/console/core/src/schema/billing.sql.ts b/packages/console/core/src/schema/billing.sql.ts index 6c2cfcf96f..f1300f8498 100644 --- a/packages/console/core/src/schema/billing.sql.ts +++ b/packages/console/core/src/schema/billing.sql.ts @@ -1,4 +1,4 @@ -import { bigint, boolean, index, int, json, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core" +import { bigint, boolean, index, int, json, mysqlEnum, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core" import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types" import { workspaceIndexes } from "./workspace.sql" @@ -23,6 +23,8 @@ export const BillingTable = mysqlTable( timeReloadLockedTill: utc("time_reload_locked_till"), subscriptionID: varchar("subscription_id", { length: 28 }), subscriptionCouponID: varchar("subscription_coupon_id", { length: 28 }), + subscriptionPlan: mysqlEnum("subscription_plan", ["20", "100", "200"] as const), + timeSubscriptionBooked: utc("time_subscription_booked"), }, (table) => [ ...workspaceIndexes(table), diff --git a/packages/console/core/sst-env.d.ts b/packages/console/core/sst-env.d.ts index 96fada3e3c..b8e50a2611 100644 --- a/packages/console/core/sst-env.d.ts +++ b/packages/console/core/sst-env.d.ts @@ -78,6 +78,10 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "STRIPE_PUBLISHABLE_KEY": { + "type": "sst.sst.Secret" + "value": string + } "STRIPE_SECRET_KEY": { "type": "sst.sst.Secret" "value": string diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 8f3b1ddeef..0642aef8f0 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.1.16", + "version": "1.1.21", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/function/sst-env.d.ts b/packages/console/function/sst-env.d.ts index 96fada3e3c..b8e50a2611 100644 --- a/packages/console/function/sst-env.d.ts +++ b/packages/console/function/sst-env.d.ts @@ -78,6 +78,10 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "STRIPE_PUBLISHABLE_KEY": { + "type": "sst.sst.Secret" + "value": string + } "STRIPE_SECRET_KEY": { "type": "sst.sst.Secret" "value": string diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 9572cfde8e..323ad4466e 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.1.16", + "version": "1.1.21", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/console/resource/sst-env.d.ts b/packages/console/resource/sst-env.d.ts index 96fada3e3c..b8e50a2611 100644 --- a/packages/console/resource/sst-env.d.ts +++ b/packages/console/resource/sst-env.d.ts @@ -78,6 +78,10 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "STRIPE_PUBLISHABLE_KEY": { + "type": "sst.sst.Secret" + "value": string + } "STRIPE_SECRET_KEY": { "type": "sst.sst.Secret" "value": string diff --git a/packages/desktop/index.html b/packages/desktop/index.html index 11e9f39a63..d7d439ab8a 100644 --- a/packages/desktop/index.html +++ b/packages/desktop/index.html @@ -17,7 +17,7 @@ -
    +
    diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 5cf2b20dbe..a67fc92107 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.1.16", + "version": "1.1.21", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index 05422b0968..1a58646415 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -41,6 +41,7 @@ semver = "1.0.27" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } uuid = { version = "1.19.0", features = ["v4"] } + [target.'cfg(target_os = "linux")'.dependencies] gtk = "0.18.2" webkit2gtk = "=2.0.1" diff --git a/packages/desktop/src-tauri/capabilities/default.json b/packages/desktop/src-tauri/capabilities/default.json index 1b305aebee..2653f8c280 100644 --- a/packages/desktop/src-tauri/capabilities/default.json +++ b/packages/desktop/src-tauri/capabilities/default.json @@ -7,6 +7,7 @@ "core:default", "opener:default", "core:window:allow-start-dragging", + "core:window:allow-set-theme", "core:webview:allow-set-webview-zoom", "core:window:allow-is-focused", "core:window:allow-show", diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 4bb7b21ec4..3cfa8a9be5 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -14,7 +14,7 @@ use std::{ sync::{Arc, Mutex}, time::{Duration, Instant}, }; -use tauri::{AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl, WebviewWindow}; +use tauri::{AppHandle, LogicalSize, Manager, RunEvent, State, WebviewWindowBuilder}; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult}; use tauri_plugin_shell::process::{CommandChild, CommandEvent}; use tauri_plugin_store::StoreExt; @@ -198,7 +198,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32, password: &str) -> CommandChild { } async fn check_server_health(url: &str, password: Option<&str>) -> bool { - let health_url = format!("{}/health", url.trim_end_matches('/')); + let health_url = format!("{}/global/health", url.trim_end_matches('/')); let client = reqwest::Client::builder() .timeout(Duration::from_secs(3)) .build(); @@ -223,6 +223,11 @@ async fn check_server_health(url: &str, password: Option<&str>) -> bool { pub fn run() { let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some(); + #[cfg(all(target_os = "macos", not(debug_assertions)))] + let _ = std::process::Command::new("killall") + .arg("opencode-cli") + .output(); + let mut builder = tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { // Focus existing window when another instance is launched @@ -232,7 +237,14 @@ pub fn run() { } })) .plugin(tauri_plugin_os::init()) - .plugin(tauri_plugin_window_state::Builder::new().build()) + .plugin( + tauri_plugin_window_state::Builder::new() + .with_state_flags( + tauri_plugin_window_state::StateFlags::all() + - tauri_plugin_window_state::StateFlags::DECORATIONS, + ) + .build(), + ) .plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_shell::init()) @@ -263,29 +275,30 @@ pub fn run() { .map(|m| m.size().to_logical(m.scale_factor())) .unwrap_or(LogicalSize::new(1920, 1080)); - #[allow(unused_mut)] - let mut window_builder = - WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into())) - .title("OpenCode") - .inner_size(size.width as f64, size.height as f64) - .decorations(true) - .zoom_hotkeys_enabled(true) - .disable_drag_drop_handler() - .initialization_script(format!( - r#" + let config = app + .config() + .app + .windows + .iter() + .find(|w| w.label == "main") + .expect("main window config missing"); + + let window_builder = WebviewWindowBuilder::from_config(&app, config) + .expect("Failed to create window builder from config") + .inner_size(size.width as f64, size.height as f64) + .initialization_script(format!( + r#" window.__OPENCODE__ ??= {{}}; window.__OPENCODE__.updaterEnabled = {updater_enabled}; "# - )); + )); #[cfg(target_os = "macos")] - { - window_builder = window_builder - .title_bar_style(tauri::TitleBarStyle::Overlay) - .hidden_title(true); - } + let window_builder = window_builder + .title_bar_style(tauri::TitleBarStyle::Overlay) + .hidden_title(true); - window_builder.build().expect("Failed to create window"); + let _window = window_builder.build().expect("Failed to create window"); let (tx, rx) = oneshot::channel(); app.manage(ServerState::new(None, rx)); diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json index b6737d383a..19da295c56 100644 --- a/packages/desktop/src-tauri/tauri.conf.json +++ b/packages/desktop/src-tauri/tauri.conf.json @@ -11,6 +11,20 @@ "frontendDist": "../dist" }, "app": { + "windows": [ + { + "label": "main", + "create": false, + "title": "OpenCode", + "url": "/", + "decorations": true, + "dragDropEnabled": false, + "zoomHotkeysEnabled": true, + "titleBarStyle": "Overlay", + "hiddenTitle": true, + "trafficLightPosition": { "x": 12.0, "y": 18.0 } + } + ], "withGlobalTauri": true, "security": { "csp": null diff --git a/packages/desktop/src-tauri/tauri.prod.conf.json b/packages/desktop/src-tauri/tauri.prod.conf.json index 7ce4c78420..282db5b26c 100644 --- a/packages/desktop/src-tauri/tauri.prod.conf.json +++ b/packages/desktop/src-tauri/tauri.prod.conf.json @@ -2,6 +2,27 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "OpenCode", "identifier": "ai.opencode.desktop", + "app": { + "windows": [ + { + "label": "main", + "create": false, + "title": "OpenCode", + "url": "/", + "decorations": true, + "dragDropEnabled": false, + "zoomHotkeysEnabled": true, + "titleBarStyle": "Overlay", + "hiddenTitle": true, + "trafficLightPosition": { "x": 12.0, "y": 18.0 } + } + ], + "withGlobalTauri": true, + "security": { + "csp": null + }, + "macOSPrivateApi": true + }, "bundle": { "createUpdaterArtifacts": true, "icon": [ diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 5d699bb90c..7a46ba8cde 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -13,7 +13,7 @@ import { AsyncStorage } from "@solid-primitives/storage" import { fetch as tauriFetch } from "@tauri-apps/plugin-http" import { Store } from "@tauri-apps/plugin-store" import { Logo } from "@opencode-ai/ui/logo" -import { createSignal, Show, Accessor, JSX, createResource } from "solid-js" +import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } from "solid-js" import { UPDATER_ENABLED } from "./updater" import { createMenu } from "./menu" @@ -30,6 +30,11 @@ let update: Update | null = null const createPlatform = (password: Accessor): Platform => ({ platform: "desktop", + os: (() => { + const type = ostype() + if (type === "macos" || type === "windows" || type === "linux") return type + return undefined + })(), version: pkg.version, async openDirectoryPickerDialog(opts) { @@ -296,12 +301,24 @@ render(() => { const [serverPassword, setServerPassword] = createSignal(null) const platform = createPlatform(() => serverPassword()) + function handleClick(e: MouseEvent) { + const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null + if (link?.href) { + e.preventDefault() + platform.openLink(link.href) + } + } + + onMount(() => { + document.addEventListener("click", handleClick) + onCleanup(() => { + document.removeEventListener("click", handleClick) + }) + }) + return ( - {ostype() === "macos" && ( -
    - )} {(data) => { setServerPassword(data().password) diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 7dcdb574d6..19f0c8c6c3 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.1.16", + "version": "1.1.21", "private": true, "type": "module", "license": "MIT", diff --git a/packages/enterprise/sst-env.d.ts b/packages/enterprise/sst-env.d.ts index 96fada3e3c..b8e50a2611 100644 --- a/packages/enterprise/sst-env.d.ts +++ b/packages/enterprise/sst-env.d.ts @@ -78,6 +78,10 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "STRIPE_PUBLISHABLE_KEY": { + "type": "sst.sst.Secret" + "value": string + } "STRIPE_SECRET_KEY": { "type": "sst.sst.Secret" "value": string diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 6ccac0c10b..170e2d224f 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.1.16" +version = "1.1.21" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.16/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.21/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.16/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.21/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.16/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.21/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.16/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.21/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.16/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.21/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index cc8ae0f18c..43d10c0d72 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.1.16", + "version": "1.1.21", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/function/sst-env.d.ts b/packages/function/sst-env.d.ts index 96fada3e3c..b8e50a2611 100644 --- a/packages/function/sst-env.d.ts +++ b/packages/function/sst-env.d.ts @@ -78,6 +78,10 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "STRIPE_PUBLISHABLE_KEY": { + "type": "sst.sst.Secret" + "value": string + } "STRIPE_SECRET_KEY": { "type": "sst.sst.Secret" "value": string diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 07fee7d730..1a3d41e062 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.1.16", + "version": "1.1.21", "name": "opencode", "type": "module", "license": "MIT", @@ -70,6 +70,7 @@ "@ai-sdk/vercel": "1.0.31", "@ai-sdk/xai": "2.0.51", "@clack/prompts": "1.0.0-alpha.1", + "@gitlab/gitlab-ai-provider": "3.1.1", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.25.2", @@ -81,8 +82,8 @@ "@opencode-ai/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", "@openrouter/ai-sdk-provider": "1.5.2", - "@opentui/core": "0.1.72", - "@opentui/solid": "0.1.72", + "@opentui/core": "0.1.73", + "@opentui/solid": "0.1.73", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index ea9d3e3ba1..6487509191 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -13,6 +13,8 @@ import PROMPT_SUMMARY from "./prompt/summary.txt" import PROMPT_TITLE from "./prompt/title.txt" import { PermissionNext } from "@/permission/next" import { mergeDeep, pipe, sortBy, values } from "remeda" +import { Global } from "@/global" +import path from "path" export namespace Agent { export const Info = z @@ -53,6 +55,8 @@ export namespace Agent { [Truncate.GLOB]: "allow", }, question: "deny", + plan_enter: "deny", + plan_exit: "deny", // mirrors github.com/github/gitignore Node.gitignore pattern for .env files read: { "*": "allow", @@ -71,6 +75,7 @@ export namespace Agent { defaults, PermissionNext.fromConfig({ question: "allow", + plan_enter: "allow", }), user, ), @@ -84,9 +89,14 @@ export namespace Agent { defaults, PermissionNext.fromConfig({ question: "allow", + plan_exit: "allow", + external_directory: { + [path.join(Global.Path.data, "plans", "*")]: "allow", + }, edit: { "*": "deny", - ".opencode/plan/*.md": "allow", + [path.join(".opencode", "plans", "*.md")]: "allow", + [path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow", }, }), user, diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index 3dd7bcc35d..bbaecfd8c7 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -338,9 +338,9 @@ export const AuthLoginCommand = cmd({ prompts.log.info( "Amazon Bedrock authentication priority:\n" + " 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" + - " 2. AWS credential chain (profile, access keys, IAM roles)\n\n" + + " 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" + "Configure via opencode.json options (profile, region, endpoint) or\n" + - "AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID).", + "AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).", ) } diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index d8b1bea30b..927c964c9d 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -394,6 +394,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v6 + with: + persist-credentials: false - name: Run opencode uses: anomalyco/opencode/github@latest${envStr} diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index cfb54081f6..cdd741fbc7 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -21,7 +21,7 @@ function getAuthStatusIcon(status: MCP.AuthStatus): string { case "expired": return "⚠" case "not_authenticated": - return "○" + return "✗" } } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index 71a7d22b8f..bcbbe69287 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -5,7 +5,7 @@ import { map, pipe, flatMap, entries, filter, sortBy, take } from "remeda" import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select" import { useDialog } from "@tui/ui/dialog" import { createDialogProviderOptions, DialogProvider } from "./dialog-provider" -import { Keybind } from "@/util/keybind" +import { useKeybind } from "../context/keybind" import * as fuzzysort from "fuzzysort" export function useConnected() { @@ -19,6 +19,7 @@ export function DialogModel(props: { providerID?: string }) { const local = useLocal() const sync = useSync() const dialog = useDialog() + const keybind = useKeybind() const [ref, setRef] = createSignal>() const [query, setQuery] = createSignal("") @@ -207,14 +208,14 @@ export function DialogModel(props: { providerID?: string }) { ) }, }, { - keybind: Keybind.parse("ctrl+f")[0], + keybind: keybind.all.model_favorite_toggle?.[0], title: "Favorite", disabled: !connected(), onTrigger: (option) => { diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index cb33c6301b..4e1171a420 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -26,67 +26,82 @@ export function createDialogProviderOptions() { const sync = useSync() const dialog = useDialog() const sdk = useSDK() + const connected = createMemo(() => new Set(sync.data.provider_next.connected)) const options = createMemo(() => { return pipe( sync.data.provider_next.all, sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99), - map((provider) => ({ - title: provider.name, - value: provider.id, - description: { - opencode: "(Recommended)", - anthropic: "(Claude Max or API key)", - openai: "(ChatGPT Plus/Pro or API key)", - }[provider.id], - category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other", - async onSelect() { - const methods = sync.data.provider_auth[provider.id] ?? [ - { - type: "api", - label: "API key", - }, - ] - let index: number | null = 0 - if (methods.length > 1) { - index = await new Promise((resolve) => { - dialog.replace( - () => ( - ({ - title: x.label, - value: index, - }))} - onSelect={(option) => resolve(option.value)} + map((provider) => { + const isConnected = connected().has(provider.id) + return { + title: provider.name, + value: provider.id, + description: { + opencode: "(Recommended)", + anthropic: "(Claude Max or API key)", + openai: "(ChatGPT Plus/Pro or API key)", + }[provider.id], + category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other", + footer: isConnected ? "Connected" : undefined, + async onSelect() { + const methods = sync.data.provider_auth[provider.id] ?? [ + { + type: "api", + label: "API key", + }, + ] + let index: number | null = 0 + if (methods.length > 1) { + index = await new Promise((resolve) => { + dialog.replace( + () => ( + ({ + title: x.label, + value: index, + }))} + onSelect={(option) => resolve(option.value)} + /> + ), + () => resolve(null), + ) + }) + } + if (index == null) return + const method = methods[index] + if (method.type === "oauth") { + const result = await sdk.client.provider.oauth.authorize({ + providerID: provider.id, + method: index, + }) + if (result.data?.method === "code") { + dialog.replace(() => ( + - ), - () => resolve(null), - ) - }) - } - if (index == null) return - const method = methods[index] - if (method.type === "oauth") { - const result = await sdk.client.provider.oauth.authorize({ - providerID: provider.id, - method: index, - }) - if (result.data?.method === "code") { - dialog.replace(() => ( - - )) + )) + } + if (result.data?.method === "auto") { + dialog.replace(() => ( + + )) + } } - if (result.data?.method === "auto") { - dialog.replace(() => ( - - )) + if (method.type === "api") { + return dialog.replace(() => ) } - } - if (method.type === "api") { - return dialog.replace(() => ) - } - }, - })), + }, + } + }), ) }) return options diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 07de4d4720..85c174c1dc 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -4,7 +4,7 @@ import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" import { createMemo, createSignal, createResource, onMount, Show } from "solid-js" import { Locale } from "@/util/locale" -import { Keybind } from "@/util/keybind" +import { useKeybind } from "../context/keybind" import { useTheme } from "../context/theme" import { useSDK } from "../context/sdk" import { DialogSessionRename } from "./dialog-session-rename" @@ -14,9 +14,10 @@ import "opentui-spinner/solid" export function DialogSessionList() { const dialog = useDialog() - const sync = useSync() - const { theme } = useTheme() const route = useRoute() + const sync = useSync() + const keybind = useKeybind() + const { theme } = useTheme() const sdk = useSDK() const kv = useKV() @@ -29,8 +30,6 @@ export function DialogSessionList() { return result.data ?? [] }) - const deleteKeybind = "ctrl+d" - const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] @@ -52,7 +51,7 @@ export function DialogSessionList() { const status = sync.data.session_status?.[x.id] const isWorking = status?.type === "busy" return { - title: isDeleting ? `Press ${deleteKeybind} again to confirm` : x.title, + title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title, bg: isDeleting ? theme.error : undefined, value: x.id, category, @@ -89,7 +88,7 @@ export function DialogSessionList() { }} keybind={[ { - keybind: Keybind.parse(deleteKeybind)[0], + keybind: keybind.all.session_delete?.[0], title: "delete", onTrigger: async (option) => { if (toDelete() === option.value) { @@ -103,7 +102,7 @@ export function DialogSessionList() { }, }, { - keybind: Keybind.parse("ctrl+r")[0], + keybind: keybind.all.session_rename?.[0], title: "rename", onTrigger: async (option) => { dialog.replace(() => ) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx index 29f2d78dca..e8664f6289 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx @@ -2,8 +2,8 @@ import { useDialog } from "@tui/ui/dialog" import { DialogSelect } from "@tui/ui/dialog-select" import { createMemo, createSignal } from "solid-js" import { Locale } from "@/util/locale" -import { Keybind } from "@/util/keybind" import { useTheme } from "../context/theme" +import { useKeybind } from "../context/keybind" import { usePromptStash, type StashEntry } from "./prompt/stash" function getRelativeTime(timestamp: number): string { @@ -30,6 +30,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { const dialog = useDialog() const stash = usePromptStash() const { theme } = useTheme() + const keybind = useKeybind() const [toDelete, setToDelete] = createSignal() @@ -41,7 +42,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { const isDeleting = toDelete() === index const lineCount = (entry.input.match(/\n/g)?.length ?? 0) + 1 return { - title: isDeleting ? "Press ctrl+d again to confirm" : getStashPreview(entry.input), + title: isDeleting ? `Press ${keybind.print("stash_delete")} again to confirm` : getStashPreview(entry.input), bg: isDeleting ? theme.error : undefined, value: index, description: getRelativeTime(entry.timestamp), @@ -69,7 +70,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { }} keybind={[ { - keybind: Keybind.parse("ctrl+d")[0], + keybind: keybind.all.stash_delete?.[0], title: "delete", onTrigger: (option) => { if (toDelete() === option.value) { diff --git a/packages/opencode/src/cli/cmd/tui/component/logo.tsx b/packages/opencode/src/cli/cmd/tui/component/logo.tsx index d1be06a7f2..771962b75d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/logo.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/logo.tsx @@ -1,24 +1,85 @@ -import { TextAttributes } from "@opentui/core" -import { For } from "solid-js" -import { useTheme } from "@tui/context/theme" +import { TextAttributes, RGBA } from "@opentui/core" +import { For, type JSX } from "solid-js" +import { useTheme, tint } from "@tui/context/theme" -const LOGO_LEFT = [` `, `█▀▀█ █▀▀█ █▀▀█ █▀▀▄`, `█░░█ █░░█ █▀▀▀ █░░█`, `▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀`] +// Shadow markers (rendered chars in parens): +// _ = full shadow cell (space with bg=shadow) +// ^ = letter top, shadow bottom (▀ with fg=letter, bg=shadow) +// ~ = shadow top only (▀ with fg=shadow) +const SHADOW_MARKER = /[_^~]/ -const LOGO_RIGHT = [` ▄ `, `█▀▀▀ █▀▀█ █▀▀█ █▀▀█`, `█░░░ █░░█ █░░█ █▀▀▀`, `▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`] +const LOGO_LEFT = [` `, `█▀▀█ █▀▀█ █▀▀█ █▀▀▄`, `█__█ █__█ █^^^ █__█`, `▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀~~▀`] + +const LOGO_RIGHT = [` ▄ `, `█▀▀▀ █▀▀█ █▀▀█ █▀▀█`, `█___ █__█ █__█ █^^^`, `▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`] export function Logo() { const { theme } = useTheme() + + const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => { + const shadow = tint(theme.background, fg, 0.25) + const attrs = bold ? TextAttributes.BOLD : undefined + const elements: JSX.Element[] = [] + let i = 0 + + while (i < line.length) { + const rest = line.slice(i) + const markerIndex = rest.search(SHADOW_MARKER) + + if (markerIndex === -1) { + elements.push( + + {rest} + , + ) + break + } + + if (markerIndex > 0) { + elements.push( + + {rest.slice(0, markerIndex)} + , + ) + } + + const marker = rest[markerIndex] + switch (marker) { + case "_": + elements.push( + + {" "} + , + ) + break + case "^": + elements.push( + + ▀ + , + ) + break + case "~": + elements.push( + + ▀ + , + ) + break + } + + i += markerIndex + 1 + } + + return elements + } + return ( {(line, index) => ( - - {line} - - - {LOGO_RIGHT[index()]} - + {renderLine(line, theme.textMuted, false)} + {renderLine(LOGO_RIGHT[index()], theme.text, true)} )} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 4f8f979d53..601eb82bc4 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -159,6 +159,26 @@ export function Autocomplete(props: { }) props.setPrompt((draft) => { + if (part.type === "file") { + const existingIndex = draft.parts.findIndex((p) => p.type === "file" && "url" in p && p.url === part.url) + if (existingIndex !== -1) { + const existing = draft.parts[existingIndex] + if ( + part.source?.text && + existing && + "source" in existing && + existing.source && + "text" in existing.source && + existing.source.text + ) { + existing.source.text.start = extmarkStart + existing.source.text.end = extmarkEnd + existing.source.text.value = virtualText + } + return + } + } + if (part.type === "file" && part.source?.text) { part.source.text.start = extmarkStart part.source.text.end = extmarkEnd diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index d5e0a0aa2a..96b9e8ffd5 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -23,6 +23,7 @@ import type { FilePart } from "@opencode-ai/sdk/v2" import { TuiEvent } from "../../event" import { iife } from "@/util/iife" import { Locale } from "@/util/locale" +import { formatDuration } from "@/util/format" import { createColors, createFrames } from "../../ui/spinner.ts" import { useDialog } from "@tui/ui/dialog" import { DialogProvider as DialogProviderConnect } from "../dialog-provider" @@ -563,25 +564,27 @@ export function Prompt(props: PromptProps) { })), }) } else { - sdk.client.session.prompt({ - sessionID, - ...selectedModel, - messageID, - agent: local.agent.current().name, - model: selectedModel, - variant, - parts: [ - { - id: Identifier.ascending("part"), - type: "text", - text: inputText, - }, - ...nonTextParts.map((x) => ({ - id: Identifier.ascending("part"), - ...x, - })), - ], - }) + sdk.client.session + .prompt({ + sessionID, + ...selectedModel, + messageID, + agent: local.agent.current().name, + model: selectedModel, + variant, + parts: [ + { + id: Identifier.ascending("part"), + type: "text", + text: inputText, + }, + ...nonTextParts.map((x) => ({ + id: Identifier.ascending("part"), + ...x, + })), + ], + }) + .catch(() => {}) } history.append({ ...store.prompt, @@ -1035,7 +1038,8 @@ export function Prompt(props: PromptProps) { if (!r) return "" const baseMessage = message() const truncatedHint = isTruncated() ? " (click to expand)" : "" - const retryInfo = ` [retrying ${seconds() > 0 ? `in ${seconds()}s ` : ""}attempt #${r.attempt}]` + const duration = formatDuration(seconds()) + const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]` return baseMessage + truncatedHint + retryInfo } diff --git a/packages/opencode/src/cli/cmd/tui/component/tips.tsx b/packages/opencode/src/cli/cmd/tui/component/tips.tsx index 18d3c2df14..516d7e7e2c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/tips.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/tips.tsx @@ -139,7 +139,7 @@ const TIPS = [ "Press {highlight}Ctrl+X S{/highlight} or {highlight}/status{/highlight} to see system status info", "Enable {highlight}tui.scroll_acceleration{/highlight} for smooth macOS-style scrolling", "Toggle username display in chat via command palette ({highlight}Ctrl+P{/highlight})", - "Run {highlight}docker run -it --rm ghcr.io/sst/opencode{/highlight} for containerized use", + "Run {highlight}docker run -it --rm ghcr.io/anomalyco/opencode{/highlight} for containerized use", "Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models", "Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing", "Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs", diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 6489fc0e1e..127be0dfc0 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -417,6 +417,13 @@ async function getCustomThemes() { return result } +export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA { + const r = base.r + (overlay.r - base.r) * alpha + const g = base.g + (overlay.g - base.g) * alpha + const b = base.b + (overlay.b - base.b) * alpha + return RGBA.fromInts(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)) +} + function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson { const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!) const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!) @@ -428,13 +435,6 @@ function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJs return ansiToRgba(i) } - const tint = (base: RGBA, overlay: RGBA, alpha: number) => { - const r = base.r + (overlay.r - base.r) * alpha - const g = base.g + (overlay.g - base.g) * alpha - const b = base.b + (overlay.b - base.b) * alpha - return RGBA.fromInts(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)) - } - // Generate gray scale based on terminal background const grays = generateGrayScale(bg, isDark) const textMuted = generateMutedTextColor(bg, isDark) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx index d10c49c833..8ace2fff37 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx @@ -25,24 +25,27 @@ export function Footer() { }) onMount(() => { + // Track all timeouts to ensure proper cleanup + const timeouts: ReturnType[] = [] + function tick() { if (connected()) return if (!store.welcome) { setStore("welcome", true) - timeout = setTimeout(() => tick(), 5000) + timeouts.push(setTimeout(() => tick(), 5000)) return } if (store.welcome) { setStore("welcome", false) - timeout = setTimeout(() => tick(), 10_000) + timeouts.push(setTimeout(() => tick(), 10_000)) return } } - let timeout = setTimeout(() => tick(), 10_000) + timeouts.push(setTimeout(() => tick(), 10_000)) onCleanup(() => { - clearTimeout(timeout) + timeouts.forEach(clearTimeout) }) }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 10e340d7f8..d91363954a 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -69,6 +69,7 @@ import { Footer } from "./footer.tsx" import { usePromptRef } from "../../context/prompt" import { useExit } from "../../context/exit" import { Filesystem } from "@/util/filesystem" +import { Global } from "@/global" import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" @@ -195,6 +196,23 @@ export function Session() { } }) + let lastSwitch: string | undefined = undefined + sdk.event.on("message.part.updated", (evt) => { + const part = evt.properties.part + if (part.type !== "tool") return + if (part.sessionID !== route.sessionID) return + if (part.state.status !== "completed") return + if (part.id === lastSwitch) return + + if (part.tool === "plan_exit") { + local.agent.set("build") + lastSwitch = part.id + } else if (part.tool === "plan_enter") { + local.agent.set("plan") + lastSwitch = part.id + } + }) + let scroll: ScrollBoxRenderable let prompt: PromptRef const keybind = useKeybind() @@ -1525,6 +1543,7 @@ function BlockTool(props: { title: string; children: JSX.Element; onClick?: () = function Bash(props: ToolProps) { const { theme } = useTheme() + const sync = useSync() const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? "")) const [expanded, setExpanded] = createSignal(false) const lines = createMemo(() => output().split("\n")) @@ -1534,11 +1553,36 @@ function Bash(props: ToolProps) { return [...lines().slice(0, 10), "…"].join("\n") }) + const workdirDisplay = createMemo(() => { + const workdir = props.input.workdir + if (!workdir || workdir === ".") return undefined + + const base = sync.data.path.directory + if (!base) return undefined + + const absolute = path.resolve(base, workdir) + if (absolute === base) return undefined + + const home = Global.Path.home + if (!home) return absolute + + const match = absolute === home || absolute.startsWith(home + path.sep) + return match ? absolute.replace(home, "~") : absolute + }) + + const title = createMemo(() => { + const desc = props.input.description ?? "Shell" + const wd = workdirDisplay() + if (!wd) return `# ${desc}` + if (desc.includes(wd)) return `# ${desc}` + return `# ${desc} in ${wd}` + }) + return ( setExpanded((prev) => !prev) : undefined} > @@ -1850,10 +1894,10 @@ function Question(props: ToolProps) { - + {(q, i) => ( - + {q.question} {format(props.metadata.answers?.[i()])} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index f5b6badb58..eab2adb100 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -1,6 +1,6 @@ import { createStore } from "solid-js/store" import { createMemo, For, Match, Show, Switch } from "solid-js" -import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid" +import { Portal, useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import type { TextareaRenderable } from "@opentui/core" import { useKeybind } from "../../context/keybind" import { useTheme, selectedForeground } from "../../context/theme" @@ -11,16 +11,28 @@ import { useSync } from "../../context/sync" import { useTextareaKeybindings } from "../../component/textarea-keybindings" import path from "path" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" +import { Keybind } from "@/util/keybind" import { Locale } from "@/util/locale" +import { Global } from "@/global" type PermissionStage = "permission" | "always" | "reject" function normalizePath(input?: string) { if (!input) return "" - if (path.isAbsolute(input)) { - return path.relative(process.cwd(), input) || "." + + const cwd = process.cwd() + const home = Global.Path.home + const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input) + const relative = path.relative(cwd, absolute) + + if (!relative) return "." + if (!relative.startsWith("..")) return relative + + // outside cwd - use ~ or absolute + if (home && (absolute === home || absolute.startsWith(home + path.sep))) { + return absolute.replace(home, "~") } - return input + return absolute } function filetype(input?: string) { @@ -32,7 +44,9 @@ function filetype(input?: string) { } function EditBody(props: { request: PermissionRequest }) { - const { theme, syntax } = useTheme() + const themeState = useTheme() + const theme = themeState.theme + const syntax = themeState.syntax const sync = useSync() const dimensions = useTerminalDimensions() @@ -54,7 +68,7 @@ function EditBody(props: { request: PermissionRequest }) { Edit {normalizePath(filepath())} - + - + ) @@ -172,86 +186,111 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { message: message || undefined, }) }} - onCancel={() => setStore("stage", "permission")} + onCancel={() => { + setStore("stage", "permission") + }} /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - } - options={{ once: "Allow once", always: "Allow always", reject: "Reject" }} - escapeKey="reject" - onSelect={(option) => { - if (option === "always") { - setStore("stage", "always") - return - } - if (option === "reject") { - if (session()?.parentID) { - setStore("stage", "reject") - return + {(() => { + const body = ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {(() => { + const meta = props.request.metadata ?? {} + const parent = typeof meta["parentDir"] === "string" ? meta["parentDir"] : undefined + const filepath = typeof meta["filepath"] === "string" ? meta["filepath"] : undefined + const pattern = props.request.patterns?.[0] + const derived = + typeof pattern === "string" + ? pattern.includes("*") + ? path.dirname(pattern) + : pattern + : undefined + + const raw = parent ?? filepath ?? derived + const dir = normalizePath(raw) + + return + })()} + + + + + + + + } - sdk.client.permission.reply({ - reply: "reject", - requestID: props.request.id, - }) - } - sdk.client.permission.reply({ - reply: "once", - requestID: props.request.id, - }) - }} - /> + options={{ once: "Allow once", always: "Allow always", reject: "Reject" }} + escapeKey="reject" + fullscreen + onSelect={(option) => { + if (option === "always") { + setStore("stage", "always") + return + } + if (option === "reject") { + if (session()?.parentID) { + setStore("stage", "reject") + return + } + sdk.client.permission.reply({ + reply: "reject", + requestID: props.request.id, + }) + } + sdk.client.permission.reply({ + reply: "once", + requestID: props.request.id, + }) + }} + /> + ) + + return body + })()} ) @@ -327,14 +366,18 @@ function Prompt>(props: { body: JSX.Element options: T escapeKey?: keyof T + fullscreen?: boolean onSelect: (option: keyof T) => void }) { const { theme } = useTheme() const keybind = useKeybind() + const dimensions = useTerminalDimensions() const keys = Object.keys(props.options) as (keyof T)[] const [store, setStore] = createStore({ selected: keys[0], + expanded: false, }) + const diffKey = Keybind.parse("ctrl+f")[0] useKeyboard((evt) => { if (evt.name === "left" || evt.name == "h") { @@ -360,17 +403,36 @@ function Prompt>(props: { evt.preventDefault() props.onSelect(props.escapeKey) } + + if (props.fullscreen && diffKey && Keybind.match(diffKey, keybind.parse(evt))) { + evt.preventDefault() + evt.stopPropagation() + setStore("expanded", (v) => !v) + } }) - return ( + const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen")) + const renderer = useRenderer() + + const content = () => ( - - + + {"△"} {props.title} @@ -403,6 +465,11 @@ function Prompt>(props: { + + + {"ctrl+f"} {hint()} + + {"⇆"} select @@ -413,4 +480,10 @@ function Prompt>(props: { ) + + return ( + {content()}}> + {content()} + + ) } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx index ccc0e9b125..049e320cb9 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx @@ -32,7 +32,8 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { const question = createMemo(() => questions()[store.tab]) const confirm = createMemo(() => !single() && store.tab === questions().length) const options = createMemo(() => question()?.options ?? []) - const other = createMemo(() => store.selected === options().length) + const custom = createMemo(() => question()?.custom !== false) + const other = createMemo(() => custom() && store.selected === options().length) const input = createMemo(() => store.custom[store.tab] ?? "") const multi = createMemo(() => question()?.multiple === true) const customPicked = createMemo(() => { @@ -131,6 +132,16 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { setStore("editing", false) return } + if (keybind.match("input_clear", evt)) { + evt.preventDefault() + const text = textarea?.plainText ?? "" + if (!text) { + setStore("editing", false) + return + } + textarea?.setText("") + return + } if (evt.name === "return") { evt.preventDefault() const text = textarea?.plainText?.trim() ?? "" @@ -141,16 +152,11 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { const inputs = [...store.custom] inputs[store.tab] = "" setStore("custom", inputs) - } - const answers = [...store.answers] - if (prev) { + const answers = [...store.answers] answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev) + setStore("answers", answers) } - if (!prev) { - answers[store.tab] = [] - } - setStore("answers", answers) setStore("editing", false) return } @@ -203,7 +209,17 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { } } else { const opts = options() - const total = opts.length + 1 // options + "Other" + const total = opts.length + (custom() ? 1 : 0) + const max = Math.min(total, 9) + const digit = Number(evt.name) + + if (!Number.isNaN(digit) && digit >= 1 && digit <= max) { + evt.preventDefault() + const index = digit - 1 + moveTo(index) + selectOption() + return + } if (evt.name === "up" || evt.name === "k") { evt.preventDefault() @@ -286,11 +302,16 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { - {i() + 1}. {opt.label} + {multi() + ? `${i() + 1}. [${picked() ? "✓" : " "}] ${opt.label}` + : `${i() + 1}. ${opt.label}`} - {picked() ? "✓" : ""} + + {picked() ? "✓" : ""} + + {opt.description} @@ -298,35 +319,46 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { ) }} - moveTo(options().length)} onMouseUp={() => selectOption()}> - - - - {options().length + 1}. Type your own answer - + + moveTo(options().length)} onMouseUp={() => selectOption()}> + + + + {multi() + ? `${options().length + 1}. [${customPicked() ? "✓" : " "}] Type your own answer` + : `${options().length + 1}. Type your own answer`} + + + + {customPicked() ? "✓" : ""} + - {customPicked() ? "✓" : ""} + + +