mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-17 01:48:29 -04:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a0fd15e9e4 | |||
| 4f2514d9f5 | |||
| 5d9801f206 | |||
| a300cd6465 | |||
| 399699469b | |||
| cacf9df24c | |||
| 2844086752 | |||
| 75df5040ea | |||
| f2b2940298 | |||
| 22f51c6b47 | |||
| de286b08f6 | |||
| 0f2124db32 | |||
| e30a159264 | |||
| a5edf3a311 | |||
| 7c2907cbb4 | |||
| bce9dc040f | |||
| 44297ffe78 | |||
| cbb3141130 | |||
| 18cf4df6c6 | |||
| f3e8a275b8 | |||
| 7df36cf0fa | |||
| e82b112759 | |||
| 030b14ac4e | |||
| b1e381cff7 | |||
| 0433d4d064 | |||
| d34fdac854 | |||
| ec828619ca | |||
| 559013e124 | |||
| 8e3ab4afa7 | |||
| 13305966e5 | |||
| a618fbe8cf | |||
| 07dc1f8ecc | |||
| 445c8631a2 | |||
| c4eacd0ccf | |||
| dd5ec26c8c | |||
| 8b062ed621 | |||
| ab97a95032 | |||
| a98d108d2e | |||
| 2e875b2d65 | |||
| 790baec41f | |||
| 52fbd16e08 | |||
| cf97633d7d | |||
| 3fe2e89d55 | |||
| eb5c113cff | |||
| bd9c13bb27 | |||
| 3019b1f825 | |||
| 119cc8c795 | |||
| be9b2bab15 | |||
| c949e5b390 | |||
| 1c717d62e4 | |||
| 27675dfd70 | |||
| 374275eeb6 | |||
| faa848cfb1 | |||
| 80772e5ac2 | |||
| be34747526 |
@@ -0,0 +1,3 @@
|
|||||||
|
### What does this PR do?
|
||||||
|
|
||||||
|
### How did you verify your code works?
|
||||||
@@ -9,6 +9,13 @@ on:
|
|||||||
- "nix/**"
|
- "nix/**"
|
||||||
- "packages/app/**"
|
- "packages/app/**"
|
||||||
- "packages/desktop/**"
|
- "packages/desktop/**"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "flake.nix"
|
||||||
|
- "flake.lock"
|
||||||
|
- "nix/**"
|
||||||
|
- "packages/app/**"
|
||||||
|
- "packages/desktop/**"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ npm i -g opencode-ai@latest # or bun/pnpm/yarn
|
|||||||
scoop bucket add extras; scoop install extras/opencode # Windows
|
scoop bucket add extras; scoop install extras/opencode # Windows
|
||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
|
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
|
||||||
brew install opencode # macOS and Linux (official brew formula, updated less frequently)
|
brew install opencode # macOS and Linux (official brew formula, updated less)
|
||||||
paru -S opencode-bin # Arch Linux
|
paru -S opencode-bin # Arch Linux
|
||||||
mise use -g opencode # Any OS
|
mise use -g opencode # Any OS
|
||||||
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
|
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
|
||||||
|
|||||||
@@ -195,3 +195,4 @@
|
|||||||
| 2026-01-06 | 1,960,988 (+222,817) | 1,377,377 (+24,334) | 3,338,365 (+247,151) |
|
| 2026-01-06 | 1,960,988 (+222,817) | 1,377,377 (+24,334) | 3,338,365 (+247,151) |
|
||||||
| 2026-01-07 | 2,123,239 (+162,251) | 1,398,648 (+21,271) | 3,521,887 (+183,522) |
|
| 2026-01-07 | 2,123,239 (+162,251) | 1,398,648 (+21,271) | 3,521,887 (+183,522) |
|
||||||
| 2026-01-08 | 2,272,630 (+149,391) | 1,432,480 (+33,832) | 3,705,110 (+183,223) |
|
| 2026-01-08 | 2,272,630 (+149,391) | 1,432,480 (+33,832) | 3,705,110 (+183,223) |
|
||||||
|
| 2026-01-09 | 2,443,565 (+170,935) | 1,469,451 (+36,971) | 3,913,016 (+207,906) |
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "2.0.0",
|
"@ai-sdk/anthropic": "2.0.0",
|
||||||
"@ai-sdk/openai": "2.0.2",
|
"@ai-sdk/openai": "2.0.2",
|
||||||
@@ -149,7 +149,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
@@ -173,7 +173,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/app": "workspace:*",
|
"@opencode-ai/app": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
@@ -202,7 +202,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
@@ -231,7 +231,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -247,7 +247,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -286,8 +286,8 @@
|
|||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
"@openrouter/ai-sdk-provider": "1.5.2",
|
"@openrouter/ai-sdk-provider": "1.5.2",
|
||||||
"@opentui/core": "0.1.70",
|
"@opentui/core": "0.1.72",
|
||||||
"@opentui/solid": "0.1.70",
|
"@opentui/solid": "0.1.72",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
@@ -350,7 +350,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
@@ -370,7 +370,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hey-api/openapi-ts": "0.88.1",
|
"@hey-api/openapi-ts": "0.88.1",
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
@@ -381,7 +381,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -394,7 +394,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -433,7 +433,7 @@
|
|||||||
},
|
},
|
||||||
"packages/util": {
|
"packages/util": {
|
||||||
"name": "@opencode-ai/util",
|
"name": "@opencode-ai/util",
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -444,7 +444,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
@@ -1201,21 +1201,21 @@
|
|||||||
|
|
||||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||||
|
|
||||||
"@opentui/core": ["@opentui/core@0.1.70", "", { "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.70", "@opentui/core-darwin-x64": "0.1.70", "@opentui/core-linux-arm64": "0.1.70", "@opentui/core-linux-x64": "0.1.70", "@opentui/core-win32-arm64": "0.1.70", "@opentui/core-win32-x64": "0.1.70", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-6cPAlbCnaiUUtQtvZNpkr0Xv8AdVAgJuy2VAwIsDN1pIv0zMpa0ZG+mr7afCGygw1eeDRveefrjfgFAB1r0SVw=="],
|
"@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-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.70", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rM8EnvW1tOAXWnp2Iy2M82I+ViSmRwUagx3v1/ni6N8GCcw/3mE0C6eB3sVlYNXVMwBEgiKpWFn85RCe4+qXQw=="],
|
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.72", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RoU48kOrhLZYDBiXaDu1LXS2bwRdlJlFle8eUQiqJjLRbMIY34J/srBuL0JnAS3qKW4J34NepUQa0l0/S43Q3w=="],
|
||||||
|
|
||||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.70", "", { "os": "darwin", "cpu": "x64" }, "sha512-XdBgW+em8J+YGSUpaKF8/NxPjikJygK3dIkeMAw5xQ2lt7jXKxeM5MMmN/V4MfK3pLMtO56rLJlXaLH/h50uQA=="],
|
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.72", "", { "os": "darwin", "cpu": "x64" }, "sha512-hHUQw8i2LWPToRW1rjAiRqmNf34iJPS9ve9CJDygvFs5JOqUxN5yrfLfKfE+1bQjfFDHnpqW1HUk96iLhkPj8Q=="],
|
||||||
|
|
||||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.70", "", { "os": "linux", "cpu": "arm64" }, "sha512-oSVWNMSOx0Na0M0LCqtWCxeh4SuLSK5lg8ZwVzsEoimIAxh0snp9nRUo/Qi8yD9BP0DSDmXuM/B3ONtzFaf0dw=="],
|
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.72", "", { "os": "linux", "cpu": "arm64" }, "sha512-63yml0OQ8tVa0JuDF9lBAWiChX6Q+iDO7lKv7c2n0352n/WyPr3iAgq4uSoH49HXuKeAXY/VwHGjvPzjXD/SDA=="],
|
||||||
|
|
||||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.70", "", { "os": "linux", "cpu": "x64" }, "sha512-WUrhukefMghcZ7sAjkxEy50vA6ii0X21xh7m8c4omXyYYfQXyDs25pNExB8cwoCrZEaC8RTlF4lRSNPIXsZKhA=="],
|
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.72", "", { "os": "linux", "cpu": "x64" }, "sha512-51veiQXNLvzDsFzsEvt71uK7WhiRe2DnvlJSGBSe6aRRHHxjCFYHzYi7t6bitJqtDTUj+EaMPbH81oZ6xy7tyg=="],
|
||||||
|
|
||||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.70", "", { "os": "win32", "cpu": "arm64" }, "sha512-p1K2VJXGmZqSV7mR61v7KJpT1Zth7DS99wEtaqqfK68OWt33K2XxLmGO0KD142R2JLfXu32NnRmBHxmVx8IjBA=="],
|
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.72", "", { "os": "win32", "cpu": "arm64" }, "sha512-1Ep6OcaYTy1RlLOln+LNN7DL1iNyLwLjG2M8aO0pVJKFvxeD5P7rdRzY065E4uhkHeJIHuduUqxvUjD0dyuwbw=="],
|
||||||
|
|
||||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.70", "", { "os": "win32", "cpu": "x64" }, "sha512-G6b8te1twMeDhjg1oZa0IcUjhOJZFCSdlQt+q5gu5vVtjCrIwAn9o7m5EwNMPakc31pDWUZ7v0ktgv0Xw1AQVA=="],
|
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.72", "", { "os": "win32", "cpu": "x64" }, "sha512-5QUv91UkOINlkEaPky3kaxmJvshcJMBAX7LZtIroduaKBGpWRA1aogNhPZzp+30WkvgOU7aOtUktAZuFXb9WdQ=="],
|
||||||
|
|
||||||
"@opentui/solid": ["@opentui/solid@0.1.70", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.70", "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-8Cw/w4Of2OJhsFhcp/Wdj8cJRVaGvVsIiUoYiFtyToM01J4en0bg/vnbeZteyuZWeEtA4iz1/rSEQf7Dp+2FIQ=="],
|
"@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=="],
|
||||||
|
|
||||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": "sha256-KjBAaI9Kv6huOmPvUbtyYsMhbScI91w1lOZyXpIWqI0="
|
"nodeModules": "sha256-+QM5BDFxzrm1HY5ealjCm7jIO1t/rpW1q4GGLViPMmA="
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-30
@@ -14,36 +14,7 @@
|
|||||||
<meta property="og:image" content="/social-share.png" />
|
<meta property="og:image" content="/social-share.png" />
|
||||||
<meta property="twitter:image" content="/social-share.png" />
|
<meta property="twitter:image" content="/social-share.png" />
|
||||||
<!-- Theme preload script - applies cached theme to avoid FOUC -->
|
<!-- Theme preload script - applies cached theme to avoid FOUC -->
|
||||||
<script id="oc-theme-preload-script">
|
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
|
||||||
;(function () {
|
|
||||||
var themeId = localStorage.getItem("opencode-theme-id")
|
|
||||||
if (!themeId) return
|
|
||||||
|
|
||||||
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
|
|
||||||
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
|
|
||||||
var mode = isDark ? "dark" : "light"
|
|
||||||
|
|
||||||
document.documentElement.dataset.theme = themeId
|
|
||||||
document.documentElement.dataset.colorScheme = mode
|
|
||||||
|
|
||||||
if (themeId === "oc-1") return
|
|
||||||
|
|
||||||
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
|
|
||||||
if (css) {
|
|
||||||
var style = document.createElement("style")
|
|
||||||
style.id = "oc-theme-preload"
|
|
||||||
style.textContent =
|
|
||||||
":root{color-scheme:" +
|
|
||||||
mode +
|
|
||||||
";--text-mix-blend-mode:" +
|
|
||||||
(isDark ? "plus-lighter" : "multiply") +
|
|
||||||
";" +
|
|
||||||
css +
|
|
||||||
"}"
|
|
||||||
document.head.appendChild(style)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
</script>
|
|
||||||
</head>
|
</head>
|
||||||
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
;(function () {
|
||||||
|
var themeId = localStorage.getItem("opencode-theme-id")
|
||||||
|
if (!themeId) return
|
||||||
|
|
||||||
|
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
|
||||||
|
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
|
||||||
|
var mode = isDark ? "dark" : "light"
|
||||||
|
|
||||||
|
document.documentElement.dataset.theme = themeId
|
||||||
|
document.documentElement.dataset.colorScheme = mode
|
||||||
|
|
||||||
|
if (themeId === "oc-1") return
|
||||||
|
|
||||||
|
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
|
||||||
|
if (css) {
|
||||||
|
var style = document.createElement("style")
|
||||||
|
style.id = "oc-theme-preload"
|
||||||
|
style.textContent =
|
||||||
|
":root{color-scheme:" +
|
||||||
|
mode +
|
||||||
|
";--text-mix-blend-mode:" +
|
||||||
|
(isDark ? "plus-lighter" : "multiply") +
|
||||||
|
";" +
|
||||||
|
css +
|
||||||
|
"}"
|
||||||
|
document.head.appendChild(style)
|
||||||
|
}
|
||||||
|
})()
|
||||||
@@ -38,9 +38,6 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const defaultServerUrl = iife(() => {
|
const defaultServerUrl = iife(() => {
|
||||||
const param = new URLSearchParams(document.location.search).get("url")
|
|
||||||
if (param) return param
|
|
||||||
|
|
||||||
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
||||||
if (window.__OPENCODE__) return `http://127.0.0.1:${window.__OPENCODE__.port}`
|
if (window.__OPENCODE__) return `http://127.0.0.1:${window.__OPENCODE__.port}`
|
||||||
if (import.meta.env.DEV)
|
if (import.meta.env.DEV)
|
||||||
@@ -108,8 +105,7 @@ export function AppInterface() {
|
|||||||
<Route path="/" component={() => <Navigate href="session" />} />
|
<Route path="/" component={() => <Navigate href="session" />} />
|
||||||
<Route
|
<Route
|
||||||
path="/session/:id?"
|
path="/session/:id?"
|
||||||
component={(p) => (
|
component={() => (
|
||||||
<Show when={p.params.id ?? "new"} keyed>
|
|
||||||
<TerminalProvider>
|
<TerminalProvider>
|
||||||
<FileProvider>
|
<FileProvider>
|
||||||
<PromptProvider>
|
<PromptProvider>
|
||||||
@@ -119,7 +115,6 @@ export function AppInterface() {
|
|||||||
</PromptProvider>
|
</PromptProvider>
|
||||||
</FileProvider>
|
</FileProvider>
|
||||||
</TerminalProvider>
|
</TerminalProvider>
|
||||||
</Show>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -7,15 +7,11 @@ import { createMemo, createSignal, For, Show } from "solid-js"
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { type LocalProject, getAvatarColors } from "@/context/layout"
|
import { type LocalProject, getAvatarColors } from "@/context/layout"
|
||||||
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||||
|
|
||||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||||
|
|
||||||
function getFilename(input: string) {
|
|
||||||
const parts = input.split("/")
|
|
||||||
return parts[parts.length - 1] || input
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DialogEditProject(props: { project: LocalProject }) {
|
export function DialogEditProject(props: { project: LocalProject }) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export function DialogSelectFile() {
|
|||||||
const params = useParams()
|
const params = useParams()
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
||||||
|
const view = createMemo(() => layout.view(sessionKey()))
|
||||||
return (
|
return (
|
||||||
<Dialog title="Select file">
|
<Dialog title="Select file">
|
||||||
<List
|
<List
|
||||||
@@ -27,7 +28,7 @@ export function DialogSelectFile() {
|
|||||||
const value = file.tab(path)
|
const value = file.tab(path)
|
||||||
tabs().open(value)
|
tabs().open(value)
|
||||||
file.load(path)
|
file.load(path)
|
||||||
layout.review.open()
|
view().reviewPanel.open()
|
||||||
}
|
}
|
||||||
dialog.close()
|
dialog.close()
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export const ModelSelectorPopover: Component<{
|
|||||||
<Kobalte open={open()} onOpenChange={setOpen} placement="top-start" gutter={8}>
|
<Kobalte open={open()} onOpenChange={setOpen} placement="top-start" gutter={8}>
|
||||||
<Kobalte.Trigger as="div">{props.children}</Kobalte.Trigger>
|
<Kobalte.Trigger as="div">{props.children}</Kobalte.Trigger>
|
||||||
<Kobalte.Portal>
|
<Kobalte.Portal>
|
||||||
<Kobalte.Content class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none">
|
<Kobalte.Content class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden">
|
||||||
<Kobalte.Title class="sr-only">Select model</Kobalte.Title>
|
<Kobalte.Title class="sr-only">Select model</Kobalte.Title>
|
||||||
<ModelList provider={props.provider} onSelect={() => setOpen(false)} class="p-1" />
|
<ModelList provider={props.provider} onSelect={() => setOpen(false)} class="p-1" />
|
||||||
</Kobalte.Content>
|
</Kobalte.Content>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
const variant = createMemo(() => props.variant ?? "button")
|
const variant = createMemo(() => props.variant ?? "button")
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
||||||
|
const view = createMemo(() => layout.view(sessionKey()))
|
||||||
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||||
|
|
||||||
const cost = createMemo(() => {
|
const cost = createMemo(() => {
|
||||||
@@ -48,7 +49,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
|
|
||||||
const openContext = () => {
|
const openContext = () => {
|
||||||
if (!params.id) return
|
if (!params.id) return
|
||||||
layout.review.open()
|
view().reviewPanel.open()
|
||||||
tabs().open("context")
|
tabs().open("context")
|
||||||
tabs().setActive("context")
|
tabs().setActive("context")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export function SessionHeader() {
|
|||||||
})
|
})
|
||||||
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
||||||
const worktrees = createMemo(() => layout.projects.list().map((p) => p.worktree), [], { equals: same })
|
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) {
|
function navigateToProject(directory: string) {
|
||||||
navigate(`/${base64Encode(directory)}`)
|
navigate(`/${base64Encode(directory)}`)
|
||||||
@@ -171,20 +173,24 @@ export function SessionHeader() {
|
|||||||
title="Toggle review"
|
title="Toggle review"
|
||||||
keybind={command.keybind("review.toggle")}
|
keybind={command.keybind("review.toggle")}
|
||||||
>
|
>
|
||||||
<Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="group/review-toggle size-6 p-0"
|
||||||
|
onClick={() => view().reviewPanel.toggle()}
|
||||||
|
>
|
||||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||||
<Icon
|
<Icon
|
||||||
name={layout.review.opened() ? "layout-right" : "layout-left"}
|
name={view().reviewPanel.opened() ? "layout-right" : "layout-left"}
|
||||||
size="small"
|
size="small"
|
||||||
class="group-hover/review-toggle:hidden"
|
class="group-hover/review-toggle:hidden"
|
||||||
/>
|
/>
|
||||||
<Icon
|
<Icon
|
||||||
name={layout.review.opened() ? "layout-right-partial" : "layout-left-partial"}
|
name={view().reviewPanel.opened() ? "layout-right-partial" : "layout-left-partial"}
|
||||||
size="small"
|
size="small"
|
||||||
class="hidden group-hover/review-toggle:inline-block"
|
class="hidden group-hover/review-toggle:inline-block"
|
||||||
/>
|
/>
|
||||||
<Icon
|
<Icon
|
||||||
name={layout.review.opened() ? "layout-right-full" : "layout-left-full"}
|
name={view().reviewPanel.opened() ? "layout-right-full" : "layout-left-full"}
|
||||||
size="small"
|
size="small"
|
||||||
class="hidden group-active/review-toggle:inline-block"
|
class="hidden group-active/review-toggle:inline-block"
|
||||||
/>
|
/>
|
||||||
@@ -197,11 +203,11 @@ export function SessionHeader() {
|
|||||||
title="Toggle terminal"
|
title="Toggle terminal"
|
||||||
keybind={command.keybind("terminal.toggle")}
|
keybind={command.keybind("terminal.toggle")}
|
||||||
>
|
>
|
||||||
<Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
|
<Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={() => view().terminal.toggle()}>
|
||||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||||
<Icon
|
<Icon
|
||||||
size="small"
|
size="small"
|
||||||
name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
|
name={view().terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
|
||||||
class="group-hover/terminal-toggle:hidden"
|
class="group-hover/terminal-toggle:hidden"
|
||||||
/>
|
/>
|
||||||
<Icon
|
<Icon
|
||||||
@@ -211,7 +217,7 @@ export function SessionHeader() {
|
|||||||
/>
|
/>
|
||||||
<Icon
|
<Icon
|
||||||
size="small"
|
size="small"
|
||||||
name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
|
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
|
||||||
class="hidden group-active/terminal-toggle:inline-block"
|
class="hidden group-active/terminal-toggle:inline-block"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
let serializeAddon: SerializeAddon
|
let serializeAddon: SerializeAddon
|
||||||
let fitAddon: FitAddon
|
let fitAddon: FitAddon
|
||||||
let handleResize: () => void
|
let handleResize: () => void
|
||||||
|
let handleTextareaFocus: () => void
|
||||||
|
let handleTextareaBlur: () => void
|
||||||
let reconnect: number | undefined
|
let reconnect: number | undefined
|
||||||
let disposed = false
|
let disposed = false
|
||||||
|
|
||||||
@@ -105,6 +107,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
|
|
||||||
const t = new mod.Terminal({
|
const t = new mod.Terminal({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
|
cursorStyle: "bar",
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: "IBM Plex Mono, monospace",
|
fontFamily: "IBM Plex Mono, monospace",
|
||||||
allowTransparency: true,
|
allowTransparency: true,
|
||||||
@@ -170,6 +173,17 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
|
|
||||||
t.open(container)
|
t.open(container)
|
||||||
container.addEventListener("pointerdown", handlePointerDown)
|
container.addEventListener("pointerdown", handlePointerDown)
|
||||||
|
|
||||||
|
handleTextareaFocus = () => {
|
||||||
|
t.options.cursorBlink = true
|
||||||
|
}
|
||||||
|
handleTextareaBlur = () => {
|
||||||
|
t.options.cursorBlink = false
|
||||||
|
}
|
||||||
|
|
||||||
|
t.textarea?.addEventListener("focus", handleTextareaFocus)
|
||||||
|
t.textarea?.addEventListener("blur", handleTextareaBlur)
|
||||||
|
|
||||||
focusTerminal()
|
focusTerminal()
|
||||||
|
|
||||||
if (local.pty.buffer) {
|
if (local.pty.buffer) {
|
||||||
@@ -242,6 +256,8 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
window.removeEventListener("resize", handleResize)
|
window.removeEventListener("resize", handleResize)
|
||||||
}
|
}
|
||||||
container.removeEventListener("pointerdown", handlePointerDown)
|
container.removeEventListener("pointerdown", handlePointerDown)
|
||||||
|
term?.textarea?.removeEventListener("focus", handleTextareaFocus)
|
||||||
|
term?.textarea?.removeEventListener("blur", handleTextareaBlur)
|
||||||
|
|
||||||
const t = term
|
const t = term
|
||||||
if (serializeAddon && props.onCleanup && t) {
|
if (serializeAddon && props.onCleanup && t) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createEffect, createMemo, onCleanup } from "solid-js"
|
import { createEffect, createMemo, createRoot, onCleanup } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import type { FileContent } from "@opencode-ai/sdk/v2"
|
import type { FileContent } from "@opencode-ai/sdk/v2"
|
||||||
@@ -82,8 +82,106 @@ function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WORKSPACE_KEY = "__workspace__"
|
||||||
|
const MAX_FILE_VIEW_SESSIONS = 20
|
||||||
|
const MAX_VIEW_FILES = 500
|
||||||
|
|
||||||
|
type ViewSession = ReturnType<typeof createViewSession>
|
||||||
|
|
||||||
|
type ViewCacheEntry = {
|
||||||
|
value: ViewSession
|
||||||
|
dispose: VoidFunction
|
||||||
|
}
|
||||||
|
|
||||||
|
function createViewSession(dir: string, id: string | undefined) {
|
||||||
|
const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
|
||||||
|
|
||||||
|
const [view, setView, _, ready] = persisted(
|
||||||
|
Persist.scoped(dir, id, "file-view", [legacyViewKey]),
|
||||||
|
createStore<{
|
||||||
|
file: Record<string, FileViewState>
|
||||||
|
}>({
|
||||||
|
file: {},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const meta = { pruned: false }
|
||||||
|
|
||||||
|
const pruneView = (keep?: string) => {
|
||||||
|
const keys = Object.keys(view.file)
|
||||||
|
if (keys.length <= MAX_VIEW_FILES) return
|
||||||
|
|
||||||
|
const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
|
||||||
|
if (drop.length === 0) return
|
||||||
|
|
||||||
|
setView(
|
||||||
|
produce((draft) => {
|
||||||
|
for (const key of drop) {
|
||||||
|
delete draft.file[key]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready()) return
|
||||||
|
if (meta.pruned) return
|
||||||
|
meta.pruned = true
|
||||||
|
pruneView()
|
||||||
|
})
|
||||||
|
|
||||||
|
const scrollTop = (path: string) => view.file[path]?.scrollTop
|
||||||
|
const scrollLeft = (path: string) => view.file[path]?.scrollLeft
|
||||||
|
const selectedLines = (path: string) => view.file[path]?.selectedLines
|
||||||
|
|
||||||
|
const setScrollTop = (path: string, top: number) => {
|
||||||
|
setView("file", path, (current) => {
|
||||||
|
if (current?.scrollTop === top) return current
|
||||||
|
return {
|
||||||
|
...(current ?? {}),
|
||||||
|
scrollTop: top,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
pruneView(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setScrollLeft = (path: string, left: number) => {
|
||||||
|
setView("file", path, (current) => {
|
||||||
|
if (current?.scrollLeft === left) return current
|
||||||
|
return {
|
||||||
|
...(current ?? {}),
|
||||||
|
scrollLeft: left,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
pruneView(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setSelectedLines = (path: string, range: SelectedLineRange | null) => {
|
||||||
|
const next = range ? normalizeSelectedLines(range) : null
|
||||||
|
setView("file", path, (current) => {
|
||||||
|
if (current?.selectedLines === next) return current
|
||||||
|
return {
|
||||||
|
...(current ?? {}),
|
||||||
|
selectedLines: next,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
pruneView(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready,
|
||||||
|
scrollTop,
|
||||||
|
scrollLeft,
|
||||||
|
selectedLines,
|
||||||
|
setScrollTop,
|
||||||
|
setScrollLeft,
|
||||||
|
setSelectedLines,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||||
name: "File",
|
name: "File",
|
||||||
|
gate: false,
|
||||||
init: () => {
|
init: () => {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
@@ -134,42 +232,45 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
file: {},
|
file: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
const legacyViewKey = createMemo(() => `${params.dir}/file${params.id ? "/" + params.id : ""}.v1`)
|
const viewCache = new Map<string, ViewCacheEntry>()
|
||||||
|
|
||||||
const [view, setView, _, ready] = persisted(
|
const disposeViews = () => {
|
||||||
Persist.scoped(params.dir!, params.id, "file-view", [legacyViewKey()]),
|
for (const entry of viewCache.values()) {
|
||||||
createStore<{
|
entry.dispose()
|
||||||
file: Record<string, FileViewState>
|
|
||||||
}>({
|
|
||||||
file: {},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const MAX_VIEW_FILES = 500
|
|
||||||
const viewMeta = { pruned: false }
|
|
||||||
|
|
||||||
const pruneView = (keep?: string) => {
|
|
||||||
const keys = Object.keys(view.file)
|
|
||||||
if (keys.length <= MAX_VIEW_FILES) return
|
|
||||||
|
|
||||||
const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
|
|
||||||
if (drop.length === 0) return
|
|
||||||
|
|
||||||
setView(
|
|
||||||
produce((draft) => {
|
|
||||||
for (const key of drop) {
|
|
||||||
delete draft.file[key]
|
|
||||||
}
|
}
|
||||||
}),
|
viewCache.clear()
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
const pruneViews = () => {
|
||||||
if (!ready()) return
|
while (viewCache.size > MAX_FILE_VIEW_SESSIONS) {
|
||||||
if (viewMeta.pruned) return
|
const first = viewCache.keys().next().value
|
||||||
viewMeta.pruned = true
|
if (!first) return
|
||||||
pruneView()
|
const entry = viewCache.get(first)
|
||||||
})
|
entry?.dispose()
|
||||||
|
viewCache.delete(first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadView = (dir: string, id: string | undefined) => {
|
||||||
|
const key = `${dir}:${id ?? WORKSPACE_KEY}`
|
||||||
|
const existing = viewCache.get(key)
|
||||||
|
if (existing) {
|
||||||
|
viewCache.delete(key)
|
||||||
|
viewCache.set(key, existing)
|
||||||
|
return existing.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = createRoot((dispose) => ({
|
||||||
|
value: createViewSession(dir, id),
|
||||||
|
dispose,
|
||||||
|
}))
|
||||||
|
|
||||||
|
viewCache.set(key, entry)
|
||||||
|
pruneViews()
|
||||||
|
return entry.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const view = createMemo(() => loadView(params.dir!, params.id))
|
||||||
|
|
||||||
function ensure(path: string) {
|
function ensure(path: string) {
|
||||||
if (!path) return
|
if (!path) return
|
||||||
@@ -246,51 +347,32 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
|
|
||||||
const get = (input: string) => store.file[normalize(input)]
|
const get = (input: string) => store.file[normalize(input)]
|
||||||
|
|
||||||
const scrollTop = (input: string) => view.file[normalize(input)]?.scrollTop
|
const scrollTop = (input: string) => view().scrollTop(normalize(input))
|
||||||
const scrollLeft = (input: string) => view.file[normalize(input)]?.scrollLeft
|
const scrollLeft = (input: string) => view().scrollLeft(normalize(input))
|
||||||
const selectedLines = (input: string) => view.file[normalize(input)]?.selectedLines
|
const selectedLines = (input: string) => view().selectedLines(normalize(input))
|
||||||
|
|
||||||
const setScrollTop = (input: string, top: number) => {
|
const setScrollTop = (input: string, top: number) => {
|
||||||
const path = normalize(input)
|
const path = normalize(input)
|
||||||
setView("file", path, (current) => {
|
view().setScrollTop(path, top)
|
||||||
if (current?.scrollTop === top) return current
|
|
||||||
return {
|
|
||||||
...(current ?? {}),
|
|
||||||
scrollTop: top,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
pruneView(path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const setScrollLeft = (input: string, left: number) => {
|
const setScrollLeft = (input: string, left: number) => {
|
||||||
const path = normalize(input)
|
const path = normalize(input)
|
||||||
setView("file", path, (current) => {
|
view().setScrollLeft(path, left)
|
||||||
if (current?.scrollLeft === left) return current
|
|
||||||
return {
|
|
||||||
...(current ?? {}),
|
|
||||||
scrollLeft: left,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
pruneView(path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
|
const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
|
||||||
const path = normalize(input)
|
const path = normalize(input)
|
||||||
const next = range ? normalizeSelectedLines(range) : null
|
view().setSelectedLines(path, range)
|
||||||
setView("file", path, (current) => {
|
|
||||||
if (current?.selectedLines === next) return current
|
|
||||||
return {
|
|
||||||
...(current ?? {}),
|
|
||||||
selectedLines: next,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
stop()
|
||||||
|
disposeViews()
|
||||||
})
|
})
|
||||||
pruneView(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
onCleanup(() => stop())
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready: () => view().ready(),
|
||||||
normalize,
|
normalize,
|
||||||
tab,
|
tab,
|
||||||
pathFromTab,
|
pathFromTab,
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ type SessionTabs = {
|
|||||||
type SessionView = {
|
type SessionView = {
|
||||||
scroll: Record<string, SessionScroll>
|
scroll: Record<string, SessionScroll>
|
||||||
reviewOpen?: string[]
|
reviewOpen?: string[]
|
||||||
|
terminalOpened?: boolean
|
||||||
|
reviewPanelOpened?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
|
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
|
||||||
@@ -53,11 +55,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
width: 280,
|
width: 280,
|
||||||
},
|
},
|
||||||
terminal: {
|
terminal: {
|
||||||
opened: false,
|
|
||||||
height: 280,
|
height: 280,
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
opened: true,
|
|
||||||
diffStyle: "split" as ReviewDiffStyle,
|
diffStyle: "split" as ReviewDiffStyle,
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
@@ -150,7 +150,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
const current = store.sessionView[sessionKey]
|
const current = store.sessionView[sessionKey]
|
||||||
const keep = meta.active ?? sessionKey
|
const keep = meta.active ?? sessionKey
|
||||||
if (!current) {
|
if (!current) {
|
||||||
setStore("sessionView", sessionKey, { scroll: next })
|
setStore("sessionView", sessionKey, { scroll: next, terminalOpened: false, reviewPanelOpened: true })
|
||||||
prune(keep)
|
prune(keep)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -306,40 +306,20 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
terminal: {
|
terminal: {
|
||||||
opened: createMemo(() => store.terminal.opened),
|
|
||||||
open() {
|
|
||||||
setStore("terminal", "opened", true)
|
|
||||||
},
|
|
||||||
close() {
|
|
||||||
setStore("terminal", "opened", false)
|
|
||||||
},
|
|
||||||
toggle() {
|
|
||||||
setStore("terminal", "opened", (x) => !x)
|
|
||||||
},
|
|
||||||
height: createMemo(() => store.terminal.height),
|
height: createMemo(() => store.terminal.height),
|
||||||
resize(height: number) {
|
resize(height: number) {
|
||||||
setStore("terminal", "height", height)
|
setStore("terminal", "height", height)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
opened: createMemo(() => store.review?.opened ?? true),
|
|
||||||
diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
|
diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
|
||||||
setDiffStyle(diffStyle: ReviewDiffStyle) {
|
setDiffStyle(diffStyle: ReviewDiffStyle) {
|
||||||
if (!store.review) {
|
if (!store.review) {
|
||||||
setStore("review", { opened: true, diffStyle })
|
setStore("review", { diffStyle })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setStore("review", "diffStyle", diffStyle)
|
setStore("review", "diffStyle", diffStyle)
|
||||||
},
|
},
|
||||||
open() {
|
|
||||||
setStore("review", "opened", true)
|
|
||||||
},
|
|
||||||
close() {
|
|
||||||
setStore("review", "opened", false)
|
|
||||||
},
|
|
||||||
toggle() {
|
|
||||||
setStore("review", "opened", (x) => !x)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
width: createMemo(() => store.session?.width ?? 600),
|
width: createMemo(() => store.session?.width ?? 600),
|
||||||
@@ -367,6 +347,33 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
touch(sessionKey)
|
touch(sessionKey)
|
||||||
scroll.seed(sessionKey)
|
scroll.seed(sessionKey)
|
||||||
const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} })
|
const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} })
|
||||||
|
const terminalOpened = createMemo(() => s().terminalOpened ?? false)
|
||||||
|
const reviewPanelOpened = createMemo(() => s().reviewPanelOpened ?? true)
|
||||||
|
|
||||||
|
function setTerminalOpened(next: boolean) {
|
||||||
|
const current = store.sessionView[sessionKey]
|
||||||
|
if (!current) {
|
||||||
|
setStore("sessionView", sessionKey, { scroll: {}, terminalOpened: next, reviewPanelOpened: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = current.terminalOpened ?? false
|
||||||
|
if (value === next) return
|
||||||
|
setStore("sessionView", sessionKey, "terminalOpened", next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setReviewPanelOpened(next: boolean) {
|
||||||
|
const current = store.sessionView[sessionKey]
|
||||||
|
if (!current) {
|
||||||
|
setStore("sessionView", sessionKey, { scroll: {}, terminalOpened: false, reviewPanelOpened: next })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = current.reviewPanelOpened ?? true
|
||||||
|
if (value === next) return
|
||||||
|
setStore("sessionView", sessionKey, "reviewPanelOpened", next)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scroll(tab: string) {
|
scroll(tab: string) {
|
||||||
return scroll.scroll(sessionKey, tab)
|
return scroll.scroll(sessionKey, tab)
|
||||||
@@ -374,12 +381,41 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
setScroll(tab: string, pos: SessionScroll) {
|
setScroll(tab: string, pos: SessionScroll) {
|
||||||
scroll.setScroll(sessionKey, tab, pos)
|
scroll.setScroll(sessionKey, tab, pos)
|
||||||
},
|
},
|
||||||
|
terminal: {
|
||||||
|
opened: terminalOpened,
|
||||||
|
open() {
|
||||||
|
setTerminalOpened(true)
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
setTerminalOpened(false)
|
||||||
|
},
|
||||||
|
toggle() {
|
||||||
|
setTerminalOpened(!terminalOpened())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reviewPanel: {
|
||||||
|
opened: reviewPanelOpened,
|
||||||
|
open() {
|
||||||
|
setReviewPanelOpened(true)
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
setReviewPanelOpened(false)
|
||||||
|
},
|
||||||
|
toggle() {
|
||||||
|
setReviewPanelOpened(!reviewPanelOpened())
|
||||||
|
},
|
||||||
|
},
|
||||||
review: {
|
review: {
|
||||||
open: createMemo(() => s().reviewOpen),
|
open: createMemo(() => s().reviewOpen),
|
||||||
setOpen(open: string[]) {
|
setOpen(open: string[]) {
|
||||||
const current = store.sessionView[sessionKey]
|
const current = store.sessionView[sessionKey]
|
||||||
if (!current) {
|
if (!current) {
|
||||||
setStore("sessionView", sessionKey, { scroll: {}, reviewOpen: open })
|
setStore("sessionView", sessionKey, {
|
||||||
|
scroll: {},
|
||||||
|
terminalOpened: false,
|
||||||
|
reviewPanelOpened: true,
|
||||||
|
reviewOpen: open,
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { batch, createMemo } from "solid-js"
|
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import type { FileSelection } from "@/context/file"
|
import type { FileSelection } from "@/context/file"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
@@ -99,14 +99,21 @@ function clonePrompt(prompt: Prompt): Prompt {
|
|||||||
return prompt.map(clonePart)
|
return prompt.map(clonePart)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
|
const WORKSPACE_KEY = "__workspace__"
|
||||||
name: "Prompt",
|
const MAX_PROMPT_SESSIONS = 20
|
||||||
init: () => {
|
|
||||||
const params = useParams()
|
type PromptSession = ReturnType<typeof createPromptSession>
|
||||||
const legacy = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v2`)
|
|
||||||
|
type PromptCacheEntry = {
|
||||||
|
value: PromptSession
|
||||||
|
dispose: VoidFunction
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPromptSession(dir: string, id: string | undefined) {
|
||||||
|
const legacy = `${dir}/prompt${id ? "/" + id : ""}.v2`
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
Persist.scoped(params.dir!, params.id, "prompt", [legacy()]),
|
Persist.scoped(dir, id, "prompt", [legacy]),
|
||||||
createStore<{
|
createStore<{
|
||||||
prompt: Prompt
|
prompt: Prompt
|
||||||
cursor?: number
|
cursor?: number
|
||||||
@@ -168,5 +175,70 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
|
||||||
|
name: "Prompt",
|
||||||
|
gate: false,
|
||||||
|
init: () => {
|
||||||
|
const params = useParams()
|
||||||
|
const cache = new Map<string, PromptCacheEntry>()
|
||||||
|
|
||||||
|
const disposeAll = () => {
|
||||||
|
for (const entry of cache.values()) {
|
||||||
|
entry.dispose()
|
||||||
|
}
|
||||||
|
cache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
onCleanup(disposeAll)
|
||||||
|
|
||||||
|
const prune = () => {
|
||||||
|
while (cache.size > MAX_PROMPT_SESSIONS) {
|
||||||
|
const first = cache.keys().next().value
|
||||||
|
if (!first) return
|
||||||
|
const entry = cache.get(first)
|
||||||
|
entry?.dispose()
|
||||||
|
cache.delete(first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const load = (dir: string, id: string | undefined) => {
|
||||||
|
const key = `${dir}:${id ?? WORKSPACE_KEY}`
|
||||||
|
const existing = cache.get(key)
|
||||||
|
if (existing) {
|
||||||
|
cache.delete(key)
|
||||||
|
cache.set(key, existing)
|
||||||
|
return existing.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = createRoot((dispose) => ({
|
||||||
|
value: createPromptSession(dir, id),
|
||||||
|
dispose,
|
||||||
|
}))
|
||||||
|
|
||||||
|
cache.set(key, entry)
|
||||||
|
prune()
|
||||||
|
return entry.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = createMemo(() => load(params.dir!, params.id))
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready: () => session().ready(),
|
||||||
|
current: () => session().current(),
|
||||||
|
cursor: () => session().cursor(),
|
||||||
|
dirty: () => session().dirty(),
|
||||||
|
context: {
|
||||||
|
activeTab: () => session().context.activeTab(),
|
||||||
|
items: () => session().context.items(),
|
||||||
|
addActive: () => session().context.addActive(),
|
||||||
|
removeActive: () => session().context.removeActive(),
|
||||||
|
add: (item: ContextItem) => session().context.add(item),
|
||||||
|
remove: (key: string) => session().context.remove(key),
|
||||||
|
},
|
||||||
|
set: (prompt: Prompt, cursorPosition?: number) => session().set(prompt, cursorPosition),
|
||||||
|
reset: () => session().reset(),
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { batch, createMemo } from "solid-js"
|
import { batch, createMemo } from "solid-js"
|
||||||
import { produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
import { retry } from "@opencode-ai/util/retry"
|
import { retry } from "@opencode-ai/util/retry"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
@@ -14,6 +14,76 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const [store, setStore] = globalSync.child(sdk.directory)
|
const [store, setStore] = globalSync.child(sdk.directory)
|
||||||
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
|
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
|
||||||
|
const chunk = 200
|
||||||
|
const inflight = new Map<string, Promise<void>>()
|
||||||
|
const inflightDiff = new Map<string, Promise<void>>()
|
||||||
|
const inflightTodo = new Map<string, Promise<void>>()
|
||||||
|
const [meta, setMeta] = createStore({
|
||||||
|
limit: {} as Record<string, number>,
|
||||||
|
complete: {} as Record<string, boolean>,
|
||||||
|
loading: {} as Record<string, boolean>,
|
||||||
|
})
|
||||||
|
|
||||||
|
const getSession = (sessionID: string) => {
|
||||||
|
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
||||||
|
if (match.found) return store.session[match.index]
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitFor = (count: number) => {
|
||||||
|
if (count <= chunk) return chunk
|
||||||
|
return Math.ceil(count / chunk) * chunk
|
||||||
|
}
|
||||||
|
|
||||||
|
const hydrateMessages = (sessionID: string) => {
|
||||||
|
if (meta.limit[sessionID] !== undefined) return
|
||||||
|
|
||||||
|
const messages = store.message[sessionID]
|
||||||
|
if (!messages) return
|
||||||
|
|
||||||
|
const limit = limitFor(messages.length)
|
||||||
|
setMeta("limit", sessionID, limit)
|
||||||
|
setMeta("complete", sessionID, messages.length < limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadMessages = async (sessionID: string, limit: number) => {
|
||||||
|
if (meta.loading[sessionID]) return
|
||||||
|
|
||||||
|
setMeta("loading", sessionID, true)
|
||||||
|
await retry(() => sdk.client.session.messages({ sessionID, limit }))
|
||||||
|
.then((messages) => {
|
||||||
|
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
||||||
|
const next = items
|
||||||
|
.map((x) => x.info)
|
||||||
|
.filter((m) => !!m?.id)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id))
|
||||||
|
|
||||||
|
batch(() => {
|
||||||
|
setStore("message", sessionID, reconcile(next, { key: "id" }))
|
||||||
|
|
||||||
|
for (const message of items) {
|
||||||
|
setStore(
|
||||||
|
"part",
|
||||||
|
message.info.id,
|
||||||
|
reconcile(
|
||||||
|
message.parts
|
||||||
|
.filter((p) => !!p?.id)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||||
|
{ key: "id" },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
setMeta("limit", sessionID, limit)
|
||||||
|
setMeta("complete", sessionID, next.length < limit)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setMeta("loading", sessionID, false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: store,
|
data: store,
|
||||||
@@ -30,11 +100,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
return undefined
|
return undefined
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
get(sessionID: string) {
|
get: getSession,
|
||||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
|
||||||
if (match.found) return store.session[match.index]
|
|
||||||
return undefined
|
|
||||||
},
|
|
||||||
addOptimisticMessage(input: {
|
addOptimisticMessage(input: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
messageID: string
|
messageID: string
|
||||||
@@ -66,58 +132,98 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
async sync(sessionID: string, _isRetry = false) {
|
async sync(sessionID: string) {
|
||||||
const [session, messages, todo, diff] = await Promise.all([
|
const hasSession = getSession(sessionID) !== undefined
|
||||||
retry(() => sdk.client.session.get({ sessionID })),
|
hydrateMessages(sessionID)
|
||||||
retry(() => sdk.client.session.messages({ sessionID, limit: 1000 })),
|
|
||||||
retry(() => sdk.client.session.todo({ sessionID })),
|
|
||||||
retry(() => sdk.client.session.diff({ sessionID })),
|
|
||||||
])
|
|
||||||
|
|
||||||
batch(() => {
|
const hasMessages = store.message[sessionID] !== undefined
|
||||||
|
if (hasSession && hasMessages) return
|
||||||
|
|
||||||
|
const pending = inflight.get(sessionID)
|
||||||
|
if (pending) return pending
|
||||||
|
|
||||||
|
const limit = meta.limit[sessionID] ?? chunk
|
||||||
|
|
||||||
|
const sessionReq = hasSession
|
||||||
|
? Promise.resolve()
|
||||||
|
: retry(() => sdk.client.session.get({ sessionID })).then((session) => {
|
||||||
|
const data = session.data
|
||||||
|
if (!data) return
|
||||||
setStore(
|
setStore(
|
||||||
"session",
|
"session",
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
const match = Binary.search(draft, sessionID, (s) => s.id)
|
const match = Binary.search(draft, sessionID, (s) => s.id)
|
||||||
if (match.found) {
|
if (match.found) {
|
||||||
draft[match.index] = session.data!
|
draft[match.index] = data
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
draft.splice(match.index, 0, session.data!)
|
draft.splice(match.index, 0, data)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
})
|
||||||
|
|
||||||
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
|
const messagesReq = hasMessages ? Promise.resolve() : loadMessages(sessionID, limit)
|
||||||
setStore(
|
|
||||||
"message",
|
|
||||||
sessionID,
|
|
||||||
reconcile(
|
|
||||||
(messages.data ?? [])
|
|
||||||
.map((x) => x.info)
|
|
||||||
.filter((m) => !!m?.id)
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
|
||||||
{ key: "id" },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const message of messages.data ?? []) {
|
const promise = Promise.all([sessionReq, messagesReq])
|
||||||
if (!message?.info?.id) continue
|
.then(() => {})
|
||||||
setStore(
|
.finally(() => {
|
||||||
"part",
|
inflight.delete(sessionID)
|
||||||
message.info.id,
|
})
|
||||||
reconcile(
|
|
||||||
message.parts
|
|
||||||
.filter((p) => !!p?.id)
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
|
||||||
{ key: "id" },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
inflight.set(sessionID, promise)
|
||||||
|
return promise
|
||||||
|
},
|
||||||
|
async diff(sessionID: string) {
|
||||||
|
if (store.session_diff[sessionID] !== undefined) return
|
||||||
|
|
||||||
|
const pending = inflightDiff.get(sessionID)
|
||||||
|
if (pending) return pending
|
||||||
|
|
||||||
|
const promise = retry(() => sdk.client.session.diff({ sessionID }))
|
||||||
|
.then((diff) => {
|
||||||
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
|
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
|
||||||
})
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inflightDiff.delete(sessionID)
|
||||||
|
})
|
||||||
|
|
||||||
|
inflightDiff.set(sessionID, promise)
|
||||||
|
return promise
|
||||||
|
},
|
||||||
|
async todo(sessionID: string) {
|
||||||
|
if (store.todo[sessionID] !== undefined) return
|
||||||
|
|
||||||
|
const pending = inflightTodo.get(sessionID)
|
||||||
|
if (pending) return pending
|
||||||
|
|
||||||
|
const promise = retry(() => sdk.client.session.todo({ sessionID }))
|
||||||
|
.then((todo) => {
|
||||||
|
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inflightTodo.delete(sessionID)
|
||||||
|
})
|
||||||
|
|
||||||
|
inflightTodo.set(sessionID, promise)
|
||||||
|
return promise
|
||||||
|
},
|
||||||
|
history: {
|
||||||
|
more(sessionID: string) {
|
||||||
|
if (store.message[sessionID] === undefined) return false
|
||||||
|
if (meta.limit[sessionID] === undefined) return false
|
||||||
|
if (meta.complete[sessionID]) return false
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
loading(sessionID: string) {
|
||||||
|
return meta.loading[sessionID] ?? false
|
||||||
|
},
|
||||||
|
async loadMore(sessionID: string, count = chunk) {
|
||||||
|
if (meta.loading[sessionID]) return
|
||||||
|
if (meta.complete[sessionID]) return
|
||||||
|
|
||||||
|
const current = meta.limit[sessionID] ?? chunk
|
||||||
|
await loadMessages(sessionID, current + count)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
fetch: async (count = 10) => {
|
fetch: async (count = 10) => {
|
||||||
setStore("limit", (x) => x + count)
|
setStore("limit", (x) => x + count)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { batch, createMemo } from "solid-js"
|
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
@@ -14,15 +14,21 @@ export type LocalPTY = {
|
|||||||
scrollY?: number
|
scrollY?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({
|
const WORKSPACE_KEY = "__workspace__"
|
||||||
name: "Terminal",
|
const MAX_TERMINAL_SESSIONS = 20
|
||||||
init: () => {
|
|
||||||
const sdk = useSDK()
|
type TerminalSession = ReturnType<typeof createTerminalSession>
|
||||||
const params = useParams()
|
|
||||||
const legacy = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`)
|
type TerminalCacheEntry = {
|
||||||
|
value: TerminalSession
|
||||||
|
dispose: VoidFunction
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id: string | undefined) {
|
||||||
|
const legacy = `${dir}/terminal${id ? "/" + id : ""}.v1`
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
Persist.scoped(params.dir!, params.id, "terminal", [legacy()]),
|
Persist.scoped(dir, id, "terminal", [legacy]),
|
||||||
createStore<{
|
createStore<{
|
||||||
active?: string
|
active?: string
|
||||||
all: LocalPTY[]
|
all: LocalPTY[]
|
||||||
@@ -117,5 +123,66 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({
|
||||||
|
name: "Terminal",
|
||||||
|
gate: false,
|
||||||
|
init: () => {
|
||||||
|
const sdk = useSDK()
|
||||||
|
const params = useParams()
|
||||||
|
const cache = new Map<string, TerminalCacheEntry>()
|
||||||
|
|
||||||
|
const disposeAll = () => {
|
||||||
|
for (const entry of cache.values()) {
|
||||||
|
entry.dispose()
|
||||||
|
}
|
||||||
|
cache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
onCleanup(disposeAll)
|
||||||
|
|
||||||
|
const prune = () => {
|
||||||
|
while (cache.size > MAX_TERMINAL_SESSIONS) {
|
||||||
|
const first = cache.keys().next().value
|
||||||
|
if (!first) return
|
||||||
|
const entry = cache.get(first)
|
||||||
|
entry?.dispose()
|
||||||
|
cache.delete(first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const load = (dir: string, id: string | undefined) => {
|
||||||
|
const key = `${dir}:${id ?? WORKSPACE_KEY}`
|
||||||
|
const existing = cache.get(key)
|
||||||
|
if (existing) {
|
||||||
|
cache.delete(key)
|
||||||
|
cache.set(key, existing)
|
||||||
|
return existing.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = createRoot((dispose) => ({
|
||||||
|
value: createTerminalSession(sdk, dir, id),
|
||||||
|
dispose,
|
||||||
|
}))
|
||||||
|
|
||||||
|
cache.set(key, entry)
|
||||||
|
prune()
|
||||||
|
return entry.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = createMemo(() => load(params.dir!, params.id))
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready: () => session().ready(),
|
||||||
|
all: () => session().all(),
|
||||||
|
active: () => session().active(),
|
||||||
|
new: () => session().new(),
|
||||||
|
update: (pty: Partial<LocalPTY> & { id: string }) => session().update(pty),
|
||||||
|
clone: (id: string) => session().clone(id),
|
||||||
|
open: (id: string) => session().open(id),
|
||||||
|
close: (id: string) => session().close(id),
|
||||||
|
move: (id: string, to: number) => session().move(id, to),
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
batch,
|
||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
createSignal,
|
createSignal,
|
||||||
@@ -31,7 +32,7 @@ import { getFilename } from "@opencode-ai/util/path"
|
|||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
import { Session } from "@opencode-ai/sdk/v2/client"
|
import { Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import {
|
import {
|
||||||
DragDropProvider,
|
DragDropProvider,
|
||||||
DragDropSensors,
|
DragDropSensors,
|
||||||
@@ -47,6 +48,7 @@ import { useGlobalSDK } from "@/context/global-sdk"
|
|||||||
import { useNotification } from "@/context/notification"
|
import { useNotification } from "@/context/notification"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
|
import { retry } from "@opencode-ai/util/retry"
|
||||||
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
||||||
@@ -55,6 +57,7 @@ import { DialogEditProject } from "@/components/dialog-edit-project"
|
|||||||
import { DialogSelectServer } from "@/components/dialog-select-server"
|
import { DialogSelectServer } from "@/components/dialog-select-server"
|
||||||
import { useCommand, type CommandOption } from "@/context/command"
|
import { useCommand, type CommandOption } from "@/context/command"
|
||||||
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
||||||
|
import { navStart } from "@/utils/perf"
|
||||||
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
||||||
import { useServer } from "@/context/server"
|
import { useServer } from "@/context/server"
|
||||||
|
|
||||||
@@ -284,6 +287,146 @@ export default function Layout(props: ParentProps) {
|
|||||||
|
|
||||||
const currentSessions = createMemo(() => projectSessions(currentProject()))
|
const currentSessions = createMemo(() => projectSessions(currentProject()))
|
||||||
|
|
||||||
|
type PrefetchQueue = {
|
||||||
|
inflight: Set<string>
|
||||||
|
pending: string[]
|
||||||
|
pendingSet: Set<string>
|
||||||
|
running: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefetchChunk = 200
|
||||||
|
const prefetchConcurrency = 1
|
||||||
|
const prefetchPendingLimit = 6
|
||||||
|
const prefetchToken = { value: 0 }
|
||||||
|
const prefetchQueues = new Map<string, PrefetchQueue>()
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
params.dir
|
||||||
|
globalSDK.url
|
||||||
|
|
||||||
|
prefetchToken.value += 1
|
||||||
|
for (const q of prefetchQueues.values()) {
|
||||||
|
q.pending.length = 0
|
||||||
|
q.pendingSet.clear()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const queueFor = (directory: string) => {
|
||||||
|
const existing = prefetchQueues.get(directory)
|
||||||
|
if (existing) return existing
|
||||||
|
|
||||||
|
const created: PrefetchQueue = {
|
||||||
|
inflight: new Set(),
|
||||||
|
pending: [],
|
||||||
|
pendingSet: new Set(),
|
||||||
|
running: 0,
|
||||||
|
}
|
||||||
|
prefetchQueues.set(directory, created)
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefetchMessages = (directory: string, sessionID: string, token: number) => {
|
||||||
|
const [, setStore] = globalSync.child(directory)
|
||||||
|
|
||||||
|
return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
|
||||||
|
.then((messages) => {
|
||||||
|
if (prefetchToken.value !== token) return
|
||||||
|
|
||||||
|
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
||||||
|
const next = items
|
||||||
|
.map((x) => x.info)
|
||||||
|
.filter((m) => !!m?.id)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id))
|
||||||
|
|
||||||
|
batch(() => {
|
||||||
|
setStore("message", sessionID, reconcile(next, { key: "id" }))
|
||||||
|
|
||||||
|
for (const message of items) {
|
||||||
|
setStore(
|
||||||
|
"part",
|
||||||
|
message.info.id,
|
||||||
|
reconcile(
|
||||||
|
message.parts
|
||||||
|
.filter((p) => !!p?.id)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||||
|
{ key: "id" },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pumpPrefetch = (directory: string) => {
|
||||||
|
const q = queueFor(directory)
|
||||||
|
if (q.running >= prefetchConcurrency) return
|
||||||
|
|
||||||
|
const sessionID = q.pending.shift()
|
||||||
|
if (!sessionID) return
|
||||||
|
|
||||||
|
q.pendingSet.delete(sessionID)
|
||||||
|
q.inflight.add(sessionID)
|
||||||
|
q.running += 1
|
||||||
|
|
||||||
|
const token = prefetchToken.value
|
||||||
|
|
||||||
|
void prefetchMessages(directory, sessionID, token).finally(() => {
|
||||||
|
q.running -= 1
|
||||||
|
q.inflight.delete(sessionID)
|
||||||
|
pumpPrefetch(directory)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefetchSession = (session: Session, priority: "high" | "low" = "low") => {
|
||||||
|
const directory = session.directory
|
||||||
|
if (!directory) return
|
||||||
|
|
||||||
|
const [store] = globalSync.child(directory)
|
||||||
|
if (store.message[session.id] !== undefined) return
|
||||||
|
|
||||||
|
const q = queueFor(directory)
|
||||||
|
if (q.inflight.has(session.id)) return
|
||||||
|
if (q.pendingSet.has(session.id)) return
|
||||||
|
|
||||||
|
if (priority === "high") q.pending.unshift(session.id)
|
||||||
|
if (priority !== "high") q.pending.push(session.id)
|
||||||
|
q.pendingSet.add(session.id)
|
||||||
|
|
||||||
|
while (q.pending.length > prefetchPendingLimit) {
|
||||||
|
const dropped = q.pending.pop()
|
||||||
|
if (!dropped) continue
|
||||||
|
q.pendingSet.delete(dropped)
|
||||||
|
}
|
||||||
|
|
||||||
|
pumpPrefetch(directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const sessions = currentSessions()
|
||||||
|
const id = params.id
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
const first = sessions[0]
|
||||||
|
if (first) prefetchSession(first)
|
||||||
|
|
||||||
|
const second = sessions[1]
|
||||||
|
if (second) prefetchSession(second)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = sessions.findIndex((s) => s.id === id)
|
||||||
|
if (index === -1) return
|
||||||
|
|
||||||
|
const next = sessions[index + 1]
|
||||||
|
if (next) prefetchSession(next)
|
||||||
|
|
||||||
|
const prev = sessions[index - 1]
|
||||||
|
if (prev) prefetchSession(prev)
|
||||||
|
})
|
||||||
|
|
||||||
function navigateSessionByOffset(offset: number) {
|
function navigateSessionByOffset(offset: number) {
|
||||||
const projects = layout.projects.list()
|
const projects = layout.projects.list()
|
||||||
if (projects.length === 0) return
|
if (projects.length === 0) return
|
||||||
@@ -309,6 +452,27 @@ export default function Layout(props: ParentProps) {
|
|||||||
|
|
||||||
if (targetIndex >= 0 && targetIndex < sessions.length) {
|
if (targetIndex >= 0 && targetIndex < sessions.length) {
|
||||||
const session = sessions[targetIndex]
|
const session = sessions[targetIndex]
|
||||||
|
const next = sessions[targetIndex + 1]
|
||||||
|
const prev = sessions[targetIndex - 1]
|
||||||
|
|
||||||
|
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)
|
navigateToSession(session)
|
||||||
queueMicrotask(() => scrollToSession(session.id))
|
queueMicrotask(() => scrollToSession(session.id))
|
||||||
return
|
return
|
||||||
@@ -324,7 +488,27 @@ export default function Layout(props: ParentProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetSession = offset > 0 ? nextProjectSessions[0] : nextProjectSessions[nextProjectSessions.length - 1]
|
const index = offset > 0 ? 0 : nextProjectSessions.length - 1
|
||||||
|
const targetSession = nextProjectSessions[index]
|
||||||
|
const nextSession = nextProjectSessions[index + 1]
|
||||||
|
const prevSession = nextProjectSessions[index - 1]
|
||||||
|
|
||||||
|
if (offset > 0) {
|
||||||
|
if (nextSession) prefetchSession(nextSession, "high")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offset < 0) {
|
||||||
|
if (prevSession) prefetchSession(prevSession, "high")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
navStart({
|
||||||
|
dir: base64Encode(targetSession.directory),
|
||||||
|
from: params.id,
|
||||||
|
to: targetSession.id,
|
||||||
|
trigger: offset > 0 ? "alt+arrowdown" : "alt+arrowup",
|
||||||
|
})
|
||||||
|
}
|
||||||
navigateToSession(targetSession)
|
navigateToSession(targetSession)
|
||||||
queueMicrotask(() => scrollToSession(targetSession.id))
|
queueMicrotask(() => scrollToSession(targetSession.id))
|
||||||
}
|
}
|
||||||
@@ -679,6 +863,8 @@ export default function Layout(props: ParentProps) {
|
|||||||
<A
|
<A
|
||||||
href={`${props.slug}/session/${props.session.id}`}
|
href={`${props.slug}/session/${props.session.id}`}
|
||||||
class="flex flex-col min-w-0 text-left w-full focus:outline-none pl-4 pr-2 py-1"
|
class="flex flex-col min-w-0 text-left w-full focus:outline-none pl-4 pr-2 py-1"
|
||||||
|
onMouseEnter={() => prefetchSession(props.session, "high")}
|
||||||
|
onFocus={() => prefetchSession(props.session, "high")}
|
||||||
>
|
>
|
||||||
<div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7">
|
<div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7">
|
||||||
<span
|
<span
|
||||||
@@ -875,7 +1061,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
</Collapsible>
|
</Collapsible>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<Tooltip placement="right" value={props.project.worktree}>
|
<Tooltip placement="right" value={getFilename(props.project.worktree)}>
|
||||||
<ProjectVisual project={props.project} />
|
<ProjectVisual project={props.project} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Match>
|
</Match>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
|
import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
|
||||||
import { createMediaQuery } from "@solid-primitives/media"
|
import { createMediaQuery } from "@solid-primitives/media"
|
||||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
@@ -8,6 +8,7 @@ import { createStore } from "solid-js/store"
|
|||||||
import { PromptInput } from "@/components/prompt-input"
|
import { PromptInput } from "@/components/prompt-input"
|
||||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
||||||
@@ -49,10 +50,17 @@ import {
|
|||||||
NewSessionView,
|
NewSessionView,
|
||||||
} from "@/components/session"
|
} from "@/components/session"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { navMark, navParams } from "@/utils/perf"
|
||||||
import { same } from "@/utils/same"
|
import { same } from "@/utils/same"
|
||||||
|
|
||||||
type DiffStyle = "unified" | "split"
|
type DiffStyle = "unified" | "split"
|
||||||
|
|
||||||
|
const handoff = {
|
||||||
|
prompt: "",
|
||||||
|
terminals: [] as string[],
|
||||||
|
files: {} as Record<string, SelectedLineRange | null>,
|
||||||
|
}
|
||||||
|
|
||||||
interface SessionReviewTabProps {
|
interface SessionReviewTabProps {
|
||||||
diffs: () => FileDiff[]
|
diffs: () => FileDiff[]
|
||||||
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
|
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
|
||||||
@@ -162,6 +170,46 @@ export default function Page() {
|
|||||||
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
||||||
const view = createMemo(() => layout.view(sessionKey()))
|
const view = createMemo(() => layout.view(sessionKey()))
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => [params.dir, params.id] as const,
|
||||||
|
([dir, id], prev) => {
|
||||||
|
if (!id) return
|
||||||
|
navParams({ dir, from: prev?.[1], to: id })
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return
|
||||||
|
if (!prompt.ready()) return
|
||||||
|
navMark({ dir: params.dir, to: id, name: "storage:prompt-ready" })
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return
|
||||||
|
if (!terminal.ready()) return
|
||||||
|
navMark({ dir: params.dir, to: id, name: "storage:terminal-ready" })
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return
|
||||||
|
if (!file.ready()) return
|
||||||
|
navMark({ dir: params.dir, to: id, name: "storage:file-view-ready" })
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return
|
||||||
|
if (sync.data.message[id] === undefined) return
|
||||||
|
navMark({ dir: params.dir, to: id, name: "session:data-ready" })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||||
|
|
||||||
function normalizeTab(tab: string) {
|
function normalizeTab(tab: string) {
|
||||||
@@ -216,6 +264,8 @@ export default function Page() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||||
|
const reviewCount = createMemo(() => info()?.summary?.files ?? 0)
|
||||||
|
const hasReview = createMemo(() => reviewCount() > 0)
|
||||||
const revertMessageID = createMemo(() => info()?.revert?.messageID)
|
const revertMessageID = createMemo(() => info()?.revert?.messageID)
|
||||||
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||||
const messagesReady = createMemo(() => {
|
const messagesReady = createMemo(() => {
|
||||||
@@ -223,6 +273,16 @@ export default function Page() {
|
|||||||
if (!id) return true
|
if (!id) return true
|
||||||
return sync.data.message[id] !== undefined
|
return sync.data.message[id] !== undefined
|
||||||
})
|
})
|
||||||
|
const historyMore = createMemo(() => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return false
|
||||||
|
return sync.session.history.more(id)
|
||||||
|
})
|
||||||
|
const historyLoading = createMemo(() => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return false
|
||||||
|
return sync.session.history.loading(id)
|
||||||
|
})
|
||||||
const emptyUserMessages: UserMessage[] = []
|
const emptyUserMessages: UserMessage[] = []
|
||||||
const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[], emptyUserMessages)
|
const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[], emptyUserMessages)
|
||||||
const visibleUserMessages = createMemo(() => {
|
const visibleUserMessages = createMemo(() => {
|
||||||
@@ -249,11 +309,20 @@ export default function Page() {
|
|||||||
activeTerminalDraggable: undefined as string | undefined,
|
activeTerminalDraggable: undefined as string | undefined,
|
||||||
expanded: {} as Record<string, boolean>,
|
expanded: {} as Record<string, boolean>,
|
||||||
messageId: undefined as string | undefined,
|
messageId: undefined as string | undefined,
|
||||||
|
turnStart: 0,
|
||||||
mobileTab: "session" as "session" | "review",
|
mobileTab: "session" as "session" | "review",
|
||||||
newSessionWorktree: "main",
|
newSessionWorktree: "main",
|
||||||
promptHeight: 0,
|
promptHeight: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const renderedUserMessages = createMemo(() => {
|
||||||
|
const msgs = visibleUserMessages()
|
||||||
|
const start = store.turnStart
|
||||||
|
if (start <= 0) return msgs
|
||||||
|
if (start >= msgs.length) return emptyUserMessages
|
||||||
|
return msgs.slice(start)
|
||||||
|
}, emptyUserMessages)
|
||||||
|
|
||||||
const newSessionWorktree = createMemo(() => {
|
const newSessionWorktree = createMemo(() => {
|
||||||
if (store.newSessionWorktree === "create") return "create"
|
if (store.newSessionWorktree === "create") return "create"
|
||||||
const project = sync.project
|
const project = sync.project
|
||||||
@@ -290,6 +359,12 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
||||||
|
const diffsReady = createMemo(() => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return true
|
||||||
|
if (!hasReview()) return true
|
||||||
|
return sync.data.session_diff[id] !== undefined
|
||||||
|
})
|
||||||
|
|
||||||
const idle = { type: "idle" as const }
|
const idle = { type: "idle" as const }
|
||||||
let inputRef!: HTMLDivElement
|
let inputRef!: HTMLDivElement
|
||||||
@@ -302,11 +377,10 @@ export default function Page() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (layout.terminal.opened()) {
|
if (!view().terminal.opened()) return
|
||||||
if (terminal.all().length === 0) {
|
if (!terminal.ready()) return
|
||||||
|
if (terminal.all().length !== 0) return
|
||||||
terminal.new()
|
terminal.new()
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
@@ -366,7 +440,7 @@ export default function Page() {
|
|||||||
category: "View",
|
category: "View",
|
||||||
keybind: "ctrl+`",
|
keybind: "ctrl+`",
|
||||||
slash: "terminal",
|
slash: "terminal",
|
||||||
onSelect: () => layout.terminal.toggle(),
|
onSelect: () => view().terminal.toggle(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "review.toggle",
|
id: "review.toggle",
|
||||||
@@ -374,7 +448,7 @@ export default function Page() {
|
|||||||
description: "Show or hide the review panel",
|
description: "Show or hide the review panel",
|
||||||
category: "View",
|
category: "View",
|
||||||
keybind: "mod+shift+r",
|
keybind: "mod+shift+r",
|
||||||
onSelect: () => layout.review.toggle(),
|
onSelect: () => view().reviewPanel.toggle(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "terminal.new",
|
id: "terminal.new",
|
||||||
@@ -643,11 +717,11 @@ export default function Page() {
|
|||||||
.filter((tab) => tab !== "context"),
|
.filter((tab) => tab !== "context"),
|
||||||
)
|
)
|
||||||
|
|
||||||
const reviewTab = createMemo(() => diffs().length > 0 || tabs().active() === "review")
|
const reviewTab = createMemo(() => hasReview() || tabs().active() === "review")
|
||||||
const mobileReview = createMemo(() => !isDesktop() && diffs().length > 0 && store.mobileTab === "review")
|
const mobileReview = createMemo(() => !isDesktop() && hasReview() && store.mobileTab === "review")
|
||||||
|
|
||||||
const showTabs = createMemo(
|
const showTabs = createMemo(
|
||||||
() => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0 || contextOpen()),
|
() => view().reviewPanel.opened() && (hasReview() || tabs().all().length > 0 || contextOpen()),
|
||||||
)
|
)
|
||||||
|
|
||||||
const activeTab = createMemo(() => {
|
const activeTab = createMemo(() => {
|
||||||
@@ -664,10 +738,22 @@ export default function Page() {
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!layout.ready()) return
|
if (!layout.ready()) return
|
||||||
if (tabs().active()) return
|
if (tabs().active()) return
|
||||||
if (diffs().length === 0 && openedTabs().length === 0 && !contextOpen()) return
|
if (!hasReview() && openedTabs().length === 0 && !contextOpen()) return
|
||||||
tabs().setActive(activeTab())
|
tabs().setActive(activeTab())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return
|
||||||
|
if (!hasReview()) return
|
||||||
|
|
||||||
|
const wants = isDesktop() ? view().reviewPanel.opened() && activeTab() === "review" : store.mobileTab === "review"
|
||||||
|
if (!wants) return
|
||||||
|
if (diffsReady()) return
|
||||||
|
|
||||||
|
sync.session.diff(id)
|
||||||
|
})
|
||||||
|
|
||||||
const isWorking = createMemo(() => status().type !== "idle")
|
const isWorking = createMemo(() => status().type !== "idle")
|
||||||
const autoScroll = createAutoScroll({
|
const autoScroll = createAutoScroll({
|
||||||
working: isWorking,
|
working: isWorking,
|
||||||
@@ -683,6 +769,88 @@ export default function Page() {
|
|||||||
autoScroll.scrollRef(el)
|
autoScroll.scrollRef(el)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const turnInit = 20
|
||||||
|
const turnBatch = 20
|
||||||
|
let turnHandle: number | undefined
|
||||||
|
let turnIdle = false
|
||||||
|
|
||||||
|
function cancelTurnBackfill() {
|
||||||
|
const handle = turnHandle
|
||||||
|
if (handle === undefined) return
|
||||||
|
turnHandle = undefined
|
||||||
|
|
||||||
|
if (turnIdle && window.cancelIdleCallback) {
|
||||||
|
window.cancelIdleCallback(handle)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleTurnBackfill() {
|
||||||
|
if (turnHandle !== undefined) return
|
||||||
|
if (store.turnStart <= 0) return
|
||||||
|
|
||||||
|
if (window.requestIdleCallback) {
|
||||||
|
turnIdle = true
|
||||||
|
turnHandle = window.requestIdleCallback(() => {
|
||||||
|
turnHandle = undefined
|
||||||
|
backfillTurns()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
turnIdle = false
|
||||||
|
turnHandle = window.setTimeout(() => {
|
||||||
|
turnHandle = undefined
|
||||||
|
backfillTurns()
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function backfillTurns() {
|
||||||
|
const start = store.turnStart
|
||||||
|
if (start <= 0) return
|
||||||
|
|
||||||
|
const next = start - turnBatch
|
||||||
|
const nextStart = next > 0 ? next : 0
|
||||||
|
|
||||||
|
const el = scroller
|
||||||
|
if (!el) {
|
||||||
|
setStore("turnStart", nextStart)
|
||||||
|
scheduleTurnBackfill()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeTop = el.scrollTop
|
||||||
|
const beforeHeight = el.scrollHeight
|
||||||
|
|
||||||
|
setStore("turnStart", nextStart)
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const delta = el.scrollHeight - beforeHeight
|
||||||
|
if (delta) el.scrollTop = beforeTop + delta
|
||||||
|
})
|
||||||
|
|
||||||
|
scheduleTurnBackfill()
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => [params.id, messagesReady()] as const,
|
||||||
|
([id, ready]) => {
|
||||||
|
cancelTurnBackfill()
|
||||||
|
setStore("turnStart", 0)
|
||||||
|
if (!id || !ready) return
|
||||||
|
|
||||||
|
const len = visibleUserMessages().length
|
||||||
|
const start = len > turnInit ? len - turnInit : 0
|
||||||
|
setStore("turnStart", start)
|
||||||
|
scheduleTurnBackfill()
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
createResizeObserver(
|
createResizeObserver(
|
||||||
() => promptDock,
|
() => promptDock,
|
||||||
({ height }) => {
|
({ height }) => {
|
||||||
@@ -710,6 +878,21 @@ export default function Page() {
|
|||||||
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
|
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
|
||||||
setActiveMessage(message)
|
setActiveMessage(message)
|
||||||
|
|
||||||
|
const msgs = visibleUserMessages()
|
||||||
|
const index = msgs.findIndex((m) => m.id === message.id)
|
||||||
|
if (index !== -1 && index < store.turnStart) {
|
||||||
|
setStore("turnStart", index)
|
||||||
|
scheduleTurnBackfill()
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = document.getElementById(anchor(message.id))
|
||||||
|
if (el) el.scrollIntoView({ behavior, block: "start" })
|
||||||
|
})
|
||||||
|
|
||||||
|
updateHash(message.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const el = document.getElementById(anchor(message.id))
|
const el = document.getElementById(anchor(message.id))
|
||||||
if (el) el.scrollIntoView({ behavior, block: "start" })
|
if (el) el.scrollIntoView({ behavior, block: "start" })
|
||||||
updateHash(message.id)
|
updateHash(message.id)
|
||||||
@@ -755,12 +938,27 @@ export default function Page() {
|
|||||||
if (!sessionID || !ready) return
|
if (!sessionID || !ready) return
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const id = window.location.hash.slice(1)
|
const hash = window.location.hash.slice(1)
|
||||||
const hashTarget = id ? document.getElementById(id) : undefined
|
if (!hash) {
|
||||||
|
autoScroll.forceScrollToBottom()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashTarget = document.getElementById(hash)
|
||||||
if (hashTarget) {
|
if (hashTarget) {
|
||||||
hashTarget.scrollIntoView({ behavior: "auto", block: "start" })
|
hashTarget.scrollIntoView({ behavior: "auto", block: "start" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const match = hash.match(/^message-(.+)$/)
|
||||||
|
if (match) {
|
||||||
|
const msg = visibleUserMessages().find((m) => m.id === match[1])
|
||||||
|
if (msg) {
|
||||||
|
scrollToMessage(msg, "auto")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
autoScroll.forceScrollToBottom()
|
autoScroll.forceScrollToBottom()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -769,7 +967,43 @@ export default function Page() {
|
|||||||
document.addEventListener("keydown", handleKeyDown)
|
document.addEventListener("keydown", handleKeyDown)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const previewPrompt = () =>
|
||||||
|
prompt
|
||||||
|
.current()
|
||||||
|
.map((part) => {
|
||||||
|
if (part.type === "file") return `[file:${part.path}]`
|
||||||
|
if (part.type === "agent") return `@${part.name}`
|
||||||
|
if (part.type === "image") return `[image:${part.filename}]`
|
||||||
|
return part.content
|
||||||
|
})
|
||||||
|
.join("")
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!prompt.ready()) return
|
||||||
|
handoff.prompt = previewPrompt()
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!terminal.ready()) return
|
||||||
|
handoff.terminals = terminal.all().map((t) => t.title)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!file.ready()) return
|
||||||
|
handoff.files = Object.fromEntries(
|
||||||
|
tabs()
|
||||||
|
.all()
|
||||||
|
.flatMap((tab) => {
|
||||||
|
const path = file.pathFromTab(tab)
|
||||||
|
if (!path) return []
|
||||||
|
return [[path, file.selectedLines(path) ?? null] as const]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
|
cancelTurnBackfill()
|
||||||
document.removeEventListener("keydown", handleKeyDown)
|
document.removeEventListener("keydown", handleKeyDown)
|
||||||
if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
|
if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
|
||||||
})
|
})
|
||||||
@@ -779,7 +1013,7 @@ export default function Page() {
|
|||||||
<SessionHeader />
|
<SessionHeader />
|
||||||
<div class="flex-1 min-h-0 flex flex-col md:flex-row">
|
<div class="flex-1 min-h-0 flex flex-col md:flex-row">
|
||||||
{/* Mobile tab bar - only shown on mobile when there are diffs */}
|
{/* Mobile tab bar - only shown on mobile when there are diffs */}
|
||||||
<Show when={!isDesktop() && diffs().length > 0}>
|
<Show when={!isDesktop() && hasReview()}>
|
||||||
<Tabs class="h-auto">
|
<Tabs class="h-auto">
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Trigger
|
<Tabs.Trigger
|
||||||
@@ -796,7 +1030,7 @@ export default function Page() {
|
|||||||
classes={{ button: "w-full" }}
|
classes={{ button: "w-full" }}
|
||||||
onClick={() => setStore("mobileTab", "review")}
|
onClick={() => setStore("mobileTab", "review")}
|
||||||
>
|
>
|
||||||
{diffs().length} Files Changed
|
{reviewCount()} Files Changed
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -821,6 +1055,10 @@ export default function Page() {
|
|||||||
when={!mobileReview()}
|
when={!mobileReview()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="relative h-full overflow-hidden">
|
<div class="relative h-full overflow-hidden">
|
||||||
|
<Show
|
||||||
|
when={diffsReady()}
|
||||||
|
fallback={<div class="px-4 py-4 text-text-weak">Loading changes...</div>}
|
||||||
|
>
|
||||||
<SessionReviewTab
|
<SessionReviewTab
|
||||||
diffs={diffs}
|
diffs={diffs}
|
||||||
view={view}
|
view={view}
|
||||||
@@ -836,6 +1074,7 @@ export default function Page() {
|
|||||||
container: "px-4",
|
container: "px-4",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -868,8 +1107,47 @@ export default function Page() {
|
|||||||
"mt-0": showTabs(),
|
"mt-0": showTabs(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<For each={visibleUserMessages()}>
|
<Show when={store.turnStart > 0}>
|
||||||
{(message) => (
|
<div class="w-full flex justify-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="large"
|
||||||
|
class="text-12-medium opacity-50"
|
||||||
|
onClick={() => setStore("turnStart", 0)}
|
||||||
|
>
|
||||||
|
Render earlier messages
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={historyMore()}>
|
||||||
|
<div class="w-full flex justify-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="large"
|
||||||
|
class="text-12-medium opacity-50"
|
||||||
|
disabled={historyLoading()}
|
||||||
|
onClick={() => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return
|
||||||
|
setStore("turnStart", 0)
|
||||||
|
sync.session.history.loadMore(id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{historyLoading() ? "Loading earlier messages..." : "Load earlier messages"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<For each={renderedUserMessages()}>
|
||||||
|
{(message) => {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
onMount(() => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return
|
||||||
|
navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
id={anchor(message.id)}
|
id={anchor(message.id)}
|
||||||
data-message-id={message.id}
|
data-message-id={message.id}
|
||||||
@@ -903,7 +1181,8 @@ export default function Page() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)
|
||||||
|
}}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -943,6 +1222,14 @@ export default function Page() {
|
|||||||
"w-full md:px-6 pointer-events-auto": true,
|
"w-full md:px-6 pointer-events-auto": true,
|
||||||
"md:max-w-200": !showTabs(),
|
"md:max-w-200": !showTabs(),
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={prompt.ready()}
|
||||||
|
fallback={
|
||||||
|
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
|
||||||
|
{handoff.prompt || "Loading prompt..."}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<PromptInput
|
<PromptInput
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
@@ -951,6 +1238,7 @@ export default function Page() {
|
|||||||
newSessionWorktree={newSessionWorktree()}
|
newSessionWorktree={newSessionWorktree()}
|
||||||
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
|
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
|
||||||
/>
|
/>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1034,7 +1322,12 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
<Show when={reviewTab()}>
|
<Show when={reviewTab()}>
|
||||||
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||||
|
<Show when={activeTab() === "review"}>
|
||||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||||
|
<Show
|
||||||
|
when={diffsReady()}
|
||||||
|
fallback={<div class="px-6 py-4 text-text-weak">Loading changes...</div>}
|
||||||
|
>
|
||||||
<SessionReviewTab
|
<SessionReviewTab
|
||||||
diffs={diffs}
|
diffs={diffs}
|
||||||
view={view}
|
view={view}
|
||||||
@@ -1046,11 +1339,14 @@ export default function Page() {
|
|||||||
file.load(path)
|
file.load(path)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
</Show>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={contextOpen()}>
|
<Show when={contextOpen()}>
|
||||||
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
|
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||||
|
<Show when={activeTab() === "context"}>
|
||||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||||
<SessionContextTab
|
<SessionContextTab
|
||||||
messages={messages}
|
messages={messages}
|
||||||
@@ -1059,6 +1355,7 @@ export default function Page() {
|
|||||||
info={info}
|
info={info}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</Show>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
</Show>
|
</Show>
|
||||||
<For each={openedTabs()}>
|
<For each={openedTabs()}>
|
||||||
@@ -1107,7 +1404,8 @@ export default function Page() {
|
|||||||
const selectedLines = createMemo(() => {
|
const selectedLines = createMemo(() => {
|
||||||
const p = path()
|
const p = path()
|
||||||
if (!p) return null
|
if (!p) return null
|
||||||
return file.selectedLines(p) ?? null
|
if (file.ready()) return file.selectedLines(p) ?? null
|
||||||
|
return handoff.files[p] ?? null
|
||||||
})
|
})
|
||||||
const selection = createMemo(() => {
|
const selection = createMemo(() => {
|
||||||
const range = selectedLines()
|
const range = selectedLines()
|
||||||
@@ -1204,6 +1502,7 @@ export default function Page() {
|
|||||||
}}
|
}}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
>
|
>
|
||||||
|
<Show when={activeTab() === tab}>
|
||||||
<Show when={selection()}>
|
<Show when={selection()}>
|
||||||
{(sel) => (
|
{(sel) => (
|
||||||
<div class="hidden sticky top-0 z-10 px-6 py-2 _flex justify-end bg-background-base border-b border-border-weak-base">
|
<div class="hidden sticky top-0 z-10 px-6 py-2 _flex justify-end bg-background-base border-b border-border-weak-base">
|
||||||
@@ -1280,6 +1579,7 @@ export default function Page() {
|
|||||||
{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}
|
{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
</Show>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
@@ -1302,7 +1602,7 @@ export default function Page() {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={isDesktop() && layout.terminal.opened()}>
|
<Show when={isDesktop() && view().terminal.opened()}>
|
||||||
<div
|
<div
|
||||||
class="relative w-full flex-col shrink-0 border-t border-border-weak-base"
|
class="relative w-full flex-col shrink-0 border-t border-border-weak-base"
|
||||||
style={{ height: `${layout.terminal.height()}px` }}
|
style={{ height: `${layout.terminal.height()}px` }}
|
||||||
@@ -1314,8 +1614,27 @@ export default function Page() {
|
|||||||
max={window.innerHeight * 0.6}
|
max={window.innerHeight * 0.6}
|
||||||
collapseThreshold={50}
|
collapseThreshold={50}
|
||||||
onResize={layout.terminal.resize}
|
onResize={layout.terminal.resize}
|
||||||
onCollapse={layout.terminal.close}
|
onCollapse={view().terminal.close}
|
||||||
/>
|
/>
|
||||||
|
<Show
|
||||||
|
when={terminal.ready()}
|
||||||
|
fallback={
|
||||||
|
<div class="flex flex-col h-full pointer-events-none">
|
||||||
|
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
|
||||||
|
<For each={handoff.terminals}>
|
||||||
|
{(title) => (
|
||||||
|
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<div class="flex-1" />
|
||||||
|
<div class="text-text-weak pr-2">Loading...</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 flex items-center justify-center text-text-weak">Loading terminal...</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
<DragDropProvider
|
<DragDropProvider
|
||||||
onDragStart={handleTerminalDragStart}
|
onDragStart={handleTerminalDragStart}
|
||||||
onDragEnd={handleTerminalDragEnd}
|
onDragEnd={handleTerminalDragEnd}
|
||||||
@@ -1364,6 +1683,7 @@ export default function Page() {
|
|||||||
</Show>
|
</Show>
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
</DragDropProvider>
|
</DragDropProvider>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
type Nav = {
|
||||||
|
id: string
|
||||||
|
dir?: string
|
||||||
|
from?: string
|
||||||
|
to: string
|
||||||
|
trigger?: string
|
||||||
|
start: number
|
||||||
|
marks: Record<string, number>
|
||||||
|
logged: boolean
|
||||||
|
timer?: ReturnType<typeof setTimeout>
|
||||||
|
}
|
||||||
|
|
||||||
|
const dev = import.meta.env.DEV
|
||||||
|
|
||||||
|
const key = (dir: string | undefined, to: string) => `${dir ?? ""}:${to}`
|
||||||
|
|
||||||
|
const now = () => performance.now()
|
||||||
|
|
||||||
|
const uid = () => crypto.randomUUID?.() ?? Math.random().toString(16).slice(2)
|
||||||
|
|
||||||
|
const navs = new Map<string, Nav>()
|
||||||
|
const pending = new Map<string, string>()
|
||||||
|
const active = new Map<string, string>()
|
||||||
|
|
||||||
|
const required = [
|
||||||
|
"session:params",
|
||||||
|
"session:data-ready",
|
||||||
|
"session:first-turn-mounted",
|
||||||
|
"storage:prompt-ready",
|
||||||
|
"storage:terminal-ready",
|
||||||
|
"storage:file-view-ready",
|
||||||
|
]
|
||||||
|
|
||||||
|
function flush(id: string, reason: "complete" | "timeout") {
|
||||||
|
if (!dev) return
|
||||||
|
const nav = navs.get(id)
|
||||||
|
if (!nav) return
|
||||||
|
if (nav.logged) return
|
||||||
|
|
||||||
|
nav.logged = true
|
||||||
|
if (nav.timer) clearTimeout(nav.timer)
|
||||||
|
|
||||||
|
const baseName = nav.marks["navigate:start"] !== undefined ? "navigate:start" : "session:params"
|
||||||
|
const base = nav.marks[baseName] ?? nav.start
|
||||||
|
|
||||||
|
const ms = Object.fromEntries(
|
||||||
|
Object.entries(nav.marks)
|
||||||
|
.slice()
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([name, t]) => [name, Math.round((t - base) * 100) / 100]),
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"perf.session-nav " +
|
||||||
|
JSON.stringify({
|
||||||
|
type: "perf.session-nav.v0",
|
||||||
|
id: nav.id,
|
||||||
|
dir: nav.dir,
|
||||||
|
from: nav.from,
|
||||||
|
to: nav.to,
|
||||||
|
trigger: nav.trigger,
|
||||||
|
base: baseName,
|
||||||
|
reason,
|
||||||
|
ms,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
navs.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeFlush(id: string) {
|
||||||
|
if (!dev) return
|
||||||
|
const nav = navs.get(id)
|
||||||
|
if (!nav) return
|
||||||
|
if (nav.logged) return
|
||||||
|
if (!required.every((name) => nav.marks[name] !== undefined)) return
|
||||||
|
flush(id, "complete")
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensure(id: string, data: Omit<Nav, "marks" | "logged" | "timer">) {
|
||||||
|
const existing = navs.get(id)
|
||||||
|
if (existing) return existing
|
||||||
|
|
||||||
|
const nav: Nav = {
|
||||||
|
...data,
|
||||||
|
marks: {},
|
||||||
|
logged: false,
|
||||||
|
}
|
||||||
|
nav.timer = setTimeout(() => flush(id, "timeout"), 5000)
|
||||||
|
navs.set(id, nav)
|
||||||
|
return nav
|
||||||
|
}
|
||||||
|
|
||||||
|
export function navStart(input: { dir?: string; from?: string; to: string; trigger?: string }) {
|
||||||
|
if (!dev) return
|
||||||
|
|
||||||
|
const id = uid()
|
||||||
|
const start = now()
|
||||||
|
const nav = ensure(id, { ...input, id, start })
|
||||||
|
nav.marks["navigate:start"] = start
|
||||||
|
|
||||||
|
pending.set(key(input.dir, input.to), id)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
export function navParams(input: { dir?: string; from?: string; to: string }) {
|
||||||
|
if (!dev) return
|
||||||
|
|
||||||
|
const k = key(input.dir, input.to)
|
||||||
|
const pendingId = pending.get(k)
|
||||||
|
if (pendingId) pending.delete(k)
|
||||||
|
const id = pendingId ?? uid()
|
||||||
|
|
||||||
|
const start = now()
|
||||||
|
const nav = ensure(id, { ...input, id, start, trigger: pendingId ? "key" : "route" })
|
||||||
|
nav.marks["session:params"] = start
|
||||||
|
|
||||||
|
active.set(k, id)
|
||||||
|
maybeFlush(id)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
export function navMark(input: { dir?: string; to: string; name: string }) {
|
||||||
|
if (!dev) return
|
||||||
|
|
||||||
|
const id = active.get(key(input.dir, input.to))
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
const nav = navs.get(id)
|
||||||
|
if (!nav) return
|
||||||
|
if (nav.marks[input.name] !== undefined) return
|
||||||
|
|
||||||
|
nav.marks[input.name] = now()
|
||||||
|
maybeFlush(id)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -129,9 +129,9 @@ export default function Download() {
|
|||||||
</code>
|
</code>
|
||||||
<CopyStatus />
|
<CopyStatus />
|
||||||
</button>
|
</button>
|
||||||
<button data-component="cli-row" onClick={handleCopyClick("brew install opencode")}>
|
<button data-component="cli-row" onClick={handleCopyClick("brew install anomalyco/tap/opencode")}>
|
||||||
<code>
|
<code>
|
||||||
brew install <strong>opencode</strong>
|
brew install <strong>anomalyco/tap/opencode</strong>
|
||||||
</code>
|
</code>
|
||||||
<CopyStatus />
|
<CopyStatus />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export default function Home() {
|
|||||||
<button data-copy data-slot="command" onClick={handleCopyClick}>
|
<button data-copy data-slot="command" onClick={handleCopyClick}>
|
||||||
<span>
|
<span>
|
||||||
<span data-slot="protocol">brew install </span>
|
<span data-slot="protocol">brew install </span>
|
||||||
<span data-slot="highlight">opencode</span>
|
<span data-slot="highlight">anomalyco/tap/opencode</span>
|
||||||
</span>
|
</span>
|
||||||
<CopyStatus />
|
<CopyStatus />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||||
import type { APIEvent } from "@solidjs/start/server"
|
import type { APIEvent } from "@solidjs/start/server"
|
||||||
import { and, Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
import { and, Database, eq, isNull, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
||||||
import { BillingTable, PaymentTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
import { BillingTable, PaymentTable, SubscriptionTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||||
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
|
||||||
import { Identifier } from "@opencode-ai/console-core/identifier.js"
|
import { Identifier } from "@opencode-ai/console-core/identifier.js"
|
||||||
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
|
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
|
||||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||||
import { Resource } from "@opencode-ai/console-resource"
|
import { Resource } from "@opencode-ai/console-resource"
|
||||||
|
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
||||||
|
import { AuthTable } from "@opencode-ai/console-core/schema/auth.sql.js"
|
||||||
|
|
||||||
export async function POST(input: APIEvent) {
|
export async function POST(input: APIEvent) {
|
||||||
const body = await Billing.stripe().webhooks.constructEventAsync(
|
const body = await Billing.stripe().webhooks.constructEventAsync(
|
||||||
@@ -40,7 +41,7 @@ export async function POST(input: APIEvent) {
|
|||||||
.where(eq(BillingTable.customerID, customerID))
|
.where(eq(BillingTable.customerID, customerID))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (body.type === "checkout.session.completed") {
|
if (body.type === "checkout.session.completed" && body.data.object.mode === "payment") {
|
||||||
const workspaceID = body.data.object.metadata?.workspaceID
|
const workspaceID = body.data.object.metadata?.workspaceID
|
||||||
const amountInCents = body.data.object.metadata?.amount && parseInt(body.data.object.metadata?.amount)
|
const amountInCents = body.data.object.metadata?.amount && parseInt(body.data.object.metadata?.amount)
|
||||||
const customerID = body.data.object.customer as string
|
const customerID = body.data.object.customer as string
|
||||||
@@ -103,85 +104,112 @@ export async function POST(input: APIEvent) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (body.type === "charge.refunded") {
|
if (body.type === "checkout.session.completed" && body.data.object.mode === "subscription") {
|
||||||
|
const workspaceID = body.data.object.custom_fields.find((f) => f.key === "workspaceid")?.text?.value
|
||||||
|
const amountInCents = body.data.object.amount_total as number
|
||||||
const customerID = body.data.object.customer as string
|
const customerID = body.data.object.customer as string
|
||||||
const paymentIntentID = body.data.object.payment_intent as string
|
const customerEmail = body.data.object.customer_details?.email as string
|
||||||
if (!customerID) throw new Error("Customer ID not found")
|
const invoiceID = body.data.object.invoice as string
|
||||||
if (!paymentIntentID) throw new Error("Payment ID not found")
|
const subscriptionID = body.data.object.subscription as string
|
||||||
|
const promoCode = body.data.object.discounts?.[0]?.promotion_code as string
|
||||||
|
|
||||||
const workspaceID = await Database.use((tx) =>
|
|
||||||
tx
|
|
||||||
.select({
|
|
||||||
workspaceID: BillingTable.workspaceID,
|
|
||||||
})
|
|
||||||
.from(BillingTable)
|
|
||||||
.where(eq(BillingTable.customerID, customerID))
|
|
||||||
.then((rows) => rows[0]?.workspaceID),
|
|
||||||
)
|
|
||||||
if (!workspaceID) throw new Error("Workspace ID not found")
|
if (!workspaceID) throw new Error("Workspace ID not found")
|
||||||
|
|
||||||
const amount = await Database.use((tx) =>
|
|
||||||
tx
|
|
||||||
.select({
|
|
||||||
amount: PaymentTable.amount,
|
|
||||||
})
|
|
||||||
.from(PaymentTable)
|
|
||||||
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
|
|
||||||
.then((rows) => rows[0]?.amount),
|
|
||||||
)
|
|
||||||
if (!amount) throw new Error("Payment not found")
|
|
||||||
|
|
||||||
await Database.transaction(async (tx) => {
|
|
||||||
await tx
|
|
||||||
.update(PaymentTable)
|
|
||||||
.set({
|
|
||||||
timeRefunded: new Date(body.created * 1000),
|
|
||||||
})
|
|
||||||
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
|
|
||||||
|
|
||||||
await tx
|
|
||||||
.update(BillingTable)
|
|
||||||
.set({
|
|
||||||
balance: sql`${BillingTable.balance} - ${amount}`,
|
|
||||||
})
|
|
||||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (body.type === "invoice.payment_succeeded" && body.data.object.billing_reason === "subscription_cycle") {
|
|
||||||
const invoiceID = body.data.object.id as string
|
|
||||||
const amountInCents = body.data.object.amount_paid
|
|
||||||
const customerID = body.data.object.customer as string
|
|
||||||
const subscriptionID = body.data.object.parent?.subscription_details?.subscription as string
|
|
||||||
|
|
||||||
if (!customerID) throw new Error("Customer ID not found")
|
if (!customerID) throw new Error("Customer ID not found")
|
||||||
|
if (!amountInCents) throw new Error("Amount not found")
|
||||||
if (!invoiceID) throw new Error("Invoice ID not found")
|
if (!invoiceID) throw new Error("Invoice ID not found")
|
||||||
if (!subscriptionID) throw new Error("Subscription ID not found")
|
if (!subscriptionID) throw new Error("Subscription ID not found")
|
||||||
|
|
||||||
|
// get payment id from invoice
|
||||||
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
|
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
|
||||||
expand: ["payments"],
|
expand: ["payments"],
|
||||||
})
|
})
|
||||||
const paymentID = invoice.payments?.data[0].payment.payment_intent as string
|
const paymentID = invoice.payments?.data[0].payment.payment_intent as string
|
||||||
if (!paymentID) throw new Error("Payment ID not found")
|
if (!paymentID) throw new Error("Payment ID not found")
|
||||||
|
|
||||||
const workspaceID = await Database.use((tx) =>
|
// get payment method for the payment intent
|
||||||
tx
|
const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, {
|
||||||
.select({ workspaceID: BillingTable.workspaceID })
|
expand: ["payment_method"],
|
||||||
.from(BillingTable)
|
})
|
||||||
.where(eq(BillingTable.customerID, customerID))
|
const paymentMethod = paymentIntent.payment_method
|
||||||
.then((rows) => rows[0]?.workspaceID),
|
if (!paymentMethod || typeof paymentMethod === "string") throw new Error("Payment method not expanded")
|
||||||
)
|
|
||||||
if (!workspaceID) throw new Error("Workspace ID not found for customer")
|
|
||||||
|
|
||||||
await Database.use((tx) =>
|
// get coupon id from promotion code
|
||||||
tx.insert(PaymentTable).values({
|
const couponID = await (async () => {
|
||||||
|
if (!promoCode) return
|
||||||
|
const coupon = await Billing.stripe().promotionCodes.retrieve(promoCode)
|
||||||
|
const couponID = coupon.coupon.id
|
||||||
|
if (!couponID) throw new Error("Coupon not found for promotion code")
|
||||||
|
return couponID
|
||||||
|
})()
|
||||||
|
|
||||||
|
// get user
|
||||||
|
|
||||||
|
await Actor.provide("system", { workspaceID }, async () => {
|
||||||
|
// look up current billing
|
||||||
|
const billing = await Billing.get()
|
||||||
|
if (!billing) throw new Error(`Workspace with ID ${workspaceID} not found`)
|
||||||
|
|
||||||
|
// Temporarily skip this check because during Black drop, user can checkout
|
||||||
|
// as a new customer
|
||||||
|
//if (billing.customerID !== customerID) throw new Error("Customer ID mismatch")
|
||||||
|
|
||||||
|
// Temporarily check the user to apply to. After Black drop, we will allow
|
||||||
|
// look up the user to apply to
|
||||||
|
const users = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select({ id: UserTable.id, email: AuthTable.subject })
|
||||||
|
.from(UserTable)
|
||||||
|
.innerJoin(AuthTable, and(eq(AuthTable.accountID, UserTable.accountID), eq(AuthTable.provider, "email")))
|
||||||
|
.where(and(eq(UserTable.workspaceID, workspaceID), isNull(UserTable.timeDeleted))),
|
||||||
|
)
|
||||||
|
const user = users.find((u) => u.email === customerEmail) ?? users[0]
|
||||||
|
if (!user) {
|
||||||
|
console.error(`Error: User with email ${customerEmail} not found in workspace ${workspaceID}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// set customer metadata
|
||||||
|
if (!billing?.customerID) {
|
||||||
|
await Billing.stripe().customers.update(customerID, {
|
||||||
|
metadata: {
|
||||||
|
workspaceID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await Database.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.update(BillingTable)
|
||||||
|
.set({
|
||||||
|
customerID,
|
||||||
|
subscriptionID,
|
||||||
|
subscriptionCouponID: couponID,
|
||||||
|
paymentMethodID: paymentMethod.id,
|
||||||
|
paymentMethodLast4: paymentMethod.card?.last4 ?? null,
|
||||||
|
paymentMethodType: paymentMethod.type,
|
||||||
|
})
|
||||||
|
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||||
|
|
||||||
|
await tx.insert(SubscriptionTable).values({
|
||||||
|
workspaceID,
|
||||||
|
id: Identifier.create("subscription"),
|
||||||
|
userID: user.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
await tx.insert(PaymentTable).values({
|
||||||
workspaceID,
|
workspaceID,
|
||||||
id: Identifier.create("payment"),
|
id: Identifier.create("payment"),
|
||||||
amount: centsToMicroCents(amountInCents),
|
amount: centsToMicroCents(amountInCents),
|
||||||
paymentID,
|
paymentID,
|
||||||
invoiceID,
|
invoiceID,
|
||||||
customerID,
|
customerID,
|
||||||
}),
|
enrichment: {
|
||||||
)
|
type: "subscription",
|
||||||
|
couponID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if (body.type === "customer.subscription.created") {
|
if (body.type === "customer.subscription.created") {
|
||||||
const data = {
|
const data = {
|
||||||
@@ -378,9 +406,111 @@ export async function POST(input: APIEvent) {
|
|||||||
if (!workspaceID) throw new Error("Workspace ID not found for subscription")
|
if (!workspaceID) throw new Error("Workspace ID not found for subscription")
|
||||||
|
|
||||||
await Database.transaction(async (tx) => {
|
await Database.transaction(async (tx) => {
|
||||||
await tx.update(BillingTable).set({ subscriptionID: null }).where(eq(BillingTable.workspaceID, workspaceID))
|
await tx
|
||||||
|
.update(BillingTable)
|
||||||
|
.set({ subscriptionID: null, subscriptionCouponID: null })
|
||||||
|
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||||
|
|
||||||
await tx.update(UserTable).set({ timeSubscribed: null }).where(eq(UserTable.workspaceID, workspaceID))
|
await tx.delete(SubscriptionTable).where(eq(SubscriptionTable.workspaceID, workspaceID))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (body.type === "invoice.payment_succeeded") {
|
||||||
|
if (body.data.object.billing_reason === "subscription_cycle") {
|
||||||
|
const invoiceID = body.data.object.id as string
|
||||||
|
const amountInCents = body.data.object.amount_paid
|
||||||
|
const customerID = body.data.object.customer as string
|
||||||
|
const subscriptionID = body.data.object.parent?.subscription_details?.subscription as string
|
||||||
|
|
||||||
|
if (!customerID) throw new Error("Customer ID not found")
|
||||||
|
if (!invoiceID) throw new Error("Invoice ID not found")
|
||||||
|
if (!subscriptionID) throw new Error("Subscription ID not found")
|
||||||
|
|
||||||
|
// get coupon id from subscription
|
||||||
|
const subscriptionData = await Billing.stripe().subscriptions.retrieve(subscriptionID, {
|
||||||
|
expand: ["discounts"],
|
||||||
|
})
|
||||||
|
const couponID =
|
||||||
|
typeof subscriptionData.discounts[0] === "string"
|
||||||
|
? subscriptionData.discounts[0]
|
||||||
|
: subscriptionData.discounts[0]?.coupon?.id
|
||||||
|
|
||||||
|
// get payment id from invoice
|
||||||
|
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
|
||||||
|
expand: ["payments"],
|
||||||
|
})
|
||||||
|
const paymentID = invoice.payments?.data[0].payment.payment_intent as string
|
||||||
|
if (!paymentID) {
|
||||||
|
// payment id can be undefined when using coupon
|
||||||
|
if (!couponID) throw new Error("Payment ID not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceID = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select({ workspaceID: BillingTable.workspaceID })
|
||||||
|
.from(BillingTable)
|
||||||
|
.where(eq(BillingTable.customerID, customerID))
|
||||||
|
.then((rows) => rows[0]?.workspaceID),
|
||||||
|
)
|
||||||
|
if (!workspaceID) throw new Error("Workspace ID not found for customer")
|
||||||
|
|
||||||
|
await Database.use((tx) =>
|
||||||
|
tx.insert(PaymentTable).values({
|
||||||
|
workspaceID,
|
||||||
|
id: Identifier.create("payment"),
|
||||||
|
amount: centsToMicroCents(amountInCents),
|
||||||
|
paymentID,
|
||||||
|
invoiceID,
|
||||||
|
customerID,
|
||||||
|
enrichment: {
|
||||||
|
type: "subscription",
|
||||||
|
couponID,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (body.type === "charge.refunded") {
|
||||||
|
const customerID = body.data.object.customer as string
|
||||||
|
const paymentIntentID = body.data.object.payment_intent as string
|
||||||
|
if (!customerID) throw new Error("Customer ID not found")
|
||||||
|
if (!paymentIntentID) throw new Error("Payment ID not found")
|
||||||
|
|
||||||
|
const workspaceID = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select({
|
||||||
|
workspaceID: BillingTable.workspaceID,
|
||||||
|
})
|
||||||
|
.from(BillingTable)
|
||||||
|
.where(eq(BillingTable.customerID, customerID))
|
||||||
|
.then((rows) => rows[0]?.workspaceID),
|
||||||
|
)
|
||||||
|
if (!workspaceID) throw new Error("Workspace ID not found")
|
||||||
|
|
||||||
|
const amount = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select({
|
||||||
|
amount: PaymentTable.amount,
|
||||||
|
})
|
||||||
|
.from(PaymentTable)
|
||||||
|
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
|
||||||
|
.then((rows) => rows[0]?.amount),
|
||||||
|
)
|
||||||
|
if (!amount) throw new Error("Payment not found")
|
||||||
|
|
||||||
|
await Database.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.update(PaymentTable)
|
||||||
|
.set({
|
||||||
|
timeRefunded: new Date(body.created * 1000),
|
||||||
|
})
|
||||||
|
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(BillingTable)
|
||||||
|
.set({
|
||||||
|
balance: sql`${BillingTable.balance} - ${amount}`,
|
||||||
|
})
|
||||||
|
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||||
import { query, action, useParams, createAsync, useAction } from "@solidjs/router"
|
import { query, action, useParams, createAsync, useAction } from "@solidjs/router"
|
||||||
import { For, Show } from "solid-js"
|
import { For, Match, Show, Switch } from "solid-js"
|
||||||
import { withActor } from "~/context/auth.withActor"
|
import { withActor } from "~/context/auth.withActor"
|
||||||
import { formatDateUTC, formatDateForTable } from "../../common"
|
import { formatDateUTC, formatDateForTable } from "../../common"
|
||||||
import styles from "./payment-section.module.css"
|
import styles from "./payment-section.module.css"
|
||||||
@@ -77,7 +77,8 @@ export function PaymentSection() {
|
|||||||
<For each={payments()!}>
|
<For each={payments()!}>
|
||||||
{(payment) => {
|
{(payment) => {
|
||||||
const date = new Date(payment.timeCreated)
|
const date = new Date(payment.timeCreated)
|
||||||
const isCredit = !payment.paymentID
|
const amount =
|
||||||
|
payment.enrichment?.type === "subscription" && payment.enrichment.couponID ? 0 : payment.amount
|
||||||
return (
|
return (
|
||||||
<tr>
|
<tr>
|
||||||
<td data-slot="payment-date" title={formatDateUTC(date)}>
|
<td data-slot="payment-date" title={formatDateUTC(date)}>
|
||||||
@@ -85,13 +86,14 @@ export function PaymentSection() {
|
|||||||
</td>
|
</td>
|
||||||
<td data-slot="payment-id">{payment.id}</td>
|
<td data-slot="payment-id">{payment.id}</td>
|
||||||
<td data-slot="payment-amount" data-refunded={!!payment.timeRefunded}>
|
<td data-slot="payment-amount" data-refunded={!!payment.timeRefunded}>
|
||||||
${((payment.amount ?? 0) / 100000000).toFixed(2)}
|
${((amount ?? 0) / 100000000).toFixed(2)}
|
||||||
{isCredit ? " (credit)" : ""}
|
<Switch>
|
||||||
|
<Match when={payment.enrichment?.type === "credit"}> (credit)</Match>
|
||||||
|
<Match when={payment.enrichment?.type === "subscription"}> (subscription)</Match>
|
||||||
|
</Switch>
|
||||||
</td>
|
</td>
|
||||||
<td data-slot="payment-receipt">
|
<td data-slot="payment-receipt">
|
||||||
{isCredit ? (
|
{payment.paymentID ? (
|
||||||
<span>-</span>
|
|
||||||
) : (
|
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const receiptUrl = await downloadReceiptAction(params.id!, payment.paymentID!)
|
const receiptUrl = await downloadReceiptAction(params.id!, payment.paymentID!)
|
||||||
@@ -103,6 +105,8 @@ export function PaymentSection() {
|
|||||||
>
|
>
|
||||||
View
|
View
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<span>-</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ async function getCosts(workspaceID: string, year: number, month: number) {
|
|||||||
eq(UsageTable.workspaceID, workspaceID),
|
eq(UsageTable.workspaceID, workspaceID),
|
||||||
gte(UsageTable.timeCreated, startDate),
|
gte(UsageTable.timeCreated, startDate),
|
||||||
lte(UsageTable.timeCreated, endDate),
|
lte(UsageTable.timeCreated, endDate),
|
||||||
|
or(isNull(UsageTable.enrichment), sql`JSON_EXTRACT(${UsageTable.enrichment}, '$.plan') != 'sub'`),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.groupBy(sql`DATE(${UsageTable.timeCreated})`, UsageTable.model, UsageTable.keyID)
|
.groupBy(sql`DATE(${UsageTable.timeCreated})`, UsageTable.model, UsageTable.keyID)
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { APIEvent } from "@solidjs/start/server"
|
import type { APIEvent } from "@solidjs/start/server"
|
||||||
import { and, Database, eq, isNull, lt, or, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
import { and, Database, eq, isNull, lt, or, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
||||||
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
|
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
|
||||||
import { BillingTable, UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
import { BillingTable, SubscriptionTable, UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||||
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
|
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
|
||||||
|
import { getWeekBounds } from "@opencode-ai/console-core/util/date.js"
|
||||||
import { Identifier } from "@opencode-ai/console-core/identifier.js"
|
import { Identifier } from "@opencode-ai/console-core/identifier.js"
|
||||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||||
@@ -415,11 +416,11 @@ export async function handler(
|
|||||||
timeMonthlyUsageUpdated: UserTable.timeMonthlyUsageUpdated,
|
timeMonthlyUsageUpdated: UserTable.timeMonthlyUsageUpdated,
|
||||||
},
|
},
|
||||||
subscription: {
|
subscription: {
|
||||||
timeSubscribed: UserTable.timeSubscribed,
|
id: SubscriptionTable.id,
|
||||||
subIntervalUsage: UserTable.subIntervalUsage,
|
rollingUsage: SubscriptionTable.rollingUsage,
|
||||||
subMonthlyUsage: UserTable.subMonthlyUsage,
|
fixedUsage: SubscriptionTable.fixedUsage,
|
||||||
timeSubIntervalUsageUpdated: UserTable.timeSubIntervalUsageUpdated,
|
timeRollingUpdated: SubscriptionTable.timeRollingUpdated,
|
||||||
timeSubMonthlyUsageUpdated: UserTable.timeSubMonthlyUsageUpdated,
|
timeFixedUpdated: SubscriptionTable.timeFixedUpdated,
|
||||||
},
|
},
|
||||||
provider: {
|
provider: {
|
||||||
credentials: ProviderTable.credentials,
|
credentials: ProviderTable.credentials,
|
||||||
@@ -440,6 +441,14 @@ export async function handler(
|
|||||||
)
|
)
|
||||||
: sql`false`,
|
: sql`false`,
|
||||||
)
|
)
|
||||||
|
.leftJoin(
|
||||||
|
SubscriptionTable,
|
||||||
|
and(
|
||||||
|
eq(SubscriptionTable.workspaceID, KeyTable.workspaceID),
|
||||||
|
eq(SubscriptionTable.userID, KeyTable.userID),
|
||||||
|
isNull(SubscriptionTable.timeDeleted),
|
||||||
|
),
|
||||||
|
)
|
||||||
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
|
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
|
||||||
.then((rows) => rows[0]),
|
.then((rows) => rows[0]),
|
||||||
)
|
)
|
||||||
@@ -448,7 +457,7 @@ export async function handler(
|
|||||||
logger.metric({
|
logger.metric({
|
||||||
api_key: data.apiKey,
|
api_key: data.apiKey,
|
||||||
workspace: data.workspaceID,
|
workspace: data.workspaceID,
|
||||||
isSubscription: data.subscription.timeSubscribed ? true : false,
|
isSubscription: data.subscription ? true : false,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -456,7 +465,7 @@ export async function handler(
|
|||||||
workspaceID: data.workspaceID,
|
workspaceID: data.workspaceID,
|
||||||
billing: data.billing,
|
billing: data.billing,
|
||||||
user: data.user,
|
user: data.user,
|
||||||
subscription: data.subscription.timeSubscribed ? data.subscription : undefined,
|
subscription: data.subscription,
|
||||||
provider: data.provider,
|
provider: data.provider,
|
||||||
isFree: FREE_WORKSPACES.includes(data.workspaceID),
|
isFree: FREE_WORKSPACES.includes(data.workspaceID),
|
||||||
isDisabled: !!data.timeDisabled,
|
isDisabled: !!data.timeDisabled,
|
||||||
@@ -484,23 +493,11 @@ export async function handler(
|
|||||||
return `${minutes}min`
|
return `${minutes}min`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check monthly limit (based on subscription billing cycle)
|
// Check weekly limit
|
||||||
if (
|
if (sub.fixedUsage && sub.timeFixedUpdated) {
|
||||||
sub.subMonthlyUsage &&
|
const week = getWeekBounds(now)
|
||||||
sub.timeSubMonthlyUsageUpdated &&
|
if (sub.timeFixedUpdated >= week.start && sub.fixedUsage >= centsToMicroCents(black.fixedLimit * 100)) {
|
||||||
sub.subMonthlyUsage >= centsToMicroCents(black.monthlyLimit * 100)
|
const retryAfter = Math.ceil((week.end.getTime() - now.getTime()) / 1000)
|
||||||
) {
|
|
||||||
const subscribeDay = sub.timeSubscribed!.getUTCDate()
|
|
||||||
const cycleStart = new Date(
|
|
||||||
Date.UTC(
|
|
||||||
now.getUTCFullYear(),
|
|
||||||
now.getUTCDate() >= subscribeDay ? now.getUTCMonth() : now.getUTCMonth() - 1,
|
|
||||||
subscribeDay,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const cycleEnd = new Date(Date.UTC(cycleStart.getUTCFullYear(), cycleStart.getUTCMonth() + 1, subscribeDay))
|
|
||||||
if (sub.timeSubMonthlyUsageUpdated >= cycleStart && sub.timeSubMonthlyUsageUpdated < cycleEnd) {
|
|
||||||
const retryAfter = Math.ceil((cycleEnd.getTime() - now.getTime()) / 1000)
|
|
||||||
throw new SubscriptionError(
|
throw new SubscriptionError(
|
||||||
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
|
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
|
||||||
retryAfter,
|
retryAfter,
|
||||||
@@ -508,14 +505,12 @@ export async function handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check interval limit
|
// Check rolling limit
|
||||||
const intervalMs = black.intervalLength * 3600 * 1000
|
if (sub.rollingUsage && sub.timeRollingUpdated) {
|
||||||
if (sub.subIntervalUsage && sub.timeSubIntervalUsageUpdated) {
|
const rollingWindowMs = black.rollingWindow * 3600 * 1000
|
||||||
const currentInterval = Math.floor(now.getTime() / intervalMs)
|
const windowStart = new Date(now.getTime() - rollingWindowMs)
|
||||||
const usageInterval = Math.floor(sub.timeSubIntervalUsageUpdated.getTime() / intervalMs)
|
if (sub.timeRollingUpdated >= windowStart && sub.rollingUsage >= centsToMicroCents(black.rollingLimit * 100)) {
|
||||||
if (currentInterval === usageInterval && sub.subIntervalUsage >= centsToMicroCents(black.intervalLimit * 100)) {
|
const retryAfter = Math.ceil((sub.timeRollingUpdated.getTime() + rollingWindowMs - now.getTime()) / 1000)
|
||||||
const nextInterval = (currentInterval + 1) * intervalMs
|
|
||||||
const retryAfter = Math.ceil((nextInterval - now.getTime()) / 1000)
|
|
||||||
throw new SubscriptionError(
|
throw new SubscriptionError(
|
||||||
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
|
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
|
||||||
retryAfter,
|
retryAfter,
|
||||||
@@ -661,38 +656,39 @@ export async function handler(
|
|||||||
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
|
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
|
||||||
...(authInfo.subscription
|
...(authInfo.subscription
|
||||||
? (() => {
|
? (() => {
|
||||||
const now = new Date()
|
const black = BlackData.get()
|
||||||
const subscribeDay = authInfo.subscription.timeSubscribed!.getUTCDate()
|
const week = getWeekBounds(new Date())
|
||||||
const cycleStart = new Date(
|
const rollingWindowSeconds = black.rollingWindow * 3600
|
||||||
Date.UTC(
|
|
||||||
now.getUTCFullYear(),
|
|
||||||
now.getUTCDate() >= subscribeDay ? now.getUTCMonth() : now.getUTCMonth() - 1,
|
|
||||||
subscribeDay,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const cycleEnd = new Date(
|
|
||||||
Date.UTC(cycleStart.getUTCFullYear(), cycleStart.getUTCMonth() + 1, subscribeDay),
|
|
||||||
)
|
|
||||||
return [
|
return [
|
||||||
db
|
db
|
||||||
.update(UserTable)
|
.update(SubscriptionTable)
|
||||||
.set({
|
.set({
|
||||||
subMonthlyUsage: sql`
|
fixedUsage: sql`
|
||||||
CASE
|
CASE
|
||||||
WHEN ${UserTable.timeSubMonthlyUsageUpdated} >= ${cycleStart} AND ${UserTable.timeSubMonthlyUsageUpdated} < ${cycleEnd} THEN ${UserTable.subMonthlyUsage} + ${cost}
|
WHEN ${SubscriptionTable.timeFixedUpdated} >= ${week.start} THEN ${SubscriptionTable.fixedUsage} + ${cost}
|
||||||
ELSE ${cost}
|
ELSE ${cost}
|
||||||
END
|
END
|
||||||
`,
|
`,
|
||||||
timeSubMonthlyUsageUpdated: sql`now()`,
|
timeFixedUpdated: sql`now()`,
|
||||||
subIntervalUsage: sql`
|
rollingUsage: sql`
|
||||||
CASE
|
CASE
|
||||||
WHEN FLOOR(UNIX_TIMESTAMP(${UserTable.timeSubIntervalUsageUpdated}) / (${BlackData.get().intervalLength} * 3600)) = FLOOR(UNIX_TIMESTAMP(now()) / (${BlackData.get().intervalLength} * 3600)) THEN ${UserTable.subIntervalUsage} + ${cost}
|
WHEN UNIX_TIMESTAMP(${SubscriptionTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${SubscriptionTable.rollingUsage} + ${cost}
|
||||||
ELSE ${cost}
|
ELSE ${cost}
|
||||||
END
|
END
|
||||||
`,
|
`,
|
||||||
timeSubIntervalUsageUpdated: sql`now()`,
|
timeRollingUpdated: sql`
|
||||||
|
CASE
|
||||||
|
WHEN UNIX_TIMESTAMP(${SubscriptionTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${SubscriptionTable.timeRollingUpdated}
|
||||||
|
ELSE now()
|
||||||
|
END
|
||||||
|
`,
|
||||||
})
|
})
|
||||||
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))),
|
.where(
|
||||||
|
and(
|
||||||
|
eq(SubscriptionTable.workspaceID, authInfo.workspaceID),
|
||||||
|
eq(SubscriptionTable.userID, authInfo.user.id),
|
||||||
|
),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
})()
|
})()
|
||||||
: [
|
: [
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE `subscription` (
|
||||||
|
`id` varchar(30) NOT NULL,
|
||||||
|
`workspace_id` varchar(30) NOT NULL,
|
||||||
|
`time_created` timestamp(3) NOT NULL DEFAULT (now()),
|
||||||
|
`time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
`time_deleted` timestamp(3),
|
||||||
|
`user_id` varchar(30) NOT NULL,
|
||||||
|
`rolling_usage` bigint,
|
||||||
|
`fixed_usage` bigint,
|
||||||
|
`time_rolling_updated` timestamp(3),
|
||||||
|
`time_fixed_updated` timestamp(3),
|
||||||
|
CONSTRAINT `subscription_workspace_id_id_pk` PRIMARY KEY(`workspace_id`,`id`)
|
||||||
|
);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE INDEX `workspace_user_id` ON `subscription` (`workspace_id`,`user_id`);--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` DROP COLUMN `time_subscribed`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` DROP COLUMN `sub_interval_usage`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` DROP COLUMN `sub_monthly_usage`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` DROP COLUMN `sub_time_interval_usage_updated`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` DROP COLUMN `sub_time_monthly_usage_updated`;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP INDEX `workspace_user_id` ON `subscription`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `subscription` ADD CONSTRAINT `workspace_user_id` UNIQUE(`workspace_id`,`user_id`);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `billing` ADD `subscription_coupon_id` varchar(28);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `payment` ADD `enrichment` json;
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -323,6 +323,41 @@
|
|||||||
"when": 1767765497502,
|
"when": 1767765497502,
|
||||||
"tag": "0045_cuddly_diamondback",
|
"tag": "0045_cuddly_diamondback",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 46,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1767912262458,
|
||||||
|
"tag": "0046_charming_black_bolt",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 47,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1767916965243,
|
||||||
|
"tag": "0047_huge_omega_red",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 48,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1767917785224,
|
||||||
|
"tag": "0048_mean_frank_castle",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 49,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1767922954153,
|
||||||
|
"tag": "0049_noisy_domino",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 50,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1767931290031,
|
||||||
|
"tag": "0050_bumpy_mephistopheles",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { Database, eq, sql, inArray } from "../src/drizzle/index.js"
|
import { Database, and, eq, sql } from "../src/drizzle/index.js"
|
||||||
import { AuthTable } from "../src/schema/auth.sql.js"
|
import { AuthTable } from "../src/schema/auth.sql.js"
|
||||||
import { UserTable } from "../src/schema/user.sql.js"
|
import { UserTable } from "../src/schema/user.sql.js"
|
||||||
import { BillingTable, PaymentTable, UsageTable } from "../src/schema/billing.sql.js"
|
import { BillingTable, PaymentTable, SubscriptionTable, UsageTable } from "../src/schema/billing.sql.js"
|
||||||
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
|
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
|
||||||
|
import { BlackData } from "../src/black.js"
|
||||||
|
import { centsToMicroCents } from "../src/util/price.js"
|
||||||
|
import { getWeekBounds } from "../src/util/date.js"
|
||||||
|
|
||||||
// get input from command line
|
// get input from command line
|
||||||
const identifier = process.argv[2]
|
const identifier = process.argv[2]
|
||||||
@@ -56,6 +59,44 @@ async function printWorkspace(workspaceID: string) {
|
|||||||
|
|
||||||
printHeader(`Workspace "${workspace.name}" (${workspace.id})`)
|
printHeader(`Workspace "${workspace.name}" (${workspace.id})`)
|
||||||
|
|
||||||
|
await printTable("Users", (tx) =>
|
||||||
|
tx
|
||||||
|
.select({
|
||||||
|
authEmail: AuthTable.subject,
|
||||||
|
inviteEmail: UserTable.email,
|
||||||
|
role: UserTable.role,
|
||||||
|
timeSeen: UserTable.timeSeen,
|
||||||
|
monthlyLimit: UserTable.monthlyLimit,
|
||||||
|
monthlyUsage: UserTable.monthlyUsage,
|
||||||
|
timeDeleted: UserTable.timeDeleted,
|
||||||
|
fixedUsage: SubscriptionTable.fixedUsage,
|
||||||
|
rollingUsage: SubscriptionTable.rollingUsage,
|
||||||
|
timeFixedUpdated: SubscriptionTable.timeFixedUpdated,
|
||||||
|
timeRollingUpdated: SubscriptionTable.timeRollingUpdated,
|
||||||
|
timeSubscriptionCreated: SubscriptionTable.timeCreated,
|
||||||
|
})
|
||||||
|
.from(UserTable)
|
||||||
|
.leftJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
|
||||||
|
.leftJoin(SubscriptionTable, eq(SubscriptionTable.userID, UserTable.id))
|
||||||
|
.where(eq(UserTable.workspaceID, workspace.id))
|
||||||
|
.then((rows) =>
|
||||||
|
rows.map((row) => {
|
||||||
|
const subStatus = getSubscriptionStatus(row)
|
||||||
|
return {
|
||||||
|
email: (row.timeDeleted ? "❌ " : "") + (row.authEmail ?? row.inviteEmail),
|
||||||
|
role: row.role,
|
||||||
|
timeSeen: formatDate(row.timeSeen),
|
||||||
|
monthly: formatMonthlyUsage(row.monthlyUsage, row.monthlyLimit),
|
||||||
|
subscribed: formatDate(row.timeSubscriptionCreated),
|
||||||
|
subWeekly: subStatus.weekly,
|
||||||
|
subRolling: subStatus.rolling,
|
||||||
|
rateLimited: subStatus.rateLimited,
|
||||||
|
retryIn: subStatus.retryIn,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
await printTable("Billing", (tx) =>
|
await printTable("Billing", (tx) =>
|
||||||
tx
|
tx
|
||||||
.select({
|
.select({
|
||||||
@@ -124,6 +165,80 @@ async function printWorkspace(workspaceID: string) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatMicroCents(value: number | null | undefined) {
|
||||||
|
if (value === null || value === undefined) return null
|
||||||
|
return `$${(value / 100000000).toFixed(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: Date | null | undefined) {
|
||||||
|
if (!value) return null
|
||||||
|
return value.toISOString().split("T")[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMonthlyUsage(usage: number | null | undefined, limit: number | null | undefined) {
|
||||||
|
const usageText = formatMicroCents(usage) ?? "$0.00"
|
||||||
|
if (limit === null || limit === undefined) return `${usageText} / no limit`
|
||||||
|
return `${usageText} / $${limit.toFixed(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRetryTime(seconds: number) {
|
||||||
|
const days = Math.floor(seconds / 86400)
|
||||||
|
if (days >= 1) return `${days} day${days > 1 ? "s" : ""}`
|
||||||
|
const hours = Math.floor(seconds / 3600)
|
||||||
|
const minutes = Math.ceil((seconds % 3600) / 60)
|
||||||
|
if (hours >= 1) return `${hours}hr ${minutes}min`
|
||||||
|
return `${minutes}min`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSubscriptionStatus(row: {
|
||||||
|
timeSubscriptionCreated: Date | null
|
||||||
|
fixedUsage: number | null
|
||||||
|
rollingUsage: number | null
|
||||||
|
timeFixedUpdated: Date | null
|
||||||
|
timeRollingUpdated: Date | null
|
||||||
|
}) {
|
||||||
|
if (!row.timeSubscriptionCreated) {
|
||||||
|
return { weekly: null, rolling: null, rateLimited: null, retryIn: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const black = BlackData.get()
|
||||||
|
const now = new Date()
|
||||||
|
const week = getWeekBounds(now)
|
||||||
|
|
||||||
|
const fixedLimit = black.fixedLimit ? centsToMicroCents(black.fixedLimit * 100) : null
|
||||||
|
const rollingLimit = black.rollingLimit ? centsToMicroCents(black.rollingLimit * 100) : null
|
||||||
|
const rollingWindowMs = (black.rollingWindow ?? 5) * 3600 * 1000
|
||||||
|
|
||||||
|
// Calculate current weekly usage (reset if outside current week)
|
||||||
|
const currentWeekly =
|
||||||
|
row.fixedUsage && row.timeFixedUpdated && row.timeFixedUpdated >= week.start ? row.fixedUsage : 0
|
||||||
|
|
||||||
|
// Calculate current rolling usage
|
||||||
|
const windowStart = new Date(now.getTime() - rollingWindowMs)
|
||||||
|
const currentRolling =
|
||||||
|
row.rollingUsage && row.timeRollingUpdated && row.timeRollingUpdated >= windowStart ? row.rollingUsage : 0
|
||||||
|
|
||||||
|
// Check rate limiting
|
||||||
|
const isWeeklyLimited = fixedLimit !== null && currentWeekly >= fixedLimit
|
||||||
|
const isRollingLimited = rollingLimit !== null && currentRolling >= rollingLimit
|
||||||
|
|
||||||
|
let retryIn: string | null = null
|
||||||
|
if (isWeeklyLimited) {
|
||||||
|
const retryAfter = Math.ceil((week.end.getTime() - now.getTime()) / 1000)
|
||||||
|
retryIn = formatRetryTime(retryAfter)
|
||||||
|
} else if (isRollingLimited && row.timeRollingUpdated) {
|
||||||
|
const retryAfter = Math.ceil((row.timeRollingUpdated.getTime() + rollingWindowMs - now.getTime()) / 1000)
|
||||||
|
retryIn = formatRetryTime(retryAfter)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
weekly: fixedLimit !== null ? `${formatMicroCents(currentWeekly)} / $${black.fixedLimit}` : null,
|
||||||
|
rolling: rollingLimit !== null ? `${formatMicroCents(currentRolling)} / $${black.rollingLimit}` : null,
|
||||||
|
rateLimited: isWeeklyLimited || isRollingLimited ? "yes" : "no",
|
||||||
|
retryIn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function printHeader(title: string) {
|
function printHeader(title: string) {
|
||||||
console.log()
|
console.log()
|
||||||
console.log("─".repeat(title.length))
|
console.log("─".repeat(title.length))
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
import { Billing } from "../src/billing.js"
|
import { Billing } from "../src/billing.js"
|
||||||
import { Database, eq, and, sql } from "../src/drizzle/index.js"
|
import { and, Database, eq, isNull, sql } from "../src/drizzle/index.js"
|
||||||
import { AuthTable } from "../src/schema/auth.sql.js"
|
|
||||||
import { UserTable } from "../src/schema/user.sql.js"
|
import { UserTable } from "../src/schema/user.sql.js"
|
||||||
import { BillingTable, PaymentTable } from "../src/schema/billing.sql.js"
|
import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js"
|
||||||
import { Identifier } from "../src/identifier.js"
|
import { Identifier } from "../src/identifier.js"
|
||||||
import { centsToMicroCents } from "../src/util/price.js"
|
import { centsToMicroCents } from "../src/util/price.js"
|
||||||
|
import { AuthTable } from "../src/schema/auth.sql.js"
|
||||||
|
|
||||||
const workspaceID = process.argv[2]
|
const workspaceID = process.argv[2]
|
||||||
const email = process.argv[3]
|
const email = process.argv[3]
|
||||||
|
|
||||||
|
console.log(`Onboarding workspace ${workspaceID} for email ${email}`)
|
||||||
|
|
||||||
if (!workspaceID || !email) {
|
if (!workspaceID || !email) {
|
||||||
console.error("Usage: bun onboard-zen-black.ts <workspaceID> <email>")
|
console.error("Usage: bun onboard-zen-black.ts <workspaceID> <email>")
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up the Stripe customer by email
|
// Look up the Stripe customer by email
|
||||||
const customers = await Billing.stripe().customers.list({ email, limit: 1 })
|
const customers = await Billing.stripe().customers.list({ email, limit: 10, expand: ["data.subscriptions"] })
|
||||||
const customer = customers.data[0]
|
if (!customers.data) {
|
||||||
if (!customer) {
|
|
||||||
console.error(`Error: No Stripe customer found for email ${email}`)
|
console.error(`Error: No Stripe customer found for email ${email}`)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
const customerID = customer.id
|
const customer = customers.data.find((c) => c.subscriptions?.data[0]?.items.data[0]?.price.unit_amount === 20000)
|
||||||
|
if (!customer) {
|
||||||
// Get the subscription id
|
console.error(`Error: No Stripe customer found for email ${email} with $200 subscription`)
|
||||||
const subscriptions = await Billing.stripe().subscriptions.list({ customer: customerID, limit: 1 })
|
|
||||||
const subscription = subscriptions.data[0]
|
|
||||||
if (!subscription) {
|
|
||||||
console.error(`Error: Customer ${customerID} does not have a subscription`)
|
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const customerID = customer.id
|
||||||
|
const subscription = customer.subscriptions!.data[0]
|
||||||
const subscriptionID = subscription.id
|
const subscriptionID = subscription.id
|
||||||
|
|
||||||
// Validate the subscription is $200
|
// Validate the subscription is $200
|
||||||
@@ -39,6 +39,12 @@ if (amountInCents !== 20000) {
|
|||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const subscriptionData = await Billing.stripe().subscriptions.retrieve(subscription.id, { expand: ["discounts"] })
|
||||||
|
const couponID =
|
||||||
|
typeof subscriptionData.discounts[0] === "string"
|
||||||
|
? subscriptionData.discounts[0]
|
||||||
|
: subscriptionData.discounts[0]?.coupon?.id
|
||||||
|
|
||||||
// Check if subscription is already tied to another workspace
|
// Check if subscription is already tied to another workspace
|
||||||
const existingSubscription = await Database.use((tx) =>
|
const existingSubscription = await Database.use((tx) =>
|
||||||
tx
|
tx
|
||||||
@@ -90,29 +96,21 @@ const paymentMethod = paymentMethodID ? await Billing.stripe().paymentMethods.re
|
|||||||
const paymentMethodLast4 = paymentMethod?.card?.last4 ?? null
|
const paymentMethodLast4 = paymentMethod?.card?.last4 ?? null
|
||||||
const paymentMethodType = paymentMethod?.type ?? null
|
const paymentMethodType = paymentMethod?.type ?? null
|
||||||
|
|
||||||
// Look up the user by email via AuthTable
|
// Look up the user in the workspace
|
||||||
const auth = await Database.use((tx) =>
|
const users = await Database.use((tx) =>
|
||||||
tx
|
tx
|
||||||
.select({ accountID: AuthTable.accountID })
|
.select({ id: UserTable.id, email: AuthTable.subject })
|
||||||
.from(AuthTable)
|
.from(UserTable)
|
||||||
.where(and(eq(AuthTable.provider, "email"), eq(AuthTable.subject, email)))
|
.innerJoin(AuthTable, and(eq(AuthTable.accountID, UserTable.accountID), eq(AuthTable.provider, "email")))
|
||||||
.then((rows) => rows[0]),
|
.where(and(eq(UserTable.workspaceID, workspaceID), isNull(UserTable.timeDeleted))),
|
||||||
)
|
)
|
||||||
if (!auth) {
|
if (users.length === 0) {
|
||||||
console.error(`Error: No user found with email ${email}`)
|
console.error(`Error: No users found in workspace ${workspaceID}`)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
const user = users.length === 1 ? users[0] : users.find((u) => u.email === email)
|
||||||
// Look up the user in the workspace
|
|
||||||
const user = await Database.use((tx) =>
|
|
||||||
tx
|
|
||||||
.select({ id: UserTable.id })
|
|
||||||
.from(UserTable)
|
|
||||||
.where(and(eq(UserTable.workspaceID, workspaceID), eq(UserTable.accountID, auth.accountID)))
|
|
||||||
.then((rows) => rows[0]),
|
|
||||||
)
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
console.error(`Error: User with email ${email} is not a member of workspace ${workspaceID}`)
|
console.error(`Error: User with email ${email} not found in workspace ${workspaceID}`)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,19 +128,19 @@ await Database.transaction(async (tx) => {
|
|||||||
.set({
|
.set({
|
||||||
customerID,
|
customerID,
|
||||||
subscriptionID,
|
subscriptionID,
|
||||||
|
subscriptionCouponID: couponID,
|
||||||
paymentMethodID,
|
paymentMethodID,
|
||||||
paymentMethodLast4,
|
paymentMethodLast4,
|
||||||
paymentMethodType,
|
paymentMethodType,
|
||||||
})
|
})
|
||||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||||
|
|
||||||
// Set current time as timeSubscribed on user
|
// Create a row in subscription table
|
||||||
await tx
|
await tx.insert(SubscriptionTable).values({
|
||||||
.update(UserTable)
|
workspaceID,
|
||||||
.set({
|
id: Identifier.create("subscription"),
|
||||||
timeSubscribed: sql`now()`,
|
userID: user.id,
|
||||||
})
|
})
|
||||||
.where(eq(UserTable.id, user.id))
|
|
||||||
|
|
||||||
// Create a row in payments table
|
// Create a row in payments table
|
||||||
await tx.insert(PaymentTable).values({
|
await tx.insert(PaymentTable).values({
|
||||||
@@ -152,6 +150,10 @@ await Database.transaction(async (tx) => {
|
|||||||
customerID,
|
customerID,
|
||||||
invoiceID,
|
invoiceID,
|
||||||
paymentID,
|
paymentID,
|
||||||
|
enrichment: {
|
||||||
|
type: "subscription",
|
||||||
|
couponID,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -171,6 +171,9 @@ export namespace Billing {
|
|||||||
workspaceID,
|
workspaceID,
|
||||||
id: Identifier.create("payment"),
|
id: Identifier.create("payment"),
|
||||||
amount: amountInMicroCents,
|
amount: amountInMicroCents,
|
||||||
|
enrichment: {
|
||||||
|
type: "credit",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
return amountInMicroCents
|
return amountInMicroCents
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { Resource } from "@opencode-ai/console-resource"
|
|||||||
|
|
||||||
export namespace BlackData {
|
export namespace BlackData {
|
||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
monthlyLimit: z.number().int(),
|
fixedLimit: z.number().int(),
|
||||||
intervalLimit: z.number().int(),
|
rollingLimit: z.number().int(),
|
||||||
intervalLength: z.number().int(),
|
rollingWindow: z.number().int(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const validate = fn(Schema, (input) => {
|
export const validate = fn(Schema, (input) => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export namespace Identifier {
|
|||||||
model: "mod",
|
model: "mod",
|
||||||
payment: "pay",
|
payment: "pay",
|
||||||
provider: "prv",
|
provider: "prv",
|
||||||
|
subscription: "sub",
|
||||||
usage: "usg",
|
usage: "usg",
|
||||||
user: "usr",
|
user: "usr",
|
||||||
workspace: "wrk",
|
workspace: "wrk",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const BillingTable = mysqlTable(
|
|||||||
timeReloadError: utc("time_reload_error"),
|
timeReloadError: utc("time_reload_error"),
|
||||||
timeReloadLockedTill: utc("time_reload_locked_till"),
|
timeReloadLockedTill: utc("time_reload_locked_till"),
|
||||||
subscriptionID: varchar("subscription_id", { length: 28 }),
|
subscriptionID: varchar("subscription_id", { length: 28 }),
|
||||||
|
subscriptionCouponID: varchar("subscription_coupon_id", { length: 28 }),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
...workspaceIndexes(table),
|
...workspaceIndexes(table),
|
||||||
@@ -30,6 +31,20 @@ export const BillingTable = mysqlTable(
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const SubscriptionTable = mysqlTable(
|
||||||
|
"subscription",
|
||||||
|
{
|
||||||
|
...workspaceColumns,
|
||||||
|
...timestamps,
|
||||||
|
userID: ulid("user_id").notNull(),
|
||||||
|
rollingUsage: bigint("rolling_usage", { mode: "number" }),
|
||||||
|
fixedUsage: bigint("fixed_usage", { mode: "number" }),
|
||||||
|
timeRollingUpdated: utc("time_rolling_updated"),
|
||||||
|
timeFixedUpdated: utc("time_fixed_updated"),
|
||||||
|
},
|
||||||
|
(table) => [...workspaceIndexes(table), uniqueIndex("workspace_user_id").on(table.workspaceID, table.userID)],
|
||||||
|
)
|
||||||
|
|
||||||
export const PaymentTable = mysqlTable(
|
export const PaymentTable = mysqlTable(
|
||||||
"payment",
|
"payment",
|
||||||
{
|
{
|
||||||
@@ -40,6 +55,15 @@ export const PaymentTable = mysqlTable(
|
|||||||
paymentID: varchar("payment_id", { length: 255 }),
|
paymentID: varchar("payment_id", { length: 255 }),
|
||||||
amount: bigint("amount", { mode: "number" }).notNull(),
|
amount: bigint("amount", { mode: "number" }).notNull(),
|
||||||
timeRefunded: utc("time_refunded"),
|
timeRefunded: utc("time_refunded"),
|
||||||
|
enrichment: json("enrichment").$type<
|
||||||
|
| {
|
||||||
|
type: "subscription"
|
||||||
|
couponID?: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "credit"
|
||||||
|
}
|
||||||
|
>(),
|
||||||
},
|
},
|
||||||
(table) => [...workspaceIndexes(table)],
|
(table) => [...workspaceIndexes(table)],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,12 +18,6 @@ export const UserTable = mysqlTable(
|
|||||||
monthlyLimit: int("monthly_limit"),
|
monthlyLimit: int("monthly_limit"),
|
||||||
monthlyUsage: bigint("monthly_usage", { mode: "number" }),
|
monthlyUsage: bigint("monthly_usage", { mode: "number" }),
|
||||||
timeMonthlyUsageUpdated: utc("time_monthly_usage_updated"),
|
timeMonthlyUsageUpdated: utc("time_monthly_usage_updated"),
|
||||||
// subscription
|
|
||||||
timeSubscribed: utc("time_subscribed"),
|
|
||||||
subIntervalUsage: bigint("sub_interval_usage", { mode: "number" }),
|
|
||||||
subMonthlyUsage: bigint("sub_monthly_usage", { mode: "number" }),
|
|
||||||
timeSubIntervalUsageUpdated: utc("sub_time_interval_usage_updated"),
|
|
||||||
timeSubMonthlyUsageUpdated: utc("sub_time_monthly_usage_updated"),
|
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
...workspaceIndexes(table),
|
...workspaceIndexes(table),
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export function getWeekBounds(date: Date) {
|
||||||
|
const dayOfWeek = date.getUTCDay()
|
||||||
|
const start = new Date(date)
|
||||||
|
start.setUTCDate(date.getUTCDate() - dayOfWeek + 1)
|
||||||
|
start.setUTCHours(0, 0, 0, 0)
|
||||||
|
const end = new Date(start)
|
||||||
|
end.setUTCDate(start.getUTCDate() + 7)
|
||||||
|
return { start, end }
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
|
|||||||
@@ -13,36 +13,7 @@
|
|||||||
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
|
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
|
||||||
<meta property="og:image" content="/social-share.png" />
|
<meta property="og:image" content="/social-share.png" />
|
||||||
<meta property="twitter:image" content="/social-share.png" />
|
<meta property="twitter:image" content="/social-share.png" />
|
||||||
<script id="oc-theme-preload-script">
|
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
|
||||||
;(function () {
|
|
||||||
var themeId = localStorage.getItem("opencode-theme-id")
|
|
||||||
if (!themeId) return
|
|
||||||
|
|
||||||
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
|
|
||||||
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
|
|
||||||
var mode = isDark ? "dark" : "light"
|
|
||||||
|
|
||||||
document.documentElement.dataset.theme = themeId
|
|
||||||
document.documentElement.dataset.colorScheme = mode
|
|
||||||
|
|
||||||
if (themeId === "oc-1") return
|
|
||||||
|
|
||||||
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
|
|
||||||
if (css) {
|
|
||||||
var style = document.createElement("style")
|
|
||||||
style.id = "oc-theme-preload"
|
|
||||||
style.textContent =
|
|
||||||
":root{color-scheme:" +
|
|
||||||
mode +
|
|
||||||
";--text-mix-blend-mode:" +
|
|
||||||
(isDark ? "plus-lighter" : "multiply") +
|
|
||||||
";" +
|
|
||||||
css +
|
|
||||||
"}"
|
|
||||||
document.head.appendChild(style)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
</script>
|
|
||||||
</head>
|
</head>
|
||||||
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
const CLI_INSTALL_DIR: &str = ".opencode/bin";
|
const CLI_INSTALL_DIR: &str = ".opencode/bin";
|
||||||
const CLI_BINARY_NAME: &str = "opencode";
|
const CLI_BINARY_NAME: &str = "opencode";
|
||||||
|
|
||||||
@@ -9,9 +11,10 @@ fn get_cli_install_path() -> Option<std::path::PathBuf> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_sidecar_path() -> std::path::PathBuf {
|
pub fn get_sidecar_path(app: &tauri::AppHandle) -> std::path::PathBuf {
|
||||||
tauri::utils::platform::current_exe()
|
// Get binary with symlinks support
|
||||||
.expect("Failed to get current exe")
|
tauri::process::current_binary(&app.env())
|
||||||
|
.expect("Failed to get current binary")
|
||||||
.parent()
|
.parent()
|
||||||
.expect("Failed to get parent dir")
|
.expect("Failed to get parent dir")
|
||||||
.join("opencode-cli")
|
.join("opencode-cli")
|
||||||
@@ -26,12 +29,12 @@ fn is_cli_installed() -> bool {
|
|||||||
const INSTALL_SCRIPT: &str = include_str!("../../../../install");
|
const INSTALL_SCRIPT: &str = include_str!("../../../../install");
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn install_cli() -> Result<String, String> {
|
pub fn install_cli(app: tauri::AppHandle) -> Result<String, String> {
|
||||||
if cfg!(not(unix)) {
|
if cfg!(not(unix)) {
|
||||||
return Err("CLI installation is only supported on macOS & Linux".to_string());
|
return Err("CLI installation is only supported on macOS & Linux".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let sidecar = get_sidecar_path();
|
let sidecar = get_sidecar_path(&app);
|
||||||
if !sidecar.exists() {
|
if !sidecar.exists() {
|
||||||
return Err("Sidecar binary not found".to_string());
|
return Err("Sidecar binary not found".to_string());
|
||||||
}
|
}
|
||||||
@@ -108,7 +111,7 @@ pub fn sync_cli(app: tauri::AppHandle) -> Result<(), String> {
|
|||||||
cli_version, app_version
|
cli_version, app_version
|
||||||
);
|
);
|
||||||
|
|
||||||
install_cli()?;
|
install_cli(app)?;
|
||||||
|
|
||||||
println!("Synced installed CLI");
|
println!("Synced installed CLI");
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ use tauri::{
|
|||||||
};
|
};
|
||||||
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
||||||
use tauri_plugin_shell::ShellExt;
|
use tauri_plugin_shell::ShellExt;
|
||||||
use tauri_plugin_store::StoreExt;
|
|
||||||
use tokio::net::TcpSocket;
|
use tokio::net::TcpSocket;
|
||||||
|
|
||||||
use crate::window_customizer::PinchZoomDisablePlugin;
|
use crate::window_customizer::PinchZoomDisablePlugin;
|
||||||
@@ -46,65 +45,6 @@ impl ServerState {
|
|||||||
struct LogState(Arc<Mutex<VecDeque<String>>>);
|
struct LogState(Arc<Mutex<VecDeque<String>>>);
|
||||||
|
|
||||||
const MAX_LOG_ENTRIES: usize = 200;
|
const MAX_LOG_ENTRIES: usize = 200;
|
||||||
const GLOBAL_STORAGE: &str = "opencode.global.dat";
|
|
||||||
|
|
||||||
/// Check if a URL's origin matches any configured server in the store.
|
|
||||||
/// Returns true if the URL should be allowed for internal navigation.
|
|
||||||
fn is_allowed_server(app: &AppHandle, url: &tauri::Url) -> bool {
|
|
||||||
// Always allow localhost and 127.0.0.1
|
|
||||||
if let Some(host) = url.host_str() {
|
|
||||||
if host == "localhost" || host == "127.0.0.1" {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to read the server list from the store
|
|
||||||
let Ok(store) = app.store(GLOBAL_STORAGE) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(server_data) = store.get("server") else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Parse the server list from the stored JSON
|
|
||||||
let Some(list) = server_data.get("list").and_then(|v| v.as_array()) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get the origin of the navigation URL (scheme + host + port)
|
|
||||||
let url_origin = format!(
|
|
||||||
"{}://{}{}",
|
|
||||||
url.scheme(),
|
|
||||||
url.host_str().unwrap_or(""),
|
|
||||||
url.port().map(|p| format!(":{}", p)).unwrap_or_default()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if any configured server matches the URL's origin
|
|
||||||
for server in list {
|
|
||||||
let Some(server_url) = server.as_str() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Parse the server URL to extract its origin
|
|
||||||
let Ok(parsed) = tauri::Url::parse(server_url) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let server_origin = format!(
|
|
||||||
"{}://{}{}",
|
|
||||||
parsed.scheme(),
|
|
||||||
parsed.host_str().unwrap_or(""),
|
|
||||||
parsed.port().map(|p| format!(":{}", p)).unwrap_or_default()
|
|
||||||
);
|
|
||||||
|
|
||||||
if url_origin == server_origin {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn kill_sidecar(app: AppHandle) {
|
fn kill_sidecar(app: AppHandle) {
|
||||||
@@ -189,7 +129,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
|
|||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
let (mut rx, child) = {
|
let (mut rx, child) = {
|
||||||
let sidecar = get_sidecar_path();
|
let sidecar = get_sidecar_path(app);
|
||||||
let shell = get_user_shell();
|
let shell = get_user_shell();
|
||||||
app.shell()
|
app.shell()
|
||||||
.command(&shell)
|
.command(&shell)
|
||||||
@@ -296,7 +236,6 @@ pub fn run() {
|
|||||||
.unwrap_or(LogicalSize::new(1920, 1080));
|
.unwrap_or(LogicalSize::new(1920, 1080));
|
||||||
|
|
||||||
// Create window immediately with serverReady = false
|
// Create window immediately with serverReady = false
|
||||||
let app_for_nav = app.clone();
|
|
||||||
let mut window_builder =
|
let mut window_builder =
|
||||||
WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
|
WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
|
||||||
.title("OpenCode")
|
.title("OpenCode")
|
||||||
@@ -304,22 +243,6 @@ pub fn run() {
|
|||||||
.decorations(true)
|
.decorations(true)
|
||||||
.zoom_hotkeys_enabled(true)
|
.zoom_hotkeys_enabled(true)
|
||||||
.disable_drag_drop_handler()
|
.disable_drag_drop_handler()
|
||||||
.on_navigation(move |url| {
|
|
||||||
// Allow internal navigation (tauri:// scheme)
|
|
||||||
if url.scheme() == "tauri" {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Allow navigation to configured servers (localhost, 127.0.0.1, or remote)
|
|
||||||
if is_allowed_server(&app_for_nav, url) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Open external http/https URLs in default browser
|
|
||||||
if url.scheme() == "http" || url.scheme() == "https" {
|
|
||||||
let _ = app_for_nav.shell().open(url.as_str(), None);
|
|
||||||
return false; // Cancel internal navigation
|
|
||||||
}
|
|
||||||
true
|
|
||||||
})
|
|
||||||
.initialization_script(format!(
|
.initialization_script(format!(
|
||||||
r#"
|
r#"
|
||||||
window.__OPENCODE__ ??= {{}};
|
window.__OPENCODE__ ??= {{}};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const host = process.env.TAURI_DEV_HOST
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [appPlugin],
|
plugins: [appPlugin],
|
||||||
|
publicDir: "../app/public",
|
||||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||||
//
|
//
|
||||||
// 1. prevent Vite from obscuring rust errors
|
// 1. prevent Vite from obscuring rust errors
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
id = "opencode"
|
id = "opencode"
|
||||||
name = "OpenCode"
|
name = "OpenCode"
|
||||||
description = "The open source coding agent."
|
description = "The open source coding agent."
|
||||||
version = "1.1.7"
|
version = "1.1.8"
|
||||||
schema_version = 1
|
schema_version = 1
|
||||||
authors = ["Anomaly"]
|
authors = ["Anomaly"]
|
||||||
repository = "https://github.com/anomalyco/opencode"
|
repository = "https://github.com/anomalyco/opencode"
|
||||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
|||||||
icon = "./icons/opencode.svg"
|
icon = "./icons/opencode.svg"
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-aarch64]
|
[agent_servers.opencode.targets.darwin-aarch64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.7/opencode-darwin-arm64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.8/opencode-darwin-arm64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-x86_64]
|
[agent_servers.opencode.targets.darwin-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.7/opencode-darwin-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.8/opencode-darwin-x64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-aarch64]
|
[agent_servers.opencode.targets.linux-aarch64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.7/opencode-linux-arm64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.8/opencode-linux-arm64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-x86_64]
|
[agent_servers.opencode.targets.linux-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.7/opencode-linux-x64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.8/opencode-linux-x64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.windows-x86_64]
|
[agent_servers.opencode.targets.windows-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.7/opencode-windows-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.8/opencode-windows-x64.zip"
|
||||||
cmd = "./opencode.exe"
|
cmd = "./opencode.exe"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -81,8 +81,8 @@
|
|||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
"@openrouter/ai-sdk-provider": "1.5.2",
|
"@openrouter/ai-sdk-provider": "1.5.2",
|
||||||
"@opentui/core": "0.1.70",
|
"@opentui/core": "0.1.72",
|
||||||
"@opentui/solid": "0.1.70",
|
"@opentui/solid": "0.1.72",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Global } from "../global"
|
|||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
|
||||||
|
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
|
||||||
|
|
||||||
export namespace Auth {
|
export namespace Auth {
|
||||||
export const Oauth = z
|
export const Oauth = z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -341,8 +341,6 @@ export const AuthLoginCommand = cmd({
|
|||||||
"Configure via opencode.json options (profile, region, endpoint) or\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).",
|
||||||
)
|
)
|
||||||
prompts.outro("Done")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider === "opencode") {
|
if (provider === "opencode") {
|
||||||
|
|||||||
@@ -33,16 +33,19 @@ export function DialogModel(props: { providerID?: string }) {
|
|||||||
|
|
||||||
const options = createMemo(() => {
|
const options = createMemo(() => {
|
||||||
const q = query()
|
const q = query()
|
||||||
const favorites = showExtra() ? local.model.favorite() : []
|
const needle = q.trim()
|
||||||
|
const showSections = showExtra() && needle.length === 0
|
||||||
|
const favorites = connected() ? local.model.favorite() : []
|
||||||
const recents = local.model.recent()
|
const recents = local.model.recent()
|
||||||
|
|
||||||
const recentList = showExtra()
|
const recentList = showSections
|
||||||
? recents.filter(
|
? recents.filter(
|
||||||
(item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID),
|
(item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID),
|
||||||
)
|
)
|
||||||
: []
|
: []
|
||||||
|
|
||||||
const favoriteOptions = favorites.flatMap((item) => {
|
const favoriteOptions = showSections
|
||||||
|
? favorites.flatMap((item) => {
|
||||||
const provider = sync.data.provider.find((x) => x.id === item.providerID)
|
const provider = sync.data.provider.find((x) => x.id === item.providerID)
|
||||||
if (!provider) return []
|
if (!provider) return []
|
||||||
const model = provider.models[item.modelID]
|
const model = provider.models[item.modelID]
|
||||||
@@ -72,8 +75,10 @@ export function DialogModel(props: { providerID?: string }) {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
: []
|
||||||
|
|
||||||
const recentOptions = recentList.flatMap((item) => {
|
const recentOptions = showSections
|
||||||
|
? recentList.flatMap((item) => {
|
||||||
const provider = sync.data.provider.find((x) => x.id === item.providerID)
|
const provider = sync.data.provider.find((x) => x.id === item.providerID)
|
||||||
if (!provider) return []
|
if (!provider) return []
|
||||||
const model = provider.models[item.modelID]
|
const model = provider.models[item.modelID]
|
||||||
@@ -103,6 +108,7 @@ export function DialogModel(props: { providerID?: string }) {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
: []
|
||||||
|
|
||||||
const providerOptions = pipe(
|
const providerOptions = pipe(
|
||||||
sync.data.provider,
|
sync.data.provider,
|
||||||
@@ -145,6 +151,7 @@ export function DialogModel(props: { providerID?: string }) {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
filter((x) => {
|
filter((x) => {
|
||||||
|
if (!showSections) return true
|
||||||
const value = x.value
|
const value = x.value
|
||||||
const inFavorites = favorites.some(
|
const inFavorites = favorites.some(
|
||||||
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
|
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
|
||||||
@@ -177,16 +184,11 @@ export function DialogModel(props: { providerID?: string }) {
|
|||||||
)
|
)
|
||||||
: []
|
: []
|
||||||
|
|
||||||
// Apply fuzzy filtering to each section separately, maintaining section order
|
// Search shows a single merged list (favorites inline)
|
||||||
if (q) {
|
if (needle) {
|
||||||
const filteredFavorites = fuzzysort.go(q, favoriteOptions, { keys: ["title"] }).map((x) => x.obj)
|
const filteredProviders = fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj)
|
||||||
const filteredRecents = fuzzysort
|
const filteredPopular = fuzzysort.go(needle, popularProviders, { keys: ["title"] }).map((x) => x.obj)
|
||||||
.go(q, recentOptions, { keys: ["title"] })
|
return [...filteredProviders, ...filteredPopular]
|
||||||
.map((x) => x.obj)
|
|
||||||
.slice(0, 5)
|
|
||||||
const filteredProviders = fuzzysort.go(q, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj)
|
|
||||||
const filteredPopular = fuzzysort.go(q, popularProviders, { keys: ["title"] }).map((x) => x.obj)
|
|
||||||
return [...filteredFavorites, ...filteredRecents, ...filteredProviders, ...filteredPopular]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders]
|
return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders]
|
||||||
|
|||||||
@@ -178,6 +178,8 @@ export namespace Config {
|
|||||||
result.compaction = { ...result.compaction, prune: false }
|
result.compaction = { ...result.compaction, prune: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
result.plugin = deduplicatePlugins(result.plugin ?? [])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
config: result,
|
config: result,
|
||||||
directories,
|
directories,
|
||||||
@@ -332,6 +334,58 @@ export namespace Config {
|
|||||||
return plugins
|
return plugins
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts a canonical plugin name from a plugin specifier.
|
||||||
|
* - For file:// URLs: extracts filename without extension
|
||||||
|
* - For npm packages: extracts package name without version
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* getPluginName("file:///path/to/plugin/foo.js") // "foo"
|
||||||
|
* getPluginName("oh-my-opencode@2.4.3") // "oh-my-opencode"
|
||||||
|
* getPluginName("@scope/pkg@1.0.0") // "@scope/pkg"
|
||||||
|
*/
|
||||||
|
export function getPluginName(plugin: string): string {
|
||||||
|
if (plugin.startsWith("file://")) {
|
||||||
|
return path.parse(new URL(plugin).pathname).name
|
||||||
|
}
|
||||||
|
const lastAt = plugin.lastIndexOf("@")
|
||||||
|
if (lastAt > 0) {
|
||||||
|
return plugin.substring(0, lastAt)
|
||||||
|
}
|
||||||
|
return plugin
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicates plugins by name, with later entries (higher priority) winning.
|
||||||
|
* Priority order (highest to lowest):
|
||||||
|
* 1. Local plugin/ directory
|
||||||
|
* 2. Local opencode.json
|
||||||
|
* 3. Global plugin/ directory
|
||||||
|
* 4. Global opencode.json
|
||||||
|
*
|
||||||
|
* Since plugins are added in low-to-high priority order,
|
||||||
|
* we reverse, deduplicate (keeping first occurrence), then restore order.
|
||||||
|
*/
|
||||||
|
export function deduplicatePlugins(plugins: string[]): string[] {
|
||||||
|
// seenNames: canonical plugin names for duplicate detection
|
||||||
|
// e.g., "oh-my-opencode", "@scope/pkg"
|
||||||
|
const seenNames = new Set<string>()
|
||||||
|
|
||||||
|
// uniqueSpecifiers: full plugin specifiers to return
|
||||||
|
// e.g., "oh-my-opencode@2.4.3", "file:///path/to/plugin.js"
|
||||||
|
const uniqueSpecifiers: string[] = []
|
||||||
|
|
||||||
|
for (const specifier of plugins.toReversed()) {
|
||||||
|
const name = getPluginName(specifier)
|
||||||
|
if (!seenNames.has(name)) {
|
||||||
|
seenNames.add(name)
|
||||||
|
uniqueSpecifiers.push(specifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueSpecifiers.toReversed()
|
||||||
|
}
|
||||||
|
|
||||||
export const McpLocal = z
|
export const McpLocal = z
|
||||||
.object({
|
.object({
|
||||||
type: z.literal("local").describe("Type of MCP server connection"),
|
type: z.literal("local").describe("Type of MCP server connection"),
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ await Promise.all([
|
|||||||
fs.mkdir(Global.Path.bin, { recursive: true }),
|
fs.mkdir(Global.Path.bin, { recursive: true }),
|
||||||
])
|
])
|
||||||
|
|
||||||
const CACHE_VERSION = "14"
|
const CACHE_VERSION = "16"
|
||||||
|
|
||||||
const version = await Bun.file(path.join(Global.Path.cache, "version"))
|
const version = await Bun.file(path.join(Global.Path.cache, "version"))
|
||||||
.text()
|
.text()
|
||||||
|
|||||||
@@ -99,14 +99,16 @@ const cli = yargs(hideBin(process.argv))
|
|||||||
.command(GithubCommand)
|
.command(GithubCommand)
|
||||||
.command(PrCommand)
|
.command(PrCommand)
|
||||||
.command(SessionCommand)
|
.command(SessionCommand)
|
||||||
.fail((msg) => {
|
.fail((msg, err) => {
|
||||||
if (
|
if (
|
||||||
msg?.startsWith("Unknown argument") ||
|
msg?.startsWith("Unknown argument") ||
|
||||||
msg?.startsWith("Not enough non-option arguments") ||
|
msg?.startsWith("Not enough non-option arguments") ||
|
||||||
msg?.startsWith("Invalid values:")
|
msg?.startsWith("Invalid values:")
|
||||||
) {
|
) {
|
||||||
|
if (err) throw err
|
||||||
cli.showHelp("log")
|
cli.showHelp("log")
|
||||||
}
|
}
|
||||||
|
if (err) throw err
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
import { OAUTH_DUMMY_KEY } from "../auth"
|
||||||
|
|
||||||
|
const log = Log.create({ service: "plugin.codex" })
|
||||||
|
|
||||||
|
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||||
|
const ISSUER = "https://auth.openai.com"
|
||||||
|
const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
|
||||||
|
const OAUTH_PORT = 1455
|
||||||
|
|
||||||
|
interface PkceCodes {
|
||||||
|
verifier: string
|
||||||
|
challenge: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generatePKCE(): Promise<PkceCodes> {
|
||||||
|
const verifier = generateRandomString(43)
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const data = encoder.encode(verifier)
|
||||||
|
const hash = await crypto.subtle.digest("SHA-256", data)
|
||||||
|
const challenge = base64UrlEncode(hash)
|
||||||
|
return { verifier, challenge }
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateRandomString(length: number): string {
|
||||||
|
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(length))
|
||||||
|
return Array.from(bytes)
|
||||||
|
.map((b) => chars[b % chars.length])
|
||||||
|
.join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlEncode(buffer: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buffer)
|
||||||
|
const binary = String.fromCharCode(...bytes)
|
||||||
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateState(): string {
|
||||||
|
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAuthorizeUrl(redirectUri: string, pkce: PkceCodes, state: string): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
response_type: "code",
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
scope: "openid profile email offline_access",
|
||||||
|
code_challenge: pkce.challenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
id_token_add_organizations: "true",
|
||||||
|
codex_cli_simplified_flow: "true",
|
||||||
|
state,
|
||||||
|
originator: "opencode",
|
||||||
|
})
|
||||||
|
return `${ISSUER}/oauth/authorize?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TokenResponse {
|
||||||
|
id_token: string
|
||||||
|
access_token: string
|
||||||
|
refresh_token: string
|
||||||
|
expires_in?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise<TokenResponse> {
|
||||||
|
const response = await fetch(`${ISSUER}/oauth/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
code_verifier: pkce.verifier,
|
||||||
|
}).toString(),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Token exchange failed: ${response.status}`)
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
|
||||||
|
const response = await fetch(`${ISSUER}/oauth/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
}).toString(),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Token refresh failed: ${response.status}`)
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
const HTML_SUCCESS = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>OpenCode - Codex Authorization Successful</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
|
||||||
|
.container { text-align: center; padding: 2rem; }
|
||||||
|
h1 { color: #4ade80; margin-bottom: 1rem; }
|
||||||
|
p { color: #aaa; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Authorization Successful</h1>
|
||||||
|
<p>You can close this window and return to OpenCode.</p>
|
||||||
|
</div>
|
||||||
|
<script>setTimeout(() => window.close(), 2000);</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
const HTML_ERROR = (error: string) => `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>OpenCode - Codex Authorization Failed</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
|
||||||
|
.container { text-align: center; padding: 2rem; }
|
||||||
|
h1 { color: #f87171; margin-bottom: 1rem; }
|
||||||
|
p { color: #aaa; }
|
||||||
|
.error { color: #fca5a5; font-family: monospace; margin-top: 1rem; padding: 1rem; background: rgba(248,113,113,0.1); border-radius: 0.5rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Authorization Failed</h1>
|
||||||
|
<p>An error occurred during authorization.</p>
|
||||||
|
<div class="error">${error}</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
interface PendingOAuth {
|
||||||
|
pkce: PkceCodes
|
||||||
|
state: string
|
||||||
|
resolve: (tokens: TokenResponse) => void
|
||||||
|
reject: (error: Error) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let oauthServer: ReturnType<typeof Bun.serve> | undefined
|
||||||
|
let pendingOAuth: PendingOAuth | undefined
|
||||||
|
|
||||||
|
async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> {
|
||||||
|
if (oauthServer) {
|
||||||
|
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
|
||||||
|
}
|
||||||
|
|
||||||
|
oauthServer = Bun.serve({
|
||||||
|
port: OAUTH_PORT,
|
||||||
|
fetch(req) {
|
||||||
|
const url = new URL(req.url)
|
||||||
|
|
||||||
|
if (url.pathname === "/auth/callback") {
|
||||||
|
const code = url.searchParams.get("code")
|
||||||
|
const state = url.searchParams.get("state")
|
||||||
|
const error = url.searchParams.get("error")
|
||||||
|
const errorDescription = url.searchParams.get("error_description")
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const errorMsg = errorDescription || error
|
||||||
|
pendingOAuth?.reject(new Error(errorMsg))
|
||||||
|
pendingOAuth = undefined
|
||||||
|
return new Response(HTML_ERROR(errorMsg), {
|
||||||
|
headers: { "Content-Type": "text/html" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
const errorMsg = "Missing authorization code"
|
||||||
|
pendingOAuth?.reject(new Error(errorMsg))
|
||||||
|
pendingOAuth = undefined
|
||||||
|
return new Response(HTML_ERROR(errorMsg), {
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "text/html" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pendingOAuth || state !== pendingOAuth.state) {
|
||||||
|
const errorMsg = "Invalid state - potential CSRF attack"
|
||||||
|
pendingOAuth?.reject(new Error(errorMsg))
|
||||||
|
pendingOAuth = undefined
|
||||||
|
return new Response(HTML_ERROR(errorMsg), {
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "text/html" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = pendingOAuth
|
||||||
|
pendingOAuth = undefined
|
||||||
|
|
||||||
|
exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
|
||||||
|
.then((tokens) => current.resolve(tokens))
|
||||||
|
.catch((err) => current.reject(err))
|
||||||
|
|
||||||
|
return new Response(HTML_SUCCESS, {
|
||||||
|
headers: { "Content-Type": "text/html" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/cancel") {
|
||||||
|
pendingOAuth?.reject(new Error("Login cancelled"))
|
||||||
|
pendingOAuth = undefined
|
||||||
|
return new Response("Login cancelled", { status: 200 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("Not found", { status: 404 })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
log.info("codex oauth server started", { port: OAUTH_PORT })
|
||||||
|
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopOAuthServer() {
|
||||||
|
if (oauthServer) {
|
||||||
|
oauthServer.stop()
|
||||||
|
oauthServer = undefined
|
||||||
|
log.info("codex oauth server stopped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResponse> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(
|
||||||
|
() => {
|
||||||
|
if (pendingOAuth) {
|
||||||
|
pendingOAuth = undefined
|
||||||
|
reject(new Error("OAuth callback timeout - authorization took too long"))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
5 * 60 * 1000,
|
||||||
|
) // 5 minute timeout
|
||||||
|
|
||||||
|
pendingOAuth = {
|
||||||
|
pkce,
|
||||||
|
state,
|
||||||
|
resolve: (tokens) => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
resolve(tokens)
|
||||||
|
},
|
||||||
|
reject: (error) => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
reject(error)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||||
|
return {
|
||||||
|
auth: {
|
||||||
|
provider: "openai",
|
||||||
|
async loader(getAuth, provider) {
|
||||||
|
const auth = await getAuth()
|
||||||
|
if (auth.type !== "oauth") return {}
|
||||||
|
|
||||||
|
// Filter models to only allowed Codex models for OAuth
|
||||||
|
const allowedModels = new Set(["gpt-5.1-codex-max", "gpt-5.1-codex-mini", "gpt-5.2", "gpt-5.2-codex"])
|
||||||
|
for (const modelId of Object.keys(provider.models)) {
|
||||||
|
if (!allowedModels.has(modelId)) {
|
||||||
|
delete provider.models[modelId]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!provider.models["gpt-5.2-codex"]) {
|
||||||
|
provider.models["gpt-5.2-codex"] = {
|
||||||
|
id: "gpt-5.2-codex",
|
||||||
|
providerID: "openai",
|
||||||
|
api: {
|
||||||
|
id: "gpt-5.2-codex",
|
||||||
|
url: "https://chatgpt.com/backend-api/codex",
|
||||||
|
npm: "@ai-sdk/openai",
|
||||||
|
},
|
||||||
|
name: "GPT-5.2 Codex",
|
||||||
|
capabilities: {
|
||||||
|
temperature: false,
|
||||||
|
reasoning: true,
|
||||||
|
attachment: true,
|
||||||
|
toolcall: true,
|
||||||
|
input: { text: true, audio: false, image: true, video: false, pdf: false },
|
||||||
|
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||||
|
},
|
||||||
|
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||||
|
limit: { context: 400000, output: 128000 },
|
||||||
|
status: "active",
|
||||||
|
options: {},
|
||||||
|
headers: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zero out costs for Codex (included with ChatGPT subscription)
|
||||||
|
for (const model of Object.values(provider.models)) {
|
||||||
|
model.cost = {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cache: { read: 0, write: 0 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiKey: OAUTH_DUMMY_KEY,
|
||||||
|
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
|
||||||
|
// Remove dummy API key authorization header
|
||||||
|
if (init?.headers) {
|
||||||
|
if (init.headers instanceof Headers) {
|
||||||
|
init.headers.delete("authorization")
|
||||||
|
init.headers.delete("Authorization")
|
||||||
|
} else if (Array.isArray(init.headers)) {
|
||||||
|
init.headers = init.headers.filter(([key]) => key.toLowerCase() !== "authorization")
|
||||||
|
} else {
|
||||||
|
delete init.headers["authorization"]
|
||||||
|
delete init.headers["Authorization"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentAuth = await getAuth()
|
||||||
|
if (currentAuth.type !== "oauth") return fetch(requestInput, init)
|
||||||
|
|
||||||
|
// Check if token needs refresh
|
||||||
|
if (!currentAuth.access || currentAuth.expires < Date.now()) {
|
||||||
|
log.info("refreshing codex access token")
|
||||||
|
const tokens = await refreshAccessToken(currentAuth.refresh)
|
||||||
|
await input.client.auth.set({
|
||||||
|
path: { id: "codex" },
|
||||||
|
body: {
|
||||||
|
type: "oauth",
|
||||||
|
refresh: tokens.refresh_token,
|
||||||
|
access: tokens.access_token,
|
||||||
|
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
currentAuth.access = tokens.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build headers
|
||||||
|
const headers = new Headers()
|
||||||
|
if (init?.headers) {
|
||||||
|
if (init.headers instanceof Headers) {
|
||||||
|
init.headers.forEach((value, key) => headers.set(key, value))
|
||||||
|
} else if (Array.isArray(init.headers)) {
|
||||||
|
for (const [key, value] of init.headers) {
|
||||||
|
if (value !== undefined) headers.set(key, String(value))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const [key, value] of Object.entries(init.headers)) {
|
||||||
|
if (value !== undefined) headers.set(key, String(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set authorization header with access token
|
||||||
|
headers.set("authorization", `Bearer ${currentAuth.access}`)
|
||||||
|
|
||||||
|
// Rewrite URL to Codex endpoint
|
||||||
|
let url: URL
|
||||||
|
if (typeof requestInput === "string") {
|
||||||
|
url = new URL(requestInput)
|
||||||
|
} else if (requestInput instanceof URL) {
|
||||||
|
url = requestInput
|
||||||
|
} else {
|
||||||
|
url = new URL(requestInput.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is a messages/responses request, redirect to Codex endpoint
|
||||||
|
if (url.pathname.includes("/v1/responses") || url.pathname.includes("/chat/completions")) {
|
||||||
|
url = new URL(CODEX_API_ENDPOINT)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(url, {
|
||||||
|
...init,
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: [
|
||||||
|
{
|
||||||
|
label: "ChatGPT Pro/Plus",
|
||||||
|
type: "oauth",
|
||||||
|
authorize: async () => {
|
||||||
|
const { redirectUri } = await startOAuthServer()
|
||||||
|
const pkce = await generatePKCE()
|
||||||
|
const state = generateState()
|
||||||
|
const authUrl = buildAuthorizeUrl(redirectUri, pkce, state)
|
||||||
|
|
||||||
|
const callbackPromise = waitForOAuthCallback(pkce, state)
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: authUrl,
|
||||||
|
instructions: "Complete authorization in your browser. This window will close automatically.",
|
||||||
|
method: "auto" as const,
|
||||||
|
callback: async () => {
|
||||||
|
const tokens = await callbackPromise
|
||||||
|
stopOAuthServer()
|
||||||
|
return {
|
||||||
|
type: "success" as const,
|
||||||
|
refresh: tokens.refresh_token,
|
||||||
|
access: tokens.access_token,
|
||||||
|
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,11 +7,15 @@ import { Server } from "../server/server"
|
|||||||
import { BunProc } from "../bun"
|
import { BunProc } from "../bun"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
|
import { CodexAuthPlugin } from "./codex"
|
||||||
|
|
||||||
export namespace Plugin {
|
export namespace Plugin {
|
||||||
const log = Log.create({ service: "plugin" })
|
const log = Log.create({ service: "plugin" })
|
||||||
|
|
||||||
const BUILTIN = ["opencode-copilot-auth@0.0.9", "opencode-anthropic-auth@0.0.5"]
|
const BUILTIN = ["opencode-copilot-auth@0.0.11", "opencode-anthropic-auth@0.0.8"]
|
||||||
|
|
||||||
|
// Built-in plugins that are directly imported (not installed from npm)
|
||||||
|
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin]
|
||||||
|
|
||||||
const state = Instance.state(async () => {
|
const state = Instance.state(async () => {
|
||||||
const client = createOpencodeClient({
|
const client = createOpencodeClient({
|
||||||
@@ -20,7 +24,7 @@ export namespace Plugin {
|
|||||||
fetch: async (...args) => Server.App().fetch(...args),
|
fetch: async (...args) => Server.App().fetch(...args),
|
||||||
})
|
})
|
||||||
const config = await Config.get()
|
const config = await Config.get()
|
||||||
const hooks = []
|
const hooks: Hooks[] = []
|
||||||
const input: PluginInput = {
|
const input: PluginInput = {
|
||||||
client,
|
client,
|
||||||
project: Instance.project,
|
project: Instance.project,
|
||||||
@@ -29,11 +33,23 @@ export namespace Plugin {
|
|||||||
serverUrl: Server.url(),
|
serverUrl: Server.url(),
|
||||||
$: Bun.$,
|
$: Bun.$,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load internal plugins first
|
||||||
|
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
|
||||||
|
for (const plugin of INTERNAL_PLUGINS) {
|
||||||
|
log.info("loading internal plugin", { name: plugin.name })
|
||||||
|
const init = await plugin(input)
|
||||||
|
hooks.push(init)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const plugins = [...(config.plugin ?? [])]
|
const plugins = [...(config.plugin ?? [])]
|
||||||
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
|
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
|
||||||
plugins.push(...BUILTIN)
|
plugins.push(...BUILTIN)
|
||||||
}
|
}
|
||||||
for (let plugin of plugins) {
|
for (let plugin of plugins) {
|
||||||
|
// ignore old codex plugin since it is supported first party now
|
||||||
|
if (plugin.includes("opencode-openai-codex-auth")) continue
|
||||||
log.info("loading plugin", { path: plugin })
|
log.info("loading plugin", { path: plugin })
|
||||||
if (!plugin.startsWith("file://")) {
|
if (!plugin.startsWith("file://")) {
|
||||||
const lastAtIndex = plugin.lastIndexOf("@")
|
const lastAtIndex = plugin.lastIndexOf("@")
|
||||||
|
|||||||
@@ -852,6 +852,7 @@ export namespace Provider {
|
|||||||
if (modelID === "gpt-5-chat-latest" || (providerID === "openrouter" && modelID === "openai/gpt-5-chat"))
|
if (modelID === "gpt-5-chat-latest" || (providerID === "openrouter" && modelID === "openai/gpt-5-chat"))
|
||||||
delete provider.models[modelID]
|
delete provider.models[modelID]
|
||||||
if (model.status === "alpha" && !Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) delete provider.models[modelID]
|
if (model.status === "alpha" && !Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) delete provider.models[modelID]
|
||||||
|
if (model.status === "deprecated") delete provider.models[modelID]
|
||||||
if (
|
if (
|
||||||
(configProvider?.blacklist && configProvider.blacklist.includes(modelID)) ||
|
(configProvider?.blacklist && configProvider.blacklist.includes(modelID)) ||
|
||||||
(configProvider?.whitelist && !configProvider.whitelist.includes(modelID))
|
(configProvider?.whitelist && !configProvider.whitelist.includes(modelID))
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export namespace Server {
|
|||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
export const App: () => Hono = lazy(
|
export const App: () => Hono = lazy(
|
||||||
() =>
|
() =>
|
||||||
|
// TODO: Break server.ts into smaller route files to fix type inference
|
||||||
app
|
app
|
||||||
.onError((err, c) => {
|
.onError((err, c) => {
|
||||||
log.error("failed", {
|
log.error("failed", {
|
||||||
|
|||||||
@@ -151,12 +151,19 @@ export namespace Session {
|
|||||||
directory: Instance.directory,
|
directory: Instance.directory,
|
||||||
})
|
})
|
||||||
const msgs = await messages({ sessionID: input.sessionID })
|
const msgs = await messages({ sessionID: input.sessionID })
|
||||||
|
const idMap = new Map<string, string>()
|
||||||
|
|
||||||
for (const msg of msgs) {
|
for (const msg of msgs) {
|
||||||
if (input.messageID && msg.info.id >= input.messageID) break
|
if (input.messageID && msg.info.id >= input.messageID) break
|
||||||
|
const newID = Identifier.ascending("message")
|
||||||
|
idMap.set(msg.info.id, newID)
|
||||||
|
|
||||||
|
const parentID = msg.info.role === "assistant" && msg.info.parentID ? idMap.get(msg.info.parentID) : undefined
|
||||||
const cloned = await updateMessage({
|
const cloned = await updateMessage({
|
||||||
...msg.info,
|
...msg.info,
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
id: Identifier.ascending("message"),
|
id: newID,
|
||||||
|
...(parentID && { parentID }),
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const part of msg.parts) {
|
for (const part of msg.parts) {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import os from "os"
|
||||||
|
import { Installation } from "@/installation"
|
||||||
import { Provider } from "@/provider/provider"
|
import { Provider } from "@/provider/provider"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import {
|
import {
|
||||||
@@ -19,6 +21,7 @@ import { Plugin } from "@/plugin"
|
|||||||
import { SystemPrompt } from "./system"
|
import { SystemPrompt } from "./system"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { PermissionNext } from "@/permission/next"
|
import { PermissionNext } from "@/permission/next"
|
||||||
|
import { Auth } from "@/auth"
|
||||||
|
|
||||||
export namespace LLM {
|
export namespace LLM {
|
||||||
const log = Log.create({ service: "llm" })
|
const log = Log.create({ service: "llm" })
|
||||||
@@ -82,12 +85,24 @@ export namespace LLM {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const provider = await Provider.getProvider(input.model.providerID)
|
const provider = await Provider.getProvider(input.model.providerID)
|
||||||
|
const auth = await Auth.get(input.model.providerID)
|
||||||
|
const isCodex = provider.id === "openai" && auth?.type === "oauth"
|
||||||
|
|
||||||
const variant =
|
const variant =
|
||||||
!input.small && input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : {}
|
!input.small && input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : {}
|
||||||
const base = input.small
|
const base = input.small
|
||||||
? ProviderTransform.smallOptions(input.model)
|
? ProviderTransform.smallOptions(input.model)
|
||||||
: ProviderTransform.options(input.model, input.sessionID, provider.options)
|
: ProviderTransform.options(input.model, input.sessionID, provider.options)
|
||||||
const options = pipe(base, mergeDeep(input.model.options), mergeDeep(input.agent.options), mergeDeep(variant))
|
const options: Record<string, any> = pipe(
|
||||||
|
base,
|
||||||
|
mergeDeep(input.model.options),
|
||||||
|
mergeDeep(input.agent.options),
|
||||||
|
mergeDeep(variant),
|
||||||
|
)
|
||||||
|
if (isCodex) {
|
||||||
|
options.instructions = SystemPrompt.instructions()
|
||||||
|
options.store = false
|
||||||
|
}
|
||||||
|
|
||||||
const params = await Plugin.trigger(
|
const params = await Plugin.trigger(
|
||||||
"chat.params",
|
"chat.params",
|
||||||
@@ -108,11 +123,9 @@ export namespace LLM {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
l.info("params", {
|
const maxOutputTokens = isCodex
|
||||||
params,
|
? undefined
|
||||||
})
|
: ProviderTransform.maxOutputTokens(
|
||||||
|
|
||||||
const maxOutputTokens = ProviderTransform.maxOutputTokens(
|
|
||||||
input.model.api.npm,
|
input.model.api.npm,
|
||||||
params.options,
|
params.options,
|
||||||
input.model.limit.output,
|
input.model.limit.output,
|
||||||
@@ -157,6 +170,13 @@ export namespace LLM {
|
|||||||
maxOutputTokens,
|
maxOutputTokens,
|
||||||
abortSignal: input.abort,
|
abortSignal: input.abort,
|
||||||
headers: {
|
headers: {
|
||||||
|
...(isCodex
|
||||||
|
? {
|
||||||
|
originator: "opencode",
|
||||||
|
"User-Agent": `opencode/${Installation.VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
|
||||||
|
session_id: input.sessionID,
|
||||||
|
}
|
||||||
|
: undefined),
|
||||||
...(input.model.providerID.startsWith("opencode")
|
...(input.model.providerID.startsWith("opencode")
|
||||||
? {
|
? {
|
||||||
"x-opencode-project": Instance.project.id,
|
"x-opencode-project": Instance.project.id,
|
||||||
@@ -169,12 +189,19 @@ export namespace LLM {
|
|||||||
},
|
},
|
||||||
maxRetries: input.retries ?? 0,
|
maxRetries: input.retries ?? 0,
|
||||||
messages: [
|
messages: [
|
||||||
...system.map(
|
...(isCodex
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: system.join("\n\n"),
|
||||||
|
} as ModelMessage,
|
||||||
|
]
|
||||||
|
: system.map(
|
||||||
(x): ModelMessage => ({
|
(x): ModelMessage => ({
|
||||||
role: "system",
|
role: "system",
|
||||||
content: x,
|
content: x,
|
||||||
}),
|
}),
|
||||||
),
|
)),
|
||||||
...input.messages,
|
...input.messages,
|
||||||
],
|
],
|
||||||
model: wrapLanguageModel({
|
model: wrapLanguageModel({
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
You are a coding agent running in the opencode, a terminal-based coding assistant. opencode is an open source project. You are expected to be precise, safe, and helpful.
|
||||||
@@ -14,6 +14,7 @@ import PROMPT_GEMINI from "./prompt/gemini.txt"
|
|||||||
import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"
|
import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"
|
||||||
|
|
||||||
import PROMPT_CODEX from "./prompt/codex.txt"
|
import PROMPT_CODEX from "./prompt/codex.txt"
|
||||||
|
import PROMPT_CODEX_INSTRUCTIONS from "./prompt/codex_header.txt"
|
||||||
import type { Provider } from "@/provider/provider"
|
import type { Provider } from "@/provider/provider"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
|
|
||||||
@@ -23,6 +24,10 @@ export namespace SystemPrompt {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function instructions() {
|
||||||
|
return PROMPT_CODEX_INSTRUCTIONS.trim()
|
||||||
|
}
|
||||||
|
|
||||||
export function provider(model: Provider.Model) {
|
export function provider(model: Provider.Model) {
|
||||||
if (model.api.id.includes("gpt-5")) return [PROMPT_CODEX]
|
if (model.api.id.includes("gpt-5")) return [PROMPT_CODEX]
|
||||||
if (model.api.id.includes("gpt-") || model.api.id.includes("o1") || model.api.id.includes("o3"))
|
if (model.api.id.includes("gpt-") || model.api.id.includes("o1") || model.api.id.includes("o3"))
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect, mock, afterEach } from "bun:test"
|
import { test, expect, describe, mock } from "bun:test"
|
||||||
import { Config } from "../../src/config/config"
|
import { Config } from "../../src/config/config"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { Auth } from "../../src/auth"
|
import { Auth } from "../../src/auth"
|
||||||
@@ -1145,3 +1145,91 @@ test("project config overrides remote well-known config", async () => {
|
|||||||
Auth.all = originalAuthAll
|
Auth.all = originalAuthAll
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("getPluginName", () => {
|
||||||
|
test("extracts name from file:// URL", () => {
|
||||||
|
expect(Config.getPluginName("file:///path/to/plugin/foo.js")).toBe("foo")
|
||||||
|
expect(Config.getPluginName("file:///path/to/plugin/bar.ts")).toBe("bar")
|
||||||
|
expect(Config.getPluginName("file:///some/path/my-plugin.js")).toBe("my-plugin")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("extracts name from npm package with version", () => {
|
||||||
|
expect(Config.getPluginName("oh-my-opencode@2.4.3")).toBe("oh-my-opencode")
|
||||||
|
expect(Config.getPluginName("some-plugin@1.0.0")).toBe("some-plugin")
|
||||||
|
expect(Config.getPluginName("plugin@latest")).toBe("plugin")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("extracts name from scoped npm package", () => {
|
||||||
|
expect(Config.getPluginName("@scope/pkg@1.0.0")).toBe("@scope/pkg")
|
||||||
|
expect(Config.getPluginName("@opencode/plugin@2.0.0")).toBe("@opencode/plugin")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns full string for package without version", () => {
|
||||||
|
expect(Config.getPluginName("some-plugin")).toBe("some-plugin")
|
||||||
|
expect(Config.getPluginName("@scope/pkg")).toBe("@scope/pkg")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("deduplicatePlugins", () => {
|
||||||
|
test("removes duplicates keeping higher priority (later entries)", () => {
|
||||||
|
const plugins = ["global-plugin@1.0.0", "shared-plugin@1.0.0", "local-plugin@2.0.0", "shared-plugin@2.0.0"]
|
||||||
|
|
||||||
|
const result = Config.deduplicatePlugins(plugins)
|
||||||
|
|
||||||
|
expect(result).toContain("global-plugin@1.0.0")
|
||||||
|
expect(result).toContain("local-plugin@2.0.0")
|
||||||
|
expect(result).toContain("shared-plugin@2.0.0")
|
||||||
|
expect(result).not.toContain("shared-plugin@1.0.0")
|
||||||
|
expect(result.length).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("prefers local file over npm package with same name", () => {
|
||||||
|
const plugins = ["oh-my-opencode@2.4.3", "file:///project/.opencode/plugin/oh-my-opencode.js"]
|
||||||
|
|
||||||
|
const result = Config.deduplicatePlugins(plugins)
|
||||||
|
|
||||||
|
expect(result.length).toBe(1)
|
||||||
|
expect(result[0]).toBe("file:///project/.opencode/plugin/oh-my-opencode.js")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves order of remaining plugins", () => {
|
||||||
|
const plugins = ["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"]
|
||||||
|
|
||||||
|
const result = Config.deduplicatePlugins(plugins)
|
||||||
|
|
||||||
|
expect(result).toEqual(["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("local plugin directory overrides global opencode.json plugin", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const projectDir = path.join(dir, "project")
|
||||||
|
const opencodeDir = path.join(projectDir, ".opencode")
|
||||||
|
const pluginDir = path.join(opencodeDir, "plugin")
|
||||||
|
await fs.mkdir(pluginDir, { recursive: true })
|
||||||
|
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
plugin: ["my-plugin@1.0.0"],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await Bun.write(path.join(pluginDir, "my-plugin.js"), "export default {}")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: path.join(tmp.path, "project"),
|
||||||
|
fn: async () => {
|
||||||
|
const config = await Config.get()
|
||||||
|
const plugins = config.plugin ?? []
|
||||||
|
|
||||||
|
const myPlugins = plugins.filter((p) => Config.getPluginName(p) === "my-plugin")
|
||||||
|
expect(myPlugins.length).toBe(1)
|
||||||
|
expect(myPlugins[0].startsWith("file://")).toBe(true)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1,19 +1,53 @@
|
|||||||
import { useMarked } from "../context/marked"
|
import { useMarked } from "../context/marked"
|
||||||
|
import { checksum } from "@opencode-ai/util/encode"
|
||||||
import { ComponentProps, createResource, splitProps } from "solid-js"
|
import { ComponentProps, createResource, splitProps } from "solid-js"
|
||||||
|
|
||||||
|
type Entry = {
|
||||||
|
hash: string
|
||||||
|
html: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const max = 200
|
||||||
|
const cache = new Map<string, Entry>()
|
||||||
|
|
||||||
|
function touch(key: string, value: Entry) {
|
||||||
|
cache.delete(key)
|
||||||
|
cache.set(key, value)
|
||||||
|
|
||||||
|
if (cache.size <= max) return
|
||||||
|
|
||||||
|
const first = cache.keys().next().value
|
||||||
|
if (!first) return
|
||||||
|
cache.delete(first)
|
||||||
|
}
|
||||||
|
|
||||||
export function Markdown(
|
export function Markdown(
|
||||||
props: ComponentProps<"div"> & {
|
props: ComponentProps<"div"> & {
|
||||||
text: string
|
text: string
|
||||||
|
cacheKey?: string
|
||||||
class?: string
|
class?: string
|
||||||
classList?: Record<string, boolean>
|
classList?: Record<string, boolean>
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const [local, others] = splitProps(props, ["text", "class", "classList"])
|
const [local, others] = splitProps(props, ["text", "cacheKey", "class", "classList"])
|
||||||
const marked = useMarked()
|
const marked = useMarked()
|
||||||
const [html] = createResource(
|
const [html] = createResource(
|
||||||
() => local.text,
|
() => local.text,
|
||||||
async (markdown) => {
|
async (markdown) => {
|
||||||
return marked.parse(markdown)
|
const hash = checksum(markdown)
|
||||||
|
const key = local.cacheKey ?? hash
|
||||||
|
|
||||||
|
if (key && hash) {
|
||||||
|
const cached = cache.get(key)
|
||||||
|
if (cached && cached.hash === hash) {
|
||||||
|
touch(key, cached)
|
||||||
|
return cached.html
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = await marked.parse(markdown)
|
||||||
|
if (key && hash) touch(key, { hash, html: next })
|
||||||
|
return next
|
||||||
},
|
},
|
||||||
{ initialValue: "" },
|
{ initialValue: "" },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
|
|
||||||
[data-slot="user-message-text"] {
|
[data-slot="user-message-text"] {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-all;
|
word-break: break-word;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--surface-base);
|
background: var(--surface-base);
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
@@ -254,6 +254,7 @@
|
|||||||
|
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -271,6 +272,7 @@
|
|||||||
/* Hide scrollbar */
|
/* Hide scrollbar */
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -458,6 +460,7 @@
|
|||||||
from {
|
from {
|
||||||
--border-angle: 0deg;
|
--border-angle: 0deg;
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
--border-angle: 360deg;
|
--border-angle: 360deg;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -566,7 +566,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
|
|||||||
return (
|
return (
|
||||||
<Show when={throttledText()}>
|
<Show when={throttledText()}>
|
||||||
<div data-component="text-part">
|
<div data-component="text-part">
|
||||||
<Markdown text={throttledText()} />
|
<Markdown text={throttledText()} cacheKey={part.id} />
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
@@ -580,7 +580,7 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
|
|||||||
return (
|
return (
|
||||||
<Show when={throttledText()}>
|
<Show when={throttledText()}>
|
||||||
<div data-component="reasoning-part">
|
<div data-component="reasoning-part">
|
||||||
<Markdown text={throttledText()} />
|
<Markdown text={throttledText()} cacheKey={part.id} />
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
@@ -984,6 +984,22 @@ ToolRegistry.register({
|
|||||||
ToolRegistry.register({
|
ToolRegistry.register({
|
||||||
name: "todowrite",
|
name: "todowrite",
|
||||||
render(props) {
|
render(props) {
|
||||||
|
const todos = createMemo(() => {
|
||||||
|
const meta = props.metadata?.todos
|
||||||
|
if (Array.isArray(meta)) return meta
|
||||||
|
|
||||||
|
const input = props.input.todos
|
||||||
|
if (Array.isArray(input)) return input
|
||||||
|
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
const subtitle = createMemo(() => {
|
||||||
|
const list = todos()
|
||||||
|
if (list.length === 0) return ""
|
||||||
|
return `${list.filter((t: Todo) => t.status === "completed").length}/${list.length}`
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BasicTool
|
<BasicTool
|
||||||
{...props}
|
{...props}
|
||||||
@@ -991,14 +1007,12 @@ ToolRegistry.register({
|
|||||||
icon="checklist"
|
icon="checklist"
|
||||||
trigger={{
|
trigger={{
|
||||||
title: "To-dos",
|
title: "To-dos",
|
||||||
subtitle: props.input.todos
|
subtitle: subtitle(),
|
||||||
? `${props.input.todos.filter((t: Todo) => t.status === "completed").length}/${props.input.todos.length}`
|
|
||||||
: "",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={props.input.todos?.length}>
|
<Show when={todos().length}>
|
||||||
<div data-component="todos">
|
<div data-component="todos">
|
||||||
<For each={props.input.todos}>
|
<For each={todos()}>
|
||||||
{(todo: Todo) => (
|
{(todo: Todo) => (
|
||||||
<Checkbox readOnly checked={todo.status === "completed"}>
|
<Checkbox readOnly checked={todo.status === "completed"}>
|
||||||
<div data-slot="message-part-todo-content" data-completed={todo.status === "completed"}>
|
<div data-slot="message-part-todo-content" data-completed={todo.status === "completed"}>
|
||||||
|
|||||||
@@ -350,15 +350,31 @@ export function SessionTurn(
|
|||||||
onUserInteracted: props.onUserInteracted,
|
onUserInteracted: props.onUserInteracted,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const diffInit = 20
|
||||||
|
const diffBatch = 20
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
stickyTitleRef: undefined as HTMLDivElement | undefined,
|
stickyTitleRef: undefined as HTMLDivElement | undefined,
|
||||||
stickyTriggerRef: undefined as HTMLDivElement | undefined,
|
stickyTriggerRef: undefined as HTMLDivElement | undefined,
|
||||||
stickyHeaderHeight: 0,
|
stickyHeaderHeight: 0,
|
||||||
retrySeconds: 0,
|
retrySeconds: 0,
|
||||||
|
diffsOpen: [] as string[],
|
||||||
|
diffLimit: diffInit,
|
||||||
status: rawStatus(),
|
status: rawStatus(),
|
||||||
duration: duration(),
|
duration: duration(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => message()?.id,
|
||||||
|
() => {
|
||||||
|
setStore("diffsOpen", [])
|
||||||
|
setStore("diffLimit", diffInit)
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const r = retry()
|
const r = retry()
|
||||||
if (!r) {
|
if (!r) {
|
||||||
@@ -542,10 +558,23 @@ export function SessionTurn(
|
|||||||
<div data-slot="session-turn-summary-section">
|
<div data-slot="session-turn-summary-section">
|
||||||
<div data-slot="session-turn-summary-header">
|
<div data-slot="session-turn-summary-header">
|
||||||
<h2 data-slot="session-turn-summary-title">Response</h2>
|
<h2 data-slot="session-turn-summary-title">Response</h2>
|
||||||
<Markdown data-slot="session-turn-markdown" data-diffs={hasDiffs()} text={response() ?? ""} />
|
<Markdown
|
||||||
|
data-slot="session-turn-markdown"
|
||||||
|
data-diffs={hasDiffs()}
|
||||||
|
text={response() ?? ""}
|
||||||
|
cacheKey={responsePartId()}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Accordion data-slot="session-turn-accordion" multiple>
|
<Accordion
|
||||||
<For each={msg().summary?.diffs ?? []}>
|
data-slot="session-turn-accordion"
|
||||||
|
multiple
|
||||||
|
value={store.diffsOpen}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (!Array.isArray(value)) return
|
||||||
|
setStore("diffsOpen", value)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<For each={(msg().summary?.diffs ?? []).slice(0, store.diffLimit)}>
|
||||||
{(diff) => (
|
{(diff) => (
|
||||||
<Accordion.Item value={diff.file}>
|
<Accordion.Item value={diff.file}>
|
||||||
<StickyAccordionHeader>
|
<StickyAccordionHeader>
|
||||||
@@ -573,6 +602,7 @@ export function SessionTurn(
|
|||||||
</Accordion.Trigger>
|
</Accordion.Trigger>
|
||||||
</StickyAccordionHeader>
|
</StickyAccordionHeader>
|
||||||
<Accordion.Content data-slot="session-turn-accordion-content">
|
<Accordion.Content data-slot="session-turn-accordion-content">
|
||||||
|
<Show when={store.diffsOpen.includes(diff.file!)}>
|
||||||
<Dynamic
|
<Dynamic
|
||||||
component={diffComponent}
|
component={diffComponent}
|
||||||
before={{
|
before={{
|
||||||
@@ -584,11 +614,29 @@ export function SessionTurn(
|
|||||||
contents: diff.after!,
|
contents: diff.after!,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</Show>
|
||||||
</Accordion.Content>
|
</Accordion.Content>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
<Show when={(msg().summary?.diffs?.length ?? 0) > store.diffLimit}>
|
||||||
|
<Button
|
||||||
|
data-slot="session-turn-accordion-more"
|
||||||
|
variant="ghost"
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
const total = msg().summary?.diffs?.length ?? 0
|
||||||
|
setStore("diffLimit", (limit) => {
|
||||||
|
const next = limit + diffBatch
|
||||||
|
if (next > total) return total
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Show more changes ({(msg().summary?.diffs?.length ?? 0) - store.diffLimit})
|
||||||
|
</Button>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={error() && !props.stepsExpanded}>
|
<Show when={error() && !props.stepsExpanded}>
|
||||||
|
|||||||
@@ -3,12 +3,19 @@ import { createContext, createMemo, Show, useContext, type ParentProps, type Acc
|
|||||||
export function createSimpleContext<T, Props extends Record<string, any>>(input: {
|
export function createSimpleContext<T, Props extends Record<string, any>>(input: {
|
||||||
name: string
|
name: string
|
||||||
init: ((input: Props) => T) | (() => T)
|
init: ((input: Props) => T) | (() => T)
|
||||||
|
gate?: boolean
|
||||||
}) {
|
}) {
|
||||||
const ctx = createContext<T>()
|
const ctx = createContext<T>()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
provider: (props: ParentProps<Props>) => {
|
provider: (props: ParentProps<Props>) => {
|
||||||
const init = input.init(props)
|
const init = input.init(props)
|
||||||
|
const gate = input.gate ?? true
|
||||||
|
|
||||||
|
if (!gate) {
|
||||||
|
return <ctx.Provider value={init}>{props.children}</ctx.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
// Access init.ready inside the memo to make it reactive for getter properties
|
// Access init.ready inside the memo to make it reactive for getter properties
|
||||||
const isReady = createMemo(() => {
|
const isReady = createMemo(() => {
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import ayuThemeJson from "./themes/ayu.json"
|
|||||||
import oneDarkProThemeJson from "./themes/onedarkpro.json"
|
import oneDarkProThemeJson from "./themes/onedarkpro.json"
|
||||||
import shadesOfPurpleThemeJson from "./themes/shadesofpurple.json"
|
import shadesOfPurpleThemeJson from "./themes/shadesofpurple.json"
|
||||||
import nightowlThemeJson from "./themes/nightowl.json"
|
import nightowlThemeJson from "./themes/nightowl.json"
|
||||||
|
import vesperThemeJson from "./themes/vesper.json"
|
||||||
|
|
||||||
export const oc1Theme = oc1ThemeJson as DesktopTheme
|
export const oc1Theme = oc1ThemeJson as DesktopTheme
|
||||||
export const tokyonightTheme = tokyoThemeJson as DesktopTheme
|
export const tokyonightTheme = tokyoThemeJson as DesktopTheme
|
||||||
@@ -22,6 +23,7 @@ export const ayuTheme = ayuThemeJson as DesktopTheme
|
|||||||
export const oneDarkProTheme = oneDarkProThemeJson as DesktopTheme
|
export const oneDarkProTheme = oneDarkProThemeJson as DesktopTheme
|
||||||
export const shadesOfPurpleTheme = shadesOfPurpleThemeJson as DesktopTheme
|
export const shadesOfPurpleTheme = shadesOfPurpleThemeJson as DesktopTheme
|
||||||
export const nightowlTheme = nightowlThemeJson as DesktopTheme
|
export const nightowlTheme = nightowlThemeJson as DesktopTheme
|
||||||
|
export const vesperTheme = vesperThemeJson as DesktopTheme
|
||||||
|
|
||||||
export const DEFAULT_THEMES: Record<string, DesktopTheme> = {
|
export const DEFAULT_THEMES: Record<string, DesktopTheme> = {
|
||||||
"oc-1": oc1Theme,
|
"oc-1": oc1Theme,
|
||||||
@@ -35,4 +37,5 @@ export const DEFAULT_THEMES: Record<string, DesktopTheme> = {
|
|||||||
onedarkpro: oneDarkProTheme,
|
onedarkpro: oneDarkProTheme,
|
||||||
shadesofpurple: shadesOfPurpleTheme,
|
shadesofpurple: shadesOfPurpleTheme,
|
||||||
nightowl: nightowlTheme,
|
nightowl: nightowlTheme,
|
||||||
|
vesper: vesperTheme,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,4 +42,5 @@ export {
|
|||||||
oneDarkProTheme,
|
oneDarkProTheme,
|
||||||
shadesOfPurpleTheme,
|
shadesOfPurpleTheme,
|
||||||
nightowlTheme,
|
nightowlTheme,
|
||||||
|
vesperTheme,
|
||||||
} from "./default-themes"
|
} from "./default-themes"
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/desktop-theme.json",
|
||||||
|
"name": "Vesper",
|
||||||
|
"id": "vesper",
|
||||||
|
"light": {
|
||||||
|
"seeds": {
|
||||||
|
"neutral": "#F0F0F0",
|
||||||
|
"primary": "#FFC799",
|
||||||
|
"success": "#99FFE4",
|
||||||
|
"warning": "#FFC799",
|
||||||
|
"error": "#FF8080",
|
||||||
|
"info": "#FFC799",
|
||||||
|
"interactive": "#FFC799",
|
||||||
|
"diffAdd": "#99FFE4",
|
||||||
|
"diffDelete": "#FF8080"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"background-base": "#FFF",
|
||||||
|
"background-weak": "#F8F8F8",
|
||||||
|
"background-strong": "#F0F0F0",
|
||||||
|
"background-stronger": "#E8E8E8",
|
||||||
|
"border-weak-base": "#E8E8E8",
|
||||||
|
"border-weak-hover": "#E0E0E0",
|
||||||
|
"border-weak-active": "#D8D8D8",
|
||||||
|
"border-weak-selected": "#D0D0D0",
|
||||||
|
"border-weak-disabled": "#F0F0F0",
|
||||||
|
"border-weak-focus": "#D8D8D8",
|
||||||
|
"border-base": "#D0D0D0",
|
||||||
|
"border-hover": "#C8C8C8",
|
||||||
|
"border-active": "#C0C0C0",
|
||||||
|
"border-selected": "#B8B8B8",
|
||||||
|
"border-disabled": "#E8E8E8",
|
||||||
|
"border-focus": "#C0C0C0",
|
||||||
|
"border-strong-base": "#A0A0A0",
|
||||||
|
"border-strong-hover": "#989898",
|
||||||
|
"border-strong-active": "#909090",
|
||||||
|
"border-strong-selected": "#888888",
|
||||||
|
"border-strong-disabled": "#D0D0D0",
|
||||||
|
"border-strong-focus": "#909090",
|
||||||
|
"surface-diff-add-base": "#e8f5e8",
|
||||||
|
"surface-diff-delete-base": "#f5e8e8",
|
||||||
|
"surface-diff-hidden-base": "#F0F0F0",
|
||||||
|
"text-base": "#101010",
|
||||||
|
"text-weak": "#A0A0A0",
|
||||||
|
"text-strong": "#000000",
|
||||||
|
"syntax-string": "#99FFE4",
|
||||||
|
"syntax-primitive": "#FF8080",
|
||||||
|
"syntax-property": "#FFC799",
|
||||||
|
"syntax-type": "#FFC799",
|
||||||
|
"syntax-constant": "#A0A0A0",
|
||||||
|
"syntax-info": "#A0A0A0",
|
||||||
|
"markdown-heading": "#FFC799",
|
||||||
|
"markdown-text": "#101010",
|
||||||
|
"markdown-link": "#FFC799",
|
||||||
|
"markdown-link-text": "#A0A0A0",
|
||||||
|
"markdown-code": "#A0A0A0",
|
||||||
|
"markdown-block-quote": "#101010",
|
||||||
|
"markdown-emph": "#101010",
|
||||||
|
"markdown-strong": "#101010",
|
||||||
|
"markdown-horizontal-rule": "#65737E",
|
||||||
|
"markdown-list-item": "#101010",
|
||||||
|
"markdown-list-enumeration": "#101010",
|
||||||
|
"markdown-image": "#FFC799",
|
||||||
|
"markdown-image-text": "#A0A0A0",
|
||||||
|
"markdown-code-block": "#FFC799"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dark": {
|
||||||
|
"seeds": {
|
||||||
|
"neutral": "#101010",
|
||||||
|
"primary": "#FFC799",
|
||||||
|
"success": "#99FFE4",
|
||||||
|
"warning": "#FFC799",
|
||||||
|
"error": "#FF8080",
|
||||||
|
"info": "#FFC799",
|
||||||
|
"interactive": "#FFC799",
|
||||||
|
"diffAdd": "#99FFE4",
|
||||||
|
"diffDelete": "#FF8080"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"background-base": "#101010",
|
||||||
|
"background-weak": "#141414",
|
||||||
|
"background-strong": "#0C0C0C",
|
||||||
|
"background-stronger": "#080808",
|
||||||
|
"border-weak-base": "#1C1C1C",
|
||||||
|
"border-weak-hover": "#202020",
|
||||||
|
"border-weak-active": "#242424",
|
||||||
|
"border-weak-selected": "#282828",
|
||||||
|
"border-weak-disabled": "#141414",
|
||||||
|
"border-weak-focus": "#242424",
|
||||||
|
"border-base": "#282828",
|
||||||
|
"border-hover": "#303030",
|
||||||
|
"border-active": "#383838",
|
||||||
|
"border-selected": "#404040",
|
||||||
|
"border-disabled": "#181818",
|
||||||
|
"border-focus": "#383838",
|
||||||
|
"border-strong-base": "#505050",
|
||||||
|
"border-strong-hover": "#585858",
|
||||||
|
"border-strong-active": "#606060",
|
||||||
|
"border-strong-selected": "#686868",
|
||||||
|
"border-strong-disabled": "#202020",
|
||||||
|
"border-strong-focus": "#606060",
|
||||||
|
"surface-diff-add-base": "#0d2818",
|
||||||
|
"surface-diff-delete-base": "#281a1a",
|
||||||
|
"surface-diff-hidden-base": "#141414",
|
||||||
|
"text-base": "#FFF",
|
||||||
|
"text-weak": "#A0A0A0",
|
||||||
|
"text-strong": "#FFFFFF",
|
||||||
|
"syntax-string": "#99FFE4",
|
||||||
|
"syntax-primitive": "#FF8080",
|
||||||
|
"syntax-property": "#FFC799",
|
||||||
|
"syntax-type": "#FFC799",
|
||||||
|
"syntax-constant": "#A0A0A0",
|
||||||
|
"syntax-info": "#8b8b8b",
|
||||||
|
"markdown-heading": "#FFC799",
|
||||||
|
"markdown-text": "#FFF",
|
||||||
|
"markdown-link": "#FFC799",
|
||||||
|
"markdown-link-text": "#A0A0A0",
|
||||||
|
"markdown-code": "#A0A0A0",
|
||||||
|
"markdown-block-quote": "#FFF",
|
||||||
|
"markdown-emph": "#FFF",
|
||||||
|
"markdown-strong": "#FFF",
|
||||||
|
"markdown-horizontal-rule": "#65737E",
|
||||||
|
"markdown-list-item": "#FFF",
|
||||||
|
"markdown-list-enumeration": "#FFF",
|
||||||
|
"markdown-image": "#FFC799",
|
||||||
|
"markdown-image-text": "#A0A0A0",
|
||||||
|
"markdown-code-block": "#FFF"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/util",
|
"name": "@opencode-ai/util",
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export function getFilename(path: string | undefined) {
|
export function getFilename(path: string | undefined) {
|
||||||
if (!path) return ""
|
if (!path) return ""
|
||||||
const trimmed = path.replace(/[\/]+$/, "")
|
const trimmed = path.replace(/[\/\\]+$/, "")
|
||||||
const parts = trimmed.split("/")
|
const parts = trimmed.split(/[\/\\]/)
|
||||||
return parts[parts.length - 1] ?? ""
|
return parts[parts.length - 1] ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||||
|
|||||||
@@ -1271,6 +1271,35 @@ SAP AI Core provides access to 40+ models from OpenAI, Anthropic, Google, Amazon
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Scaleway
|
||||||
|
|
||||||
|
To use [Scaleway Generative APIs](https://www.scaleway.com/en/docs/generative-apis/) with Opencode:
|
||||||
|
|
||||||
|
1. Head over to the [Scaleway Console IAM settings](https://console.scaleway.com/iam/api-keys) to generate a new API key.
|
||||||
|
|
||||||
|
2. Run the `/connect` command and search for **Scaleway**.
|
||||||
|
|
||||||
|
```txt
|
||||||
|
/connect
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Enter your Scaleway API key.
|
||||||
|
|
||||||
|
```txt
|
||||||
|
┌ API key
|
||||||
|
│
|
||||||
|
│
|
||||||
|
└ enter
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Run the `/models` command to select a model like _devstral-2-123b-instruct-2512_ or _gpt-oss-120b_.
|
||||||
|
|
||||||
|
```txt
|
||||||
|
/models
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Together AI
|
### Together AI
|
||||||
|
|
||||||
1. Head over to the [Together AI console](https://api.together.ai), create an account, and click **Add Key**.
|
1. Head over to the [Together AI console](https://api.together.ai), create an account, and click **Add Key**.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"displayName": "opencode",
|
"displayName": "opencode",
|
||||||
"description": "opencode for VS Code",
|
"description": "opencode for VS Code",
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"publisher": "sst-dev",
|
"publisher": "sst-dev",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
## Payload limits
|
||||||
|
|
||||||
|
Prevent blocking storage writes and runaway persisted size
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Large payloads (base64 images, terminal buffers) are currently persisted inside key-value stores:
|
||||||
|
|
||||||
|
- web: `localStorage` (sync, blocks the main thread)
|
||||||
|
- desktop: Tauri Store-backed async storage files (still expensive when values are huge)
|
||||||
|
|
||||||
|
We’ll introduce size-aware persistence policies plus a dedicated “blob store” for large/binary data (IndexedDB on web; separate files on desktop). Prompt/history state will persist only lightweight references to blobs and load them on demand.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Goals
|
||||||
|
|
||||||
|
- Stop persisting image `dataUrl` blobs inside web `localStorage`
|
||||||
|
- Stop persisting image `dataUrl` blobs inside desktop store `.dat` files
|
||||||
|
- Store image payloads out-of-band (blob store) and load lazily when needed (e.g. when restoring a history item)
|
||||||
|
- Prevent terminal buffer persistence from exceeding safe size limits
|
||||||
|
- Keep persistence behavior predictable across web (sync) and desktop (async)
|
||||||
|
- Provide escape hatches via flags and per-key size caps
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Non-goals
|
||||||
|
|
||||||
|
- Cross-device sync of images or terminal buffers
|
||||||
|
- Lossless persistence of full terminal scrollback on web
|
||||||
|
- Perfect blob deduplication or a complex reference-counting system on day one
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Current state
|
||||||
|
|
||||||
|
- `packages/app/src/utils/persist.ts` uses `localStorage` (sync) on web and async storage only on desktop.
|
||||||
|
- Desktop storage is implemented via `@tauri-apps/plugin-store` and writes to named `.dat` files (see `packages/desktop/src/index.tsx`). Large values bloat these files and increase flush costs.
|
||||||
|
- Prompt history persists under `Persist.global("prompt-history")` (`packages/app/src/components/prompt-input.tsx`) and can include image parts (`dataUrl`).
|
||||||
|
- Prompt draft persistence uses `packages/app/src/context/prompt.tsx` and can also include image parts (`dataUrl`).
|
||||||
|
- Terminal buffer is serialized in `packages/app/src/components/terminal.tsx` and persisted in `packages/app/src/context/terminal.tsx`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Proposed approach
|
||||||
|
|
||||||
|
#### 1) Add per-key persistence policies (KV store guardrails)
|
||||||
|
|
||||||
|
In `packages/app/src/utils/persist.ts`, add policy hooks for each persisted key:
|
||||||
|
|
||||||
|
- `warnBytes` (soft warning threshold)
|
||||||
|
- `maxBytes` (hard cap)
|
||||||
|
- `transformIn` / `transformOut` for lossy persistence (e.g. strip or refactor fields)
|
||||||
|
- `onOversize` strategy: `drop`, `truncate`, or `migrateToBlobRef`
|
||||||
|
|
||||||
|
This protects both:
|
||||||
|
|
||||||
|
- web (`localStorage` is sync)
|
||||||
|
- desktop (async, but still expensive to store/flush giant values)
|
||||||
|
|
||||||
|
#### 2) Add a dedicated blob store for large data
|
||||||
|
|
||||||
|
Introduce a small blob-store abstraction used by the app layer:
|
||||||
|
|
||||||
|
- web backend: IndexedDB (store `Blob` values keyed by `id`)
|
||||||
|
- desktop backend: filesystem directory under the app data directory (store one file per blob)
|
||||||
|
|
||||||
|
Store _references_ to blobs inside the persisted JSON instead of the blob contents.
|
||||||
|
|
||||||
|
#### 3) Persist image parts as references (not base64 payloads)
|
||||||
|
|
||||||
|
Update the prompt image model so the in-memory shape can still use a `dataUrl` for UI, but the persisted representation is reference-based.
|
||||||
|
|
||||||
|
Suggested approach:
|
||||||
|
|
||||||
|
- Keep `ImageAttachmentPart` with:
|
||||||
|
- required: `id`, `filename`, `mime`
|
||||||
|
- optional/ephemeral: `dataUrl?: string`
|
||||||
|
- new: `blobID?: string` (or `ref: string`)
|
||||||
|
|
||||||
|
Persistence rules:
|
||||||
|
|
||||||
|
- When writing persisted prompt/history state:
|
||||||
|
- ensure each image part is stored in blob store (`blobID`)
|
||||||
|
- persist only metadata + `blobID` (no `dataUrl`)
|
||||||
|
- When reading persisted prompt/history state:
|
||||||
|
- do not eagerly load blob payloads
|
||||||
|
- hydrate `dataUrl` only when needed:
|
||||||
|
- when applying a history entry into the editor
|
||||||
|
- before submission (ensure all image parts have usable `dataUrl`)
|
||||||
|
- when rendering an attachment preview, if required
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phased implementation steps
|
||||||
|
|
||||||
|
1. Add guardrails in `persist.ts`
|
||||||
|
|
||||||
|
- Implement size estimation in `packages/app/src/utils/persist.ts` using `TextEncoder` byte length on JSON strings.
|
||||||
|
- Add a policy registry keyed by persist name (e.g. `"prompt-history"`, `"prompt"`, `"terminal"`).
|
||||||
|
- Add a feature flag (e.g. `persist.payloadLimits`) to enable enforcement gradually.
|
||||||
|
|
||||||
|
2. Add blob-store abstraction + platform hooks
|
||||||
|
|
||||||
|
- Add a new app-level module (e.g. `packages/app/src/utils/blob.ts`) defining:
|
||||||
|
- `put(id, bytes|Blob)`
|
||||||
|
- `get(id)`
|
||||||
|
- `remove(id)`
|
||||||
|
- Extend the `Platform` interface (`packages/app/src/context/platform.tsx`) with optional blob methods, or provide a default web implementation and override on desktop:
|
||||||
|
- web: implement via IndexedDB
|
||||||
|
- desktop: implement via filesystem files (requires adding a Tauri fs plugin or `invoke` wrappers)
|
||||||
|
|
||||||
|
3. Update prompt history + prompt draft persistence to use blob refs
|
||||||
|
|
||||||
|
- Update prompt/history serialization paths to ensure image parts are stored as blob refs:
|
||||||
|
- Prompt history: `packages/app/src/components/prompt-input.tsx`
|
||||||
|
- Prompt draft: `packages/app/src/context/prompt.tsx`
|
||||||
|
- Ensure “apply history prompt” hydrates image blobs only when applying the prompt (not during background load).
|
||||||
|
|
||||||
|
4. One-time migration for existing persisted base64 images
|
||||||
|
|
||||||
|
- On read, detect legacy persisted image parts that include `dataUrl`.
|
||||||
|
- If a `dataUrl` is found:
|
||||||
|
- write it into the blob store (convert dataUrl → bytes)
|
||||||
|
- replace persisted payload with `{ blobID, filename, mime, id }` only
|
||||||
|
- re-save the reduced version
|
||||||
|
- If migration fails (missing permissions, quota, etc.), fall back to:
|
||||||
|
- keep the prompt entry but drop the image payload and mark as unavailable
|
||||||
|
|
||||||
|
5. Fix terminal persistence (bounded snapshot)
|
||||||
|
|
||||||
|
- In `packages/app/src/context/terminal.tsx`, persist only:
|
||||||
|
- last `maxLines` and/or
|
||||||
|
- last `maxBytes` of combined text
|
||||||
|
- In `packages/app/src/components/terminal.tsx`, keep the full in-memory buffer unchanged.
|
||||||
|
|
||||||
|
6. Add basic blob lifecycle cleanup
|
||||||
|
To avoid “blob directory grows forever”, add one of:
|
||||||
|
|
||||||
|
- TTL-based cleanup: store `lastAccessed` per blob and delete blobs older than N days
|
||||||
|
- Reference scan cleanup: periodically scan prompt-history + prompt drafts, build a set of referenced `blobID`s, and delete unreferenced blobs
|
||||||
|
|
||||||
|
Start with TTL-based cleanup (simpler, fewer cross-store dependencies), then consider scan-based cleanup if needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Data migration / backward compatibility
|
||||||
|
|
||||||
|
- KV store data:
|
||||||
|
- policies should be tolerant of missing fields (e.g. `dataUrl` missing)
|
||||||
|
- Image parts:
|
||||||
|
- treat missing `dataUrl` as “not hydrated yet”
|
||||||
|
- treat missing `blobID` (legacy) as “not persisted” or “needs migration”
|
||||||
|
- Desktop:
|
||||||
|
- blob files should be namespaced (e.g. `opencode/blobs/<blobID>`) to avoid collisions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Risk + mitigations
|
||||||
|
|
||||||
|
- Risk: blob store is unavailable (IndexedDB disabled, desktop fs permissions).
|
||||||
|
- Mitigation: keep base state functional; persist prompts without image payloads and show a clear placeholder.
|
||||||
|
- Risk: lazy hydration introduces edge cases when submitting.
|
||||||
|
- Mitigation: add a pre-submit “ensure images hydrated” step; if hydration fails, block submission with a clear error or submit without images.
|
||||||
|
- Risk: dataUrl→bytes conversion cost during migration.
|
||||||
|
- Mitigation: migrate incrementally (only when reading an entry) and/or use `requestIdleCallback` on web.
|
||||||
|
- Risk: blob cleanup deletes blobs still needed.
|
||||||
|
- Mitigation: TTL default should be conservative; scan-based cleanup should only delete blobs unreferenced by current persisted state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Validation plan
|
||||||
|
|
||||||
|
- Unit-level:
|
||||||
|
- size estimation + policy enforcement in `persist.ts`
|
||||||
|
- blob store put/get/remove round trips (web + desktop backends)
|
||||||
|
- Manual scenarios:
|
||||||
|
- attach multiple images, reload, and confirm:
|
||||||
|
- KV store files do not balloon
|
||||||
|
- images can be restored when selecting history items
|
||||||
|
- open terminal with large output and confirm reload restores bounded snapshot quickly
|
||||||
|
- confirm prompt draft persistence still works in `packages/app/src/context/prompt.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Rollout plan
|
||||||
|
|
||||||
|
- Phase 1: ship with `persist.payloadLimits` off; log oversize detections in dev.
|
||||||
|
- Phase 2: enable image blob refs behind `persist.imageBlobs` (web + desktop).
|
||||||
|
- Phase 3: enable terminal truncation and enforce hard caps for known hot keys.
|
||||||
|
- Phase 4: enable blob cleanup behind `persist.blobGc` (TTL first).
|
||||||
|
- Provide quick kill switches by disabling each flag independently.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Open questions
|
||||||
|
|
||||||
|
- What should the canonical persisted image schema be (`blobID` field name, placeholder shape, etc.)?
|
||||||
|
- Desktop implementation detail:
|
||||||
|
- add `@tauri-apps/plugin-fs` vs custom `invoke()` commands for blob read/write?
|
||||||
|
- where should blob files live (appDataDir) and what retention policy is acceptable?
|
||||||
|
- Web implementation detail:
|
||||||
|
- do we store `Blob` directly in IndexedDB, or store base64 strings?
|
||||||
|
- Should prompt-history images be retained indefinitely, or only for the last `MAX_HISTORY` entries?
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
## Cache eviction
|
||||||
|
|
||||||
|
Add explicit bounds for long-lived in-memory state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Several in-memory caches grow without limits during long sessions. We’ll introduce explicit eviction (LRU + TTL + size caps) for sessions/messages/file contents and global per-directory sync stores.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Goals
|
||||||
|
|
||||||
|
- Prevent unbounded memory growth from caches that survive navigation
|
||||||
|
- Add consistent eviction primitives shared across contexts
|
||||||
|
- Keep UI responsive under heavy usage (many sessions, large files)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Non-goals
|
||||||
|
|
||||||
|
- Perfect cache hit rates or prefetch strategies
|
||||||
|
- Changing server APIs or adding background jobs
|
||||||
|
- Persisting caches for offline use
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Current state
|
||||||
|
|
||||||
|
- Global sync uses per-directory child stores without eviction in `packages/app/src/context/global-sync.tsx`.
|
||||||
|
- File contents cached in `packages/app/src/context/file.tsx` with no cap.
|
||||||
|
- Session-heavy pages include `packages/app/src/pages/session.tsx` and `packages/app/src/pages/layout.tsx`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Proposed approach
|
||||||
|
|
||||||
|
- Introduce a shared cache utility that supports:
|
||||||
|
- `maxEntries`, `maxBytes` (approx), and `ttlMs`
|
||||||
|
- LRU ordering with explicit `touch(key)` on access
|
||||||
|
- deterministic `evict()` and `clear()` APIs
|
||||||
|
- Apply the utility to:
|
||||||
|
- global-sync per-directory child stores (cap number of directories kept “hot”)
|
||||||
|
- file contents cache (cap by entries + bytes, with TTL)
|
||||||
|
- session/message caches (cap by session count, and optionally message count)
|
||||||
|
- Add feature flags per cache domain to allow partial rollout (e.g. `cache.eviction.files`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phased implementation steps
|
||||||
|
|
||||||
|
1. Add a generic cache helper
|
||||||
|
|
||||||
|
- Create `packages/app/src/utils/cache.ts` with a small, dependency-free LRU+TTL.
|
||||||
|
- Keep it framework-agnostic and usable from Solid contexts.
|
||||||
|
|
||||||
|
Sketch:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type CacheOpts = {
|
||||||
|
maxEntries: number
|
||||||
|
ttlMs?: number
|
||||||
|
maxBytes?: number
|
||||||
|
sizeOf?: (value: unknown) => number
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLruCache<T>(opts: CacheOpts) {
|
||||||
|
// get, set, delete, clear, evictExpired, stats
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Apply eviction to file contents
|
||||||
|
|
||||||
|
- In `packages/app/src/context/file.tsx`:
|
||||||
|
- wrap the existing file-content map in the LRU helper
|
||||||
|
- approximate size via `TextEncoder` length of content strings
|
||||||
|
- evict on `set` and periodically via `requestIdleCallback` when available
|
||||||
|
- Add a small TTL (e.g. 10–30 minutes) to discard stale contents.
|
||||||
|
|
||||||
|
3. Apply eviction to global-sync child stores
|
||||||
|
|
||||||
|
- In `packages/app/src/context/global-sync.tsx`:
|
||||||
|
- track child stores by directory key in an LRU with `maxEntries`
|
||||||
|
- call a `dispose()` hook on eviction to release subscriptions and listeners
|
||||||
|
- Ensure “currently active directory” is always `touch()`’d to avoid surprise evictions.
|
||||||
|
|
||||||
|
4. Apply eviction to session/message caches
|
||||||
|
|
||||||
|
- Identify the session/message caching touchpoints used by `packages/app/src/pages/session.tsx`.
|
||||||
|
- Add caps that reflect UI needs (e.g. last 10–20 sessions kept, last N messages per session if cached).
|
||||||
|
|
||||||
|
5. Add developer tooling
|
||||||
|
|
||||||
|
- Add a debug-only stats readout (console or dev panel) for cache sizes and eviction counts.
|
||||||
|
- Add a one-click “clear caches” action for troubleshooting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Data migration / backward compatibility
|
||||||
|
|
||||||
|
- No persisted schema changes are required since this targets in-memory caches.
|
||||||
|
- If any cache is currently mirrored into persistence, keep keys stable and only change in-memory retention.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Risk + mitigations
|
||||||
|
|
||||||
|
- Risk: evicting content still needed causes extra refetches and flicker.
|
||||||
|
- Mitigation: always pin “active” entities and evict least-recently-used first.
|
||||||
|
- Risk: disposing global-sync child stores could leak listeners if not cleaned up correctly.
|
||||||
|
- Mitigation: require an explicit `dispose()` contract and add dev assertions for listener counts.
|
||||||
|
- Risk: approximate byte sizing is imprecise.
|
||||||
|
- Mitigation: combine entry caps with byte caps and keep thresholds conservative.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Validation plan
|
||||||
|
|
||||||
|
- Add tests for `createLruCache` covering TTL expiry, LRU ordering, and eviction triggers.
|
||||||
|
- Manual scenarios:
|
||||||
|
- open many files and confirm memory stabilizes and UI remains responsive
|
||||||
|
- switch across many directories and confirm global-sync does not continuously grow
|
||||||
|
- long session navigation loop and confirm caches plateau
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Rollout plan
|
||||||
|
|
||||||
|
- Land cache utility first with flags default off.
|
||||||
|
- Enable file cache eviction first (lowest behavioral risk).
|
||||||
|
- Enable global-sync eviction next with conservative caps and strong logging in dev.
|
||||||
|
- Enable session/message eviction last after observing real usage patterns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Open questions
|
||||||
|
|
||||||
|
- What are the current session/message cache structures and their ownership boundaries?
|
||||||
|
- Which child stores in `global-sync.tsx` have resources that must be disposed explicitly?
|
||||||
|
- What caps are acceptable for typical workflows (files open, directories visited, sessions viewed)?
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
## Request throttling
|
||||||
|
|
||||||
|
Debounce and cancel high-frequency server calls
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Some user interactions trigger bursts of server requests that can overlap and return out of order. We’ll debounce frequent triggers and cancel in-flight requests (or ignore stale results) for file search and LSP refresh.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Goals
|
||||||
|
|
||||||
|
- Reduce redundant calls from file search and LSP refresh
|
||||||
|
- Prevent stale responses from overwriting newer UI state
|
||||||
|
- Preserve responsive typing and scrolling during high activity
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Non-goals
|
||||||
|
|
||||||
|
- Changing server-side behavior or adding new endpoints
|
||||||
|
- Implementing global request queues for all SDK calls
|
||||||
|
- Persisting search results across reloads
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Current state
|
||||||
|
|
||||||
|
- File search calls `sdk.client.find.files` via `files.searchFilesAndDirectories`.
|
||||||
|
- LSP refresh is triggered frequently (exact call sites vary, but the refresh behavior is high-frequency).
|
||||||
|
- Large UI modules involved include `packages/app/src/pages/layout.tsx` and `packages/app/src/components/prompt-input.tsx`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Proposed approach
|
||||||
|
|
||||||
|
- Add a small request coordinator utility:
|
||||||
|
- debounced triggering (leading/trailing configurable)
|
||||||
|
- cancellation via `AbortController` when supported
|
||||||
|
- stale-result protection via monotonic request ids when abort is not supported
|
||||||
|
- Integrate coordinator into:
|
||||||
|
- `files.searchFilesAndDirectories` (wrap `sdk.client.find.files`)
|
||||||
|
- LSP refresh call path (wrap refresh invocation and ensure only latest applies)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phased implementation steps
|
||||||
|
|
||||||
|
1. Add a debounced + cancellable helper
|
||||||
|
|
||||||
|
- Create `packages/app/src/utils/requests.ts` with:
|
||||||
|
- `createDebouncedAsync(fn, delayMs)`
|
||||||
|
- `createLatestOnlyAsync(fn)` that drops stale responses
|
||||||
|
- Prefer explicit, readable primitives over a single complex abstraction.
|
||||||
|
|
||||||
|
Sketch:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function createLatestOnlyAsync<TArgs extends unknown[], TResult>(
|
||||||
|
fn: (args: { input: TArgs; signal?: AbortSignal }) => Promise<TResult>,
|
||||||
|
) {
|
||||||
|
let id = 0
|
||||||
|
let controller: AbortController | undefined
|
||||||
|
|
||||||
|
return async (...input: TArgs) => {
|
||||||
|
id += 1
|
||||||
|
const current = id
|
||||||
|
controller?.abort()
|
||||||
|
controller = new AbortController()
|
||||||
|
|
||||||
|
const result = await fn({ input, signal: controller.signal })
|
||||||
|
if (current !== id) return
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Apply to file search
|
||||||
|
|
||||||
|
- Update `files.searchFilesAndDirectories` to:
|
||||||
|
- debounce input changes (e.g. 150–300 ms)
|
||||||
|
- abort prior request when a new query begins
|
||||||
|
- ignore results if they are stale
|
||||||
|
- Ensure “empty query” is handled locally without calling the server.
|
||||||
|
|
||||||
|
3. Apply to LSP refresh
|
||||||
|
|
||||||
|
- Identify the refresh trigger points used during typing and file switching.
|
||||||
|
- Add:
|
||||||
|
- debounce for rapid triggers (e.g. 250–500 ms)
|
||||||
|
- cancellation for in-flight refresh if supported
|
||||||
|
- last-write-wins behavior for applying diagnostics/results
|
||||||
|
|
||||||
|
4. Add feature flags and metrics
|
||||||
|
|
||||||
|
- Add flags:
|
||||||
|
- `requests.debounce.fileSearch`
|
||||||
|
- `requests.latestOnly.lspRefresh`
|
||||||
|
- Add simple dev-only counters for “requests started / aborted / applied”.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Data migration / backward compatibility
|
||||||
|
|
||||||
|
- No persisted data changes.
|
||||||
|
- Behavior is compatible as long as UI state updates only when the “latest” request resolves.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Risk + mitigations
|
||||||
|
|
||||||
|
- Risk: aggressive debounce makes UI feel laggy.
|
||||||
|
- Mitigation: keep delays small and tune separately for search vs refresh.
|
||||||
|
- Risk: aborting requests may surface as errors in logs.
|
||||||
|
- Mitigation: treat `AbortError` as expected and do not log it as a failure.
|
||||||
|
- Risk: SDK method may not accept `AbortSignal`.
|
||||||
|
- Mitigation: use request-id stale protection even without true cancellation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Validation plan
|
||||||
|
|
||||||
|
- Manual scenarios:
|
||||||
|
- type quickly in file search and confirm requests collapse and results stay correct
|
||||||
|
- trigger LSP refresh repeatedly and confirm diagnostics do not flicker backward
|
||||||
|
- Add a small unit test for latest-only behavior (stale results are ignored).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Rollout plan
|
||||||
|
|
||||||
|
- Ship helpers behind flags default off.
|
||||||
|
- Enable file search debounce first (high impact, easy to validate).
|
||||||
|
- Enable LSP latest-only next, then add cancellation if SDK supports signals.
|
||||||
|
- Keep a quick rollback by disabling the flags.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Open questions
|
||||||
|
|
||||||
|
- Does `sdk.client.find.files` accept an abort signal today, or do we need stale-result protection only?
|
||||||
|
- Where is LSP refresh initiated, and does it have a single chokepoint we can wrap?
|
||||||
|
- What debounce values feel best for common repos and slower machines?
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
## Spy acceleration
|
||||||
|
|
||||||
|
Replace O(N) DOM scans in session view
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
The session scroll-spy currently scans the DOM with `querySelectorAll` and walks message nodes, which becomes expensive as message count grows. We’ll replace the scan with an observer-based or indexed approach that scales smoothly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Goals
|
||||||
|
|
||||||
|
- Remove repeated full DOM scans during scroll in the session view
|
||||||
|
- Keep “current message” tracking accurate during streaming and layout shifts
|
||||||
|
- Provide a safe fallback path for older browsers and edge cases
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Non-goals
|
||||||
|
|
||||||
|
- Visual redesign of the session page
|
||||||
|
- Changing message rendering structure or IDs
|
||||||
|
- Perfect accuracy during extreme layout thrash
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Current state
|
||||||
|
|
||||||
|
- `packages/app/src/pages/session.tsx` uses `querySelectorAll('[data-message-id]')` for scroll-spy.
|
||||||
|
- The page is large and handles many responsibilities, increasing the chance of perf regressions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Proposed approach
|
||||||
|
|
||||||
|
Implement a two-tier scroll-spy:
|
||||||
|
|
||||||
|
- Primary: `IntersectionObserver` to track which message elements are visible, updated incrementally.
|
||||||
|
- Secondary: binary search over precomputed offsets when observer is unavailable or insufficient.
|
||||||
|
- Use `ResizeObserver` (and a lightweight “dirty” flag) to refresh offsets only when layout changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phased implementation steps
|
||||||
|
|
||||||
|
1. Extract a dedicated scroll-spy module
|
||||||
|
|
||||||
|
- Create `packages/app/src/pages/session/scroll-spy.ts` (or similar) that exposes:
|
||||||
|
- `register(el, id)` and `unregister(id)`
|
||||||
|
- `getActiveId()` signal/store
|
||||||
|
- Keep DOM operations centralized and easy to profile.
|
||||||
|
|
||||||
|
2. Add IntersectionObserver tracking
|
||||||
|
|
||||||
|
- Observe each `[data-message-id]` element once, on mount.
|
||||||
|
- Maintain a small map of `id -> intersectionRatio` (or visible boolean).
|
||||||
|
- Pick the active id by:
|
||||||
|
- highest intersection ratio, then
|
||||||
|
- nearest to top of viewport as a tiebreaker
|
||||||
|
|
||||||
|
3. Add binary search fallback
|
||||||
|
|
||||||
|
- Maintain an ordered list of `{ id, top }` positions.
|
||||||
|
- On scroll (throttled via `requestAnimationFrame`), compute target Y and binary search to find nearest message.
|
||||||
|
- Refresh the positions list on:
|
||||||
|
- message list mutations (new messages)
|
||||||
|
- container resize events (ResizeObserver)
|
||||||
|
- explicit “layout changed” events after streaming completes
|
||||||
|
|
||||||
|
4. Remove `querySelectorAll` hot path
|
||||||
|
|
||||||
|
- Keep a one-time initial query only as a bridge during rollout, then remove it.
|
||||||
|
- Ensure newly rendered messages are registered via refs rather than scanning the whole DOM.
|
||||||
|
|
||||||
|
5. Add a feature flag and fallback
|
||||||
|
|
||||||
|
- Add `session.scrollSpyOptimized` flag.
|
||||||
|
- If observer setup fails, fall back to the existing scan behavior temporarily.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Data migration / backward compatibility
|
||||||
|
|
||||||
|
- No persisted data changes.
|
||||||
|
- IDs remain sourced from existing `data-message-id` attributes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Risk + mitigations
|
||||||
|
|
||||||
|
- Risk: observer ordering differs from previous “active message” logic.
|
||||||
|
- Mitigation: keep selection rules simple, document them, and add a small tolerance for tie cases.
|
||||||
|
- Risk: layout shifts cause incorrect offset indexing.
|
||||||
|
- Mitigation: refresh offsets with ResizeObserver and after message streaming batches.
|
||||||
|
- Risk: performance regressions from observing too many nodes.
|
||||||
|
- Mitigation: prefer one observer instance and avoid per-node observers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Validation plan
|
||||||
|
|
||||||
|
- Manual scenarios:
|
||||||
|
- very long sessions (hundreds of messages) and continuous scrolling
|
||||||
|
- streaming responses that append content and change heights
|
||||||
|
- resizing the window and toggling side panels
|
||||||
|
- Add a dev-only profiler hook to log time spent in scroll-spy updates per second.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Rollout plan
|
||||||
|
|
||||||
|
- Land extracted module first, still using the old scan internally.
|
||||||
|
- Add observer implementation behind `session.scrollSpyOptimized` off by default.
|
||||||
|
- Enable flag for internal testing, then default on after stability.
|
||||||
|
- Keep fallback code for one release cycle, then remove scan path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Open questions
|
||||||
|
|
||||||
|
- What is the exact definition of “active” used elsewhere (URL hash, sidebar highlight, breadcrumb)?
|
||||||
|
- Are messages virtualized today, or are all DOM nodes mounted at once?
|
||||||
|
- Which container is the scroll root (window vs an inner div), and does it change by layout mode?
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
## Component modularity
|
||||||
|
|
||||||
|
Split mega-components and dedupe scoped caches
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Several large UI files combine rendering, state, persistence, and caching patterns, including repeated “scoped session cache” infrastructure. We’ll extract reusable primitives and break large components into smaller units without changing user-facing behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Goals
|
||||||
|
|
||||||
|
- Reduce complexity in:
|
||||||
|
- `packages/app/src/pages/session.tsx`
|
||||||
|
- `packages/app/src/pages/layout.tsx`
|
||||||
|
- `packages/app/src/components/prompt-input.tsx`
|
||||||
|
- Deduplicate “scoped session cache” logic into a shared utility
|
||||||
|
- Make performance fixes (eviction, throttling) easier to implement safely
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Non-goals
|
||||||
|
|
||||||
|
- Large redesign of routing or page structure
|
||||||
|
- Moving to a different state management approach
|
||||||
|
- Rewriting all contexts in one pass
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Current state
|
||||||
|
|
||||||
|
- Session page is large and mixes concerns (`packages/app/src/pages/session.tsx`).
|
||||||
|
- Layout is also large and likely coordinates multiple global concerns (`packages/app/src/pages/layout.tsx`).
|
||||||
|
- Prompt input is large and includes persistence and interaction logic (`packages/app/src/components/prompt-input.tsx`).
|
||||||
|
- Similar “scoped cache” patterns appear in multiple places (session-bound maps, per-session stores, ad hoc memoization).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Proposed approach
|
||||||
|
|
||||||
|
- Introduce a shared “scoped store” utility to standardize session-bound caches:
|
||||||
|
- keyed by `sessionId`
|
||||||
|
- automatic cleanup via TTL or explicit `dispose(sessionId)`
|
||||||
|
- optional LRU cap for many sessions
|
||||||
|
- Break mega-components into focused modules with clear boundaries:
|
||||||
|
- “view” components (pure rendering)
|
||||||
|
- “controller” hooks (state + effects)
|
||||||
|
- “services” (SDK calls, persistence adapters)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phased implementation steps
|
||||||
|
|
||||||
|
1. Inventory and name the repeated pattern
|
||||||
|
|
||||||
|
- Identify the repeated “scoped session cache” usage sites in:
|
||||||
|
- `packages/app/src/pages/session.tsx`
|
||||||
|
- `packages/app/src/pages/layout.tsx`
|
||||||
|
- `packages/app/src/components/prompt-input.tsx`
|
||||||
|
- Write down the common operations (get-or-create, clear-on-session-change, dispose).
|
||||||
|
|
||||||
|
2. Add a shared scoped-cache utility
|
||||||
|
|
||||||
|
- Create `packages/app/src/utils/scoped-cache.ts`:
|
||||||
|
- `createScopedCache(createValue, opts)` returning `get(key)`, `peek(key)`, `delete(key)`, `clear()`
|
||||||
|
- optional TTL + LRU caps to avoid leak-by-design
|
||||||
|
- Keep the API tiny and explicit so call sites stay readable.
|
||||||
|
|
||||||
|
Sketch:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type ScopedOpts = { maxEntries?: number; ttlMs?: number }
|
||||||
|
|
||||||
|
function createScopedCache<T>(createValue: (key: string) => T, opts: ScopedOpts) {
|
||||||
|
// store + eviction + dispose hooks
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Extract session page submodules
|
||||||
|
|
||||||
|
- Split `packages/app/src/pages/session.tsx` into:
|
||||||
|
- `session/view.tsx` for rendering layout
|
||||||
|
- `session/messages.tsx` for message list
|
||||||
|
- `session/composer.tsx` for input wiring
|
||||||
|
- `session/scroll-spy.ts` for active message tracking
|
||||||
|
- Keep exports stable so routing code changes minimally.
|
||||||
|
|
||||||
|
4. Extract layout coordination logic
|
||||||
|
|
||||||
|
- Split `packages/app/src/pages/layout.tsx` into:
|
||||||
|
- shell layout view
|
||||||
|
- navigation/controller logic
|
||||||
|
- global keyboard shortcuts (if present)
|
||||||
|
- Ensure each extracted piece has a narrow prop surface and no hidden globals.
|
||||||
|
|
||||||
|
5. Extract prompt-input state machine
|
||||||
|
|
||||||
|
- Split `packages/app/src/components/prompt-input.tsx` into:
|
||||||
|
- `usePromptComposer()` hook (draft, submission, attachments)
|
||||||
|
- presentational input component
|
||||||
|
- Route persistence through existing `packages/app/src/context/prompt.tsx`, but isolate wiring code.
|
||||||
|
|
||||||
|
6. Replace ad hoc scoped caches with the shared utility
|
||||||
|
|
||||||
|
- Swap one call site at a time and keep behavior identical.
|
||||||
|
- Add a flag `scopedCache.shared` to fall back to the old implementation if needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Data migration / backward compatibility
|
||||||
|
|
||||||
|
- No persisted schema changes are required by modularization alone.
|
||||||
|
- If any cache keys change due to refactors, keep a compatibility reader for one release cycle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Risk + mitigations
|
||||||
|
|
||||||
|
- Risk: refactors cause subtle behavior changes (focus, keyboard shortcuts, scroll position).
|
||||||
|
- Mitigation: extract without logic changes first, then improve behavior in later diffs.
|
||||||
|
- Risk: new shared cache introduces lifecycle bugs.
|
||||||
|
- Mitigation: require explicit cleanup hooks and add dev assertions for retained keys.
|
||||||
|
- Risk: increased file count makes navigation harder temporarily.
|
||||||
|
- Mitigation: use consistent naming and keep the folder structure shallow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Validation plan
|
||||||
|
|
||||||
|
- Manual regression checklist:
|
||||||
|
- compose, attach images, submit, and reload draft
|
||||||
|
- navigate between sessions and confirm caches don’t bleed across IDs
|
||||||
|
- verify terminal, file search, and scroll-spy still behave normally
|
||||||
|
- Add lightweight unit tests for `createScopedCache` eviction and disposal behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Rollout plan
|
||||||
|
|
||||||
|
- Phase 1: introduce `createScopedCache` unused, then adopt in one low-risk area.
|
||||||
|
- Phase 2: extract session submodules with no behavior changes.
|
||||||
|
- Phase 3: flip remaining scoped caches to shared utility behind `scopedCache.shared`.
|
||||||
|
- Phase 4: remove old duplicated implementations after confidence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Open questions
|
||||||
|
|
||||||
|
- Where exactly is “scoped session cache” duplicated today, and what are the differing lifecycle rules?
|
||||||
|
- Which extracted modules must remain synchronous for Solid reactivity to behave correctly?
|
||||||
|
- Are there implicit dependencies in the large files (module-level state) that need special handling?
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
## Performance roadmap
|
||||||
|
|
||||||
|
Sequenced delivery plan for app scalability + maintainability
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
|
||||||
|
Deliver the top 5 app improvements (performance + long-term flexibility) in a safe, incremental sequence that:
|
||||||
|
|
||||||
|
- minimizes regression risk
|
||||||
|
- keeps changes reviewable (small PRs)
|
||||||
|
- provides escape hatches (flags / caps)
|
||||||
|
- validates improvements with targeted measurements
|
||||||
|
|
||||||
|
This roadmap ties together:
|
||||||
|
|
||||||
|
- `specs/01-persist-payload-limits.md`
|
||||||
|
- `specs/02-cache-eviction.md`
|
||||||
|
- `specs/03-request-throttling.md`
|
||||||
|
- `specs/04-scroll-spy-optimization.md`
|
||||||
|
- `specs/05-modularize-and-dedupe.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Guiding principles
|
||||||
|
|
||||||
|
- Prefer “guardrails first”: add caps/limits and do no harm, then optimize.
|
||||||
|
- Always ship behind flags if behavior changes (especially persistence and eviction).
|
||||||
|
- Optimize at chokepoints (SDK call wrappers, storage wrappers, scroll-spy module) instead of fixing symptoms at every call site.
|
||||||
|
- Make “hot paths” explicitly measurable in dev (e.g. via `packages/app/src/utils/perf.ts`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 0 — Baseline + flags (prep)
|
||||||
|
|
||||||
|
**Goal:** make later changes safe to land and easy to revert.
|
||||||
|
|
||||||
|
**Deliverables**
|
||||||
|
|
||||||
|
- Feature-flag plumbing for:
|
||||||
|
- persistence payload limits (`persist.payloadLimits`)
|
||||||
|
- request debouncing/latest-only (`requests.*`)
|
||||||
|
- cache eviction (`cache.eviction.*`)
|
||||||
|
- optimized scroll spy (`session.scrollSpyOptimized`)
|
||||||
|
- shared scoped cache (`scopedCache.shared`)
|
||||||
|
- Dev-only counters/logs for:
|
||||||
|
- persist oversize detections
|
||||||
|
- request aborts/stale drops
|
||||||
|
- eviction counts and retained sizes
|
||||||
|
- scroll-spy compute time per second
|
||||||
|
|
||||||
|
**Exit criteria**
|
||||||
|
|
||||||
|
- Flags exist but default “off” for behavior changes.
|
||||||
|
- No user-visible behavior changes.
|
||||||
|
|
||||||
|
**Effort / risk**: `S–M` / low
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 1 — Stop the worst “jank generators” (storage + request storms)
|
||||||
|
|
||||||
|
**Goal:** remove the highest-frequency sources of main-thread blocking and redundant work.
|
||||||
|
|
||||||
|
**Work items**
|
||||||
|
|
||||||
|
- Implement file search debounce + stale-result protection
|
||||||
|
- Spec: `specs/03-request-throttling.md`
|
||||||
|
- Start with file search only (lowest risk, easy to observe).
|
||||||
|
- Add persistence payload size checks + warnings (no enforcement yet)
|
||||||
|
- Spec: `specs/01-persist-payload-limits.md`
|
||||||
|
- Focus on detecting oversized keys and preventing repeated write attempts.
|
||||||
|
- Ship prompt-history “strip image dataUrl” behind a flag
|
||||||
|
- Spec: `specs/01-persist-payload-limits.md`
|
||||||
|
- Keep image metadata placeholders so UI remains coherent.
|
||||||
|
|
||||||
|
**Exit criteria**
|
||||||
|
|
||||||
|
- Fast typing in file search generates at most 1 request per debounce window.
|
||||||
|
- Oversize persisted keys are detected and do not cause repeated blocking writes.
|
||||||
|
- Prompt history reload does not attempt to restore base64 `dataUrl` on web when flag enabled.
|
||||||
|
|
||||||
|
**Effort / risk**: `M` / low–med
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2 — Bound memory growth (in-memory eviction)
|
||||||
|
|
||||||
|
**Goal:** stabilize memory footprint for long-running sessions and “project hopping”.
|
||||||
|
|
||||||
|
**Work items**
|
||||||
|
|
||||||
|
- Introduce shared LRU/TTL cache helper
|
||||||
|
- Spec: `specs/02-cache-eviction.md`
|
||||||
|
- Apply eviction to file contents cache first
|
||||||
|
- Spec: `specs/02-cache-eviction.md`
|
||||||
|
- Pin open tabs / active file to prevent flicker.
|
||||||
|
- Add conservative eviction for global-sync per-directory child stores
|
||||||
|
- Spec: `specs/02-cache-eviction.md`
|
||||||
|
- Ensure evicted children are fully disposed.
|
||||||
|
- (Optional) session/message eviction if memory growth persists after the above
|
||||||
|
- Spec: `specs/02-cache-eviction.md`
|
||||||
|
|
||||||
|
**Exit criteria**
|
||||||
|
|
||||||
|
- Opening many files does not continuously increase JS heap without bound.
|
||||||
|
- Switching across many directories does not keep all directory stores alive indefinitely.
|
||||||
|
- Eviction never removes currently active session/file content.
|
||||||
|
|
||||||
|
**Effort / risk**: `M–L` / med
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3 — Large session scroll scalability (scroll spy)
|
||||||
|
|
||||||
|
**Goal:** keep scrolling smooth as message count increases.
|
||||||
|
|
||||||
|
**Work items**
|
||||||
|
|
||||||
|
- Extract scroll-spy logic into a dedicated module (no behavior change)
|
||||||
|
- Spec: `specs/04-scroll-spy-optimization.md`
|
||||||
|
- Implement IntersectionObserver tracking behind flag
|
||||||
|
- Spec: `specs/04-scroll-spy-optimization.md`
|
||||||
|
- Add binary search fallback for non-observer environments
|
||||||
|
- Spec: `specs/04-scroll-spy-optimization.md`
|
||||||
|
|
||||||
|
**Exit criteria**
|
||||||
|
|
||||||
|
- Scroll handler no longer calls `querySelectorAll('[data-message-id]')` on every scroll tick.
|
||||||
|
- Long sessions (hundreds of messages) maintain smooth scrolling.
|
||||||
|
- Active message selection remains stable during streaming/layout shifts.
|
||||||
|
|
||||||
|
**Effort / risk**: `M` / med
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4 — “Make it easy to keep fast” (modularity + dedupe)
|
||||||
|
|
||||||
|
**Goal:** reduce maintenance cost and make future perf work cheaper.
|
||||||
|
|
||||||
|
**Work items**
|
||||||
|
|
||||||
|
- Introduce shared scoped-cache utility and adopt in one low-risk area
|
||||||
|
- Spec: `specs/05-modularize-and-dedupe.md`
|
||||||
|
- Incrementally split mega-components (one PR per extraction)
|
||||||
|
- Spec: `specs/05-modularize-and-dedupe.md`
|
||||||
|
- Prioritize extracting:
|
||||||
|
- session scroll/backfill logic
|
||||||
|
- prompt editor model/history
|
||||||
|
- layout event/shortcut wiring
|
||||||
|
- Remove duplicated patterns after confidence + one release cycle
|
||||||
|
|
||||||
|
**Exit criteria**
|
||||||
|
|
||||||
|
- Each mega-file drops below a target size (suggestion):
|
||||||
|
- `session.tsx` < ~800 LOC
|
||||||
|
- `prompt-input.tsx` < ~900 LOC
|
||||||
|
- “Scoped cache” has a single implementation used across contexts.
|
||||||
|
- Future perf fixes land in isolated modules with minimal cross-cutting change.
|
||||||
|
|
||||||
|
**Effort / risk**: `L` / med–high
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Recommended PR slicing (keeps reviews safe)
|
||||||
|
|
||||||
|
- PR A: add request helpers + file search debounce (flagged)
|
||||||
|
- PR B: persist size detection + logs (no behavior change)
|
||||||
|
- PR C: prompt history strip images (flagged)
|
||||||
|
- PR D: cache helper + file content eviction (flagged)
|
||||||
|
- PR E: global-sync child eviction (flagged)
|
||||||
|
- PR F: scroll-spy extraction (no behavior change)
|
||||||
|
- PR G: optimized scroll-spy implementation (flagged)
|
||||||
|
- PR H+: modularization PRs (small, mechanical refactors)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Rollout strategy
|
||||||
|
|
||||||
|
- Keep defaults conservative and ship flags “off” first.
|
||||||
|
- Enable flags internally (dev builds) to gather confidence.
|
||||||
|
- Flip defaults in this order:
|
||||||
|
1. file search debounce
|
||||||
|
2. prompt-history image stripping
|
||||||
|
3. file-content eviction
|
||||||
|
4. global-sync child eviction
|
||||||
|
5. optimized scroll-spy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Open questions
|
||||||
|
|
||||||
|
- What are acceptable defaults for storage caps and cache sizes for typical OpenCode usage?
|
||||||
|
- Does the SDK support `AbortSignal` end-to-end for cancellation, or do we rely on stale-result dropping?
|
||||||
|
- Should web and desktop persistence semantics be aligned (even if desktop has async storage available)?
|
||||||
Reference in New Issue
Block a user