Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
858e7279b0 | ||
|
|
2b0a68fce4 | ||
|
|
9048a7c301 | ||
|
|
083c4a7a6a | ||
|
|
1594fb962f | ||
|
|
085f66daed | ||
|
|
cb22b689de | ||
|
|
e940184115 | ||
|
|
6c25b9aa2d | ||
|
|
75541f8c2d | ||
|
|
15abc9ea4d | ||
|
|
b42628a744 | ||
|
|
20b6eee370 | ||
|
|
6f5d9d427e | ||
|
|
bd332c8f0a | ||
|
|
fbfbc1eac3 | ||
|
|
6412d09bc5 | ||
|
|
67efb1b76c | ||
|
|
f3c803173d | ||
|
|
0bbf26a1ce | ||
|
|
83cdb4de64 | ||
|
|
4989632245 | ||
|
|
d460614cd7 | ||
|
|
7866dbcfcc | ||
|
|
e71a21e0a8 | ||
|
|
1071aca91f | ||
|
|
b3d0446d13 | ||
|
|
949191ab74 | ||
|
|
92cd908fb5 | ||
|
|
6fcc970def | ||
|
|
52a7a04ad8 | ||
|
|
37b8662a9d | ||
|
|
ddcb32ae0b | ||
|
|
2c056c90da | ||
|
|
812d1bb32a | ||
|
|
9a58c43ef4 | ||
|
|
63585db6a7 | ||
|
|
bd44489ada | ||
|
|
a6ef9e9206 | ||
|
|
6e09a1d904 | ||
|
|
4f21757e0d | ||
|
|
2dbcd79fd2 | ||
|
|
48a7f0fd93 | ||
|
|
d69962b0f7 | ||
|
|
a6f23cb08e | ||
|
|
0540751897 |
@@ -50,20 +50,17 @@ jobs:
|
|||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
name: e2e (${{ matrix.settings.name }})
|
name: e2e (${{ matrix.settings.name }})
|
||||||
needs: unit
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
settings:
|
settings:
|
||||||
- name: linux
|
- name: linux
|
||||||
host: blacksmith-4vcpu-ubuntu-2404
|
host: blacksmith-4vcpu-ubuntu-2404
|
||||||
playwright: bunx playwright install --with-deps
|
|
||||||
- name: windows
|
- name: windows
|
||||||
host: blacksmith-4vcpu-windows-2025
|
host: blacksmith-4vcpu-windows-2025
|
||||||
playwright: bunx playwright install
|
|
||||||
runs-on: ${{ matrix.settings.host }}
|
runs-on: ${{ matrix.settings.host }}
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_BROWSERS_PATH: 0
|
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -76,9 +73,28 @@ jobs:
|
|||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: ./.github/actions/setup-bun
|
uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
- name: Read Playwright version
|
||||||
|
id: playwright-version
|
||||||
|
run: |
|
||||||
|
version=$(node -e 'console.log(require("./packages/app/package.json").devDependencies["@playwright/test"])')
|
||||||
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Cache Playwright browsers
|
||||||
|
id: playwright-cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ github.workspace }}/.playwright-browsers
|
||||||
|
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
|
||||||
|
|
||||||
|
- name: Install Playwright system dependencies
|
||||||
|
if: runner.os == 'Linux'
|
||||||
working-directory: packages/app
|
working-directory: packages/app
|
||||||
run: ${{ matrix.settings.playwright }}
|
run: bunx playwright install-deps chromium
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||||
|
working-directory: packages/app
|
||||||
|
run: bunx playwright install chromium
|
||||||
|
|
||||||
- name: Run app e2e tests
|
- name: Run app e2e tests
|
||||||
run: bun --cwd packages/app test:e2e:local
|
run: bun --cwd packages/app test:e2e:local
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
plans/
|
node_modules
|
||||||
bun.lock
|
plans
|
||||||
package.json
|
package.json
|
||||||
package-lock.json
|
bun.lock
|
||||||
|
.gitignore
|
||||||
|
package-lock.json
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
description: Translate content for a specified locale while preserving technical terms
|
description: Translate content for a specified locale while preserving technical terms
|
||||||
mode: subagent
|
mode: subagent
|
||||||
model: opencode/gemini-3.1-pro
|
model: opencode/gpt-5.4
|
||||||
---
|
---
|
||||||
|
|
||||||
You are a professional translator and localization specialist.
|
You are a professional translator and localization specialist.
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
/// <reference path="../env.d.ts" />
|
/// <reference path="../env.d.ts" />
|
||||||
import { tool } from "@opencode-ai/plugin"
|
import { tool } from "@opencode-ai/plugin"
|
||||||
import DESCRIPTION from "./github-pr-search.txt"
|
|
||||||
|
|
||||||
async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
||||||
const response = await fetch(`https://api.github.com${endpoint}`, {
|
const response = await fetch(`https://api.github.com${endpoint}`, {
|
||||||
...options,
|
...options,
|
||||||
@@ -24,7 +22,16 @@ interface PR {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default tool({
|
export default tool({
|
||||||
description: DESCRIPTION,
|
description: `Use this tool to search GitHub pull requests by title and description.
|
||||||
|
|
||||||
|
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
|
||||||
|
- PR number and title
|
||||||
|
- Author
|
||||||
|
- State (open/closed/merged)
|
||||||
|
- Labels
|
||||||
|
- Description snippet
|
||||||
|
|
||||||
|
Use the query parameter to search for keywords that might appear in PR titles or descriptions.`,
|
||||||
args: {
|
args: {
|
||||||
query: tool.schema.string().describe("Search query for PR titles and descriptions"),
|
query: tool.schema.string().describe("Search query for PR titles and descriptions"),
|
||||||
limit: tool.schema.number().describe("Maximum number of results to return").default(10),
|
limit: tool.schema.number().describe("Maximum number of results to return").default(10),
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
Use this tool to search GitHub pull requests by title and description.
|
|
||||||
|
|
||||||
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
|
|
||||||
- PR number and title
|
|
||||||
- Author
|
|
||||||
- State (open/closed/merged)
|
|
||||||
- Labels
|
|
||||||
- Description snippet
|
|
||||||
|
|
||||||
Use the query parameter to search for keywords that might appear in PR titles or descriptions.
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
/// <reference path="../env.d.ts" />
|
/// <reference path="../env.d.ts" />
|
||||||
import { tool } from "@opencode-ai/plugin"
|
import { tool } from "@opencode-ai/plugin"
|
||||||
import DESCRIPTION from "./github-triage.txt"
|
|
||||||
|
|
||||||
const TEAM = {
|
const TEAM = {
|
||||||
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
|
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
|
||||||
zen: ["fwang", "MrMushrooooom"],
|
zen: ["fwang", "MrMushrooooom"],
|
||||||
@@ -40,7 +38,12 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default tool({
|
export default tool({
|
||||||
description: DESCRIPTION,
|
description: `Use this tool to assign and/or label a GitHub issue.
|
||||||
|
|
||||||
|
Choose labels and assignee using the current triage policy and ownership rules.
|
||||||
|
Pick the most fitting labels for the issue and assign one owner.
|
||||||
|
|
||||||
|
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`,
|
||||||
args: {
|
args: {
|
||||||
assignee: tool.schema
|
assignee: tool.schema
|
||||||
.enum(ASSIGNEES as [string, ...string[]])
|
.enum(ASSIGNEES as [string, ...string[]])
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
Use this tool to assign and/or label a GitHub issue.
|
|
||||||
|
|
||||||
Choose labels and assignee using the current triage policy and ownership rules.
|
|
||||||
Pick the most fitting labels for the issue and assign one owner.
|
|
||||||
|
|
||||||
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.
|
|
||||||
@@ -355,7 +355,7 @@
|
|||||||
"cross-spawn": "^7.0.6",
|
"cross-spawn": "^7.0.6",
|
||||||
"decimal.js": "10.5.0",
|
"decimal.js": "10.5.0",
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"glob": "13.0.5",
|
"glob": "13.0.5",
|
||||||
@@ -409,8 +409,8 @@
|
|||||||
"@types/which": "3.0.4",
|
"@types/which": "3.0.4",
|
||||||
"@types/yargs": "17.0.33",
|
"@types/yargs": "17.0.33",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
"drizzle-kit": "catalog:",
|
||||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
"drizzle-orm": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vscode-languageserver-types": "3.17.5",
|
"vscode-languageserver-types": "3.17.5",
|
||||||
"why-is-node-running": "3.2.2",
|
"why-is-node-running": "3.2.2",
|
||||||
@@ -586,6 +586,8 @@
|
|||||||
],
|
],
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
|
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
|
||||||
|
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||||
|
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch",
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
@@ -616,8 +618,8 @@
|
|||||||
"ai": "5.0.124",
|
"ai": "5.0.124",
|
||||||
"diff": "8.0.2",
|
"diff": "8.0.2",
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||||
"effect": "4.0.0-beta.35",
|
"effect": "4.0.0-beta.35",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
@@ -2736,9 +2738,9 @@
|
|||||||
|
|
||||||
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
|
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
|
||||||
|
|
||||||
"drizzle-kit": ["drizzle-kit@1.0.0-beta.16-ea816b6", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GiJQqCNPZP8Kk+i7/sFa3rtXbq26tLDNi3LbMx9aoLuwF2ofk8CS7cySUGdI+r4J3q0a568quC8FZeaFTCw4IA=="],
|
"drizzle-kit": ["drizzle-kit@1.0.0-beta.19-d95b7a4", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-M0sqc+42TYBod6kEZ3AsW6+JWe3+76gR1aDFbHH5DmuLKEwewmbzlhBG6qnvV6YA1cIIbkuam3dC7r6PREOCXw=="],
|
||||||
|
|
||||||
"drizzle-orm": ["drizzle-orm@1.0.0-beta.16-ea816b6", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-k9gT4f0O9Qvah5YK/zL+FZonQ8TPyVxcG/ojN4dzO0fHP8hs8tBno8lqmJo53g0JLWv3Q2nsTUoyBRKM2TljFw=="],
|
"drizzle-orm": ["drizzle-orm@1.0.0-beta.19-d95b7a4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-bZZKKeoRKrMVU6zKTscjrSH0+WNb1WEi3N0Jl4wEyQ7aQpTgHzdYY6IJQ1P0M74HuSJVeX4UpkFB/S6dtqLEJg=="],
|
||||||
|
|
||||||
"dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
|
"dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
|
||||||
|
|
||||||
@@ -3020,6 +3022,8 @@
|
|||||||
|
|
||||||
"get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
|
"get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
|
||||||
|
|
||||||
|
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
|
||||||
|
|
||||||
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d", "sha512-fbEK8mtr7ar4ySsF+JUGjhaZrane7dKphanN+SxHt5XXI6yLMAh/Hpf6sNCOyyVa2UlGCd7YpXG/T2v2RUAX+A=="],
|
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d", "sha512-fbEK8mtr7ar4ySsF+JUGjhaZrane7dKphanN+SxHt5XXI6yLMAh/Hpf6sNCOyyVa2UlGCd7YpXG/T2v2RUAX+A=="],
|
||||||
|
|
||||||
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
|
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
|
||||||
@@ -4108,6 +4112,8 @@
|
|||||||
|
|
||||||
"resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
|
"resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
|
||||||
|
|
||||||
|
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||||
|
|
||||||
"responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
|
"responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
|
||||||
|
|
||||||
"restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="],
|
"restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="],
|
||||||
@@ -5386,6 +5392,8 @@
|
|||||||
|
|
||||||
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|
||||||
|
"db0/drizzle-orm": ["drizzle-orm@1.0.0-beta.16-ea816b6", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-k9gT4f0O9Qvah5YK/zL+FZonQ8TPyVxcG/ojN4dzO0fHP8hs8tBno8lqmJo53g0JLWv3Q2nsTUoyBRKM2TljFw=="],
|
||||||
|
|
||||||
"defaults/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
|
"defaults/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
|
||||||
|
|
||||||
"dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
"dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
|
|||||||
properties: {
|
properties: {
|
||||||
product: zenLiteProduct.id,
|
product: zenLiteProduct.id,
|
||||||
price: zenLitePrice.id,
|
price: zenLitePrice.id,
|
||||||
|
priceInr: 92900,
|
||||||
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
|
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-yfA50QKqylmaioxi+6d++W8Xv4Wix1hl3hEF6Zz7Ue0=",
|
"x86_64-linux": "sha256-Gv0pHYCinlj0SQXRQ/a9ozYPxECwdrC99ssTzpeOr1I=",
|
||||||
"aarch64-linux": "sha256-b5sO7V+/zzJClHHKjkSz+9AUBYC8cb7S3m5ab1kpAyk=",
|
"aarch64-linux": "sha256-WzVt5goOrxoGe26juzRf73PWPqwnB1URu2TYjxye/Aw=",
|
||||||
"aarch64-darwin": "sha256-V66nmRX6kAjrc41ARVeuTElWK7KD8qG/DVk9K7Fu+J8=",
|
"aarch64-darwin": "sha256-18Nn0TR1wK2gRUF/FFP4vFMY/td49XkfjOwFbD5iJNc=",
|
||||||
"x86_64-darwin": "sha256-cFyh60WESiqZ5XWZi1+g3F/beSDL1+UPG8KhRivhK8w="
|
"x86_64-darwin": "sha256-zk2yaulPzUUiCerCPJaCOCLhklXKMp9mSv7v0N8AMfA="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-3
@@ -43,8 +43,8 @@
|
|||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
"diff": "8.0.2",
|
"diff": "8.0.2",
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||||
"effect": "4.0.0-beta.35",
|
"effect": "4.0.0-beta.35",
|
||||||
"ai": "5.0.124",
|
"ai": "5.0.124",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
@@ -112,6 +112,8 @@
|
|||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch"
|
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
|
||||||
|
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch",
|
||||||
|
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+145
-7
@@ -9,6 +9,7 @@ import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
|||||||
import {
|
import {
|
||||||
dropdownMenuTriggerSelector,
|
dropdownMenuTriggerSelector,
|
||||||
dropdownMenuContentSelector,
|
dropdownMenuContentSelector,
|
||||||
|
projectSwitchSelector,
|
||||||
projectMenuTriggerSelector,
|
projectMenuTriggerSelector,
|
||||||
projectCloseMenuSelector,
|
projectCloseMenuSelector,
|
||||||
projectWorkspacesToggleSelector,
|
projectWorkspacesToggleSelector,
|
||||||
@@ -23,6 +24,16 @@ import {
|
|||||||
workspaceMenuTriggerSelector,
|
workspaceMenuTriggerSelector,
|
||||||
} from "./selectors"
|
} from "./selectors"
|
||||||
|
|
||||||
|
const phase = new WeakMap<Page, "test" | "cleanup">()
|
||||||
|
|
||||||
|
export function setHealthPhase(page: Page, value: "test" | "cleanup") {
|
||||||
|
phase.set(page, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function healthPhase(page: Page) {
|
||||||
|
return phase.get(page) ?? "test"
|
||||||
|
}
|
||||||
|
|
||||||
export async function defocus(page: Page) {
|
export async function defocus(page: Page) {
|
||||||
await page
|
await page
|
||||||
.evaluate(() => {
|
.evaluate(() => {
|
||||||
@@ -196,11 +207,51 @@ export async function closeDialog(page: Page, dialog: Locator) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function isSidebarClosed(page: Page) {
|
export async function isSidebarClosed(page: Page) {
|
||||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
const button = await waitSidebarButton(page, "isSidebarClosed")
|
||||||
await expect(button).toBeVisible()
|
|
||||||
return (await button.getAttribute("aria-expanded")) !== "true"
|
return (await button.getAttribute("aria-expanded")) !== "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function errorBoundaryText(page: Page) {
|
||||||
|
const title = page.getByRole("heading", { name: /something went wrong/i }).first()
|
||||||
|
if (!(await title.isVisible().catch(() => false))) return
|
||||||
|
|
||||||
|
const description = await page
|
||||||
|
.getByText(/an error occurred while loading the application\./i)
|
||||||
|
.first()
|
||||||
|
.textContent()
|
||||||
|
.catch(() => "")
|
||||||
|
const detail = await page
|
||||||
|
.getByRole("textbox", { name: /error details/i })
|
||||||
|
.first()
|
||||||
|
.inputValue()
|
||||||
|
.catch(async () =>
|
||||||
|
(
|
||||||
|
(await page
|
||||||
|
.getByRole("textbox", { name: /error details/i })
|
||||||
|
.first()
|
||||||
|
.textContent()
|
||||||
|
.catch(() => "")) ?? ""
|
||||||
|
).trim(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertHealthy(page: Page, context: string) {
|
||||||
|
const text = await errorBoundaryText(page)
|
||||||
|
if (!text) return
|
||||||
|
console.log(`[e2e:error-boundary][${context}]\n${text}`)
|
||||||
|
throw new Error(`Error boundary during ${context}\n${text}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitSidebarButton(page: Page, context: string) {
|
||||||
|
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||||
|
const boundary = page.getByRole("heading", { name: /something went wrong/i }).first()
|
||||||
|
await button.or(boundary).first().waitFor({ state: "visible", timeout: 10_000 })
|
||||||
|
await assertHealthy(page, context)
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
export async function toggleSidebar(page: Page) {
|
export async function toggleSidebar(page: Page) {
|
||||||
await defocus(page)
|
await defocus(page)
|
||||||
await page.keyboard.press(`${modKey}+B`)
|
await page.keyboard.press(`${modKey}+B`)
|
||||||
@@ -209,7 +260,7 @@ export async function toggleSidebar(page: Page) {
|
|||||||
export async function openSidebar(page: Page) {
|
export async function openSidebar(page: Page) {
|
||||||
if (!(await isSidebarClosed(page))) return
|
if (!(await isSidebarClosed(page))) return
|
||||||
|
|
||||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
const button = await waitSidebarButton(page, "openSidebar")
|
||||||
await button.click()
|
await button.click()
|
||||||
|
|
||||||
const opened = await expect(button)
|
const opened = await expect(button)
|
||||||
@@ -226,7 +277,7 @@ export async function openSidebar(page: Page) {
|
|||||||
export async function closeSidebar(page: Page) {
|
export async function closeSidebar(page: Page) {
|
||||||
if (await isSidebarClosed(page)) return
|
if (await isSidebarClosed(page)) return
|
||||||
|
|
||||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
const button = await waitSidebarButton(page, "closeSidebar")
|
||||||
await button.click()
|
await button.click()
|
||||||
|
|
||||||
const closed = await expect(button)
|
const closed = await expect(button)
|
||||||
@@ -241,6 +292,7 @@ export async function closeSidebar(page: Page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function openSettings(page: Page) {
|
export async function openSettings(page: Page) {
|
||||||
|
await assertHealthy(page, "openSettings")
|
||||||
await defocus(page)
|
await defocus(page)
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog")
|
const dialog = page.getByRole("dialog")
|
||||||
@@ -253,6 +305,8 @@ export async function openSettings(page: Page) {
|
|||||||
|
|
||||||
if (opened) return dialog
|
if (opened) return dialog
|
||||||
|
|
||||||
|
await assertHealthy(page, "openSettings")
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Settings" }).first().click()
|
await page.getByRole("button", { name: "Settings" }).first().click()
|
||||||
await expect(dialog).toBeVisible()
|
await expect(dialog).toBeVisible()
|
||||||
return dialog
|
return dialog
|
||||||
@@ -314,10 +368,12 @@ export async function seedProjects(page: Page, input: { directory: string; extra
|
|||||||
|
|
||||||
export async function createTestProject() {
|
export async function createTestProject() {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
|
||||||
|
const id = `e2e-${path.basename(root)}`
|
||||||
|
|
||||||
await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
|
await fs.writeFile(path.join(root, "README.md"), `# e2e\n\n${id}\n`)
|
||||||
|
|
||||||
execSync("git init", { cwd: root, stdio: "ignore" })
|
execSync("git init", { cwd: root, stdio: "ignore" })
|
||||||
|
await fs.writeFile(path.join(root, ".git", "opencode"), id)
|
||||||
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
|
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
|
||||||
execSync("git add -A", { cwd: root, stdio: "ignore" })
|
execSync("git add -A", { cwd: root, stdio: "ignore" })
|
||||||
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
|
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
|
||||||
@@ -339,12 +395,24 @@ export function slugFromUrl(url: string) {
|
|||||||
return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
|
return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function probeSession(page: Page) {
|
||||||
|
return page
|
||||||
|
.evaluate(() => {
|
||||||
|
const win = window as E2EWindow
|
||||||
|
const current = win.__opencode_e2e?.model?.current
|
||||||
|
if (!current) return null
|
||||||
|
return { dir: current.dir, sessionID: current.sessionID }
|
||||||
|
})
|
||||||
|
.catch(() => null as { dir?: string; sessionID?: string } | null)
|
||||||
|
}
|
||||||
|
|
||||||
export async function waitSlug(page: Page, skip: string[] = []) {
|
export async function waitSlug(page: Page, skip: string[] = []) {
|
||||||
let prev = ""
|
let prev = ""
|
||||||
let next = ""
|
let next = ""
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
() => {
|
async () => {
|
||||||
|
await assertHealthy(page, "waitSlug")
|
||||||
const slug = slugFromUrl(page.url())
|
const slug = slugFromUrl(page.url())
|
||||||
if (!slug) return ""
|
if (!slug) return ""
|
||||||
if (skip.includes(slug)) return ""
|
if (skip.includes(slug)) return ""
|
||||||
@@ -374,6 +442,7 @@ export async function waitDir(page: Page, directory: string) {
|
|||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
|
await assertHealthy(page, "waitDir")
|
||||||
const slug = slugFromUrl(page.url())
|
const slug = slugFromUrl(page.url())
|
||||||
if (!slug) return ""
|
if (!slug) return ""
|
||||||
return resolveSlug(slug)
|
return resolveSlug(slug)
|
||||||
@@ -386,6 +455,69 @@ export async function waitDir(page: Page, directory: string) {
|
|||||||
return { directory: target, slug: base64Encode(target) }
|
return { directory: target, slug: base64Encode(target) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) {
|
||||||
|
const target = await resolveDirectory(input.directory)
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
await assertHealthy(page, "waitSession")
|
||||||
|
const slug = slugFromUrl(page.url())
|
||||||
|
if (!slug) return false
|
||||||
|
const resolved = await resolveSlug(slug).catch(() => undefined)
|
||||||
|
if (!resolved || resolved.directory !== target) return false
|
||||||
|
if (input.sessionID && sessionIDFromUrl(page.url()) !== input.sessionID) return false
|
||||||
|
|
||||||
|
const state = await probeSession(page)
|
||||||
|
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
|
||||||
|
if (state?.dir) {
|
||||||
|
const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
|
||||||
|
if (dir !== target) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return page
|
||||||
|
.locator(promptSelector)
|
||||||
|
.first()
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false)
|
||||||
|
},
|
||||||
|
{ timeout: 45_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
return { directory: target, slug: base64Encode(target) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) {
|
||||||
|
const sdk = createSdk(directory)
|
||||||
|
const target = await resolveDirectory(directory)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const data = await sdk.session
|
||||||
|
.get({ sessionID })
|
||||||
|
.then((x) => x.data)
|
||||||
|
.catch(() => undefined)
|
||||||
|
if (!data?.directory) return ""
|
||||||
|
return resolveDirectory(data.directory).catch(() => data.directory)
|
||||||
|
},
|
||||||
|
{ timeout },
|
||||||
|
)
|
||||||
|
.toBe(target)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const items = await sdk.session
|
||||||
|
.messages({ sessionID, limit: 20 })
|
||||||
|
.then((x) => x.data ?? [])
|
||||||
|
.catch(() => [])
|
||||||
|
return items.some((item) => item.info.role === "user")
|
||||||
|
},
|
||||||
|
{ timeout },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
export function sessionIDFromUrl(url: string) {
|
export function sessionIDFromUrl(url: string) {
|
||||||
const match = /\/session\/([^/?#]+)/.exec(url)
|
const match = /\/session\/([^/?#]+)/.exec(url)
|
||||||
return match?.[1]
|
return match?.[1]
|
||||||
@@ -797,8 +929,14 @@ export async function openStatusPopover(page: Page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function openProjectMenu(page: Page, projectSlug: string) {
|
export async function openProjectMenu(page: Page, projectSlug: string) {
|
||||||
|
await openSidebar(page)
|
||||||
|
const item = page.locator(projectSwitchSelector(projectSlug)).first()
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
await item.hover()
|
||||||
|
|
||||||
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
|
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
|
||||||
await expect(trigger).toHaveCount(1)
|
await expect(trigger).toHaveCount(1)
|
||||||
|
await expect(trigger).toBeVisible()
|
||||||
|
|
||||||
const menu = page
|
const menu = page
|
||||||
.locator(dropdownMenuContentSelector)
|
.locator(dropdownMenuContentSelector)
|
||||||
@@ -807,7 +945,7 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
|
|||||||
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
|
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
|
||||||
|
|
||||||
const clicked = await trigger
|
const clicked = await trigger
|
||||||
.click({ timeout: 1500 })
|
.click({ force: true, timeout: 1500 })
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { test as base, expect, type Page } from "@playwright/test"
|
import { test as base, expect, type Page } from "@playwright/test"
|
||||||
import type { E2EWindow } from "../src/testing/terminal"
|
import type { E2EWindow } from "../src/testing/terminal"
|
||||||
import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
|
import {
|
||||||
import { promptSelector } from "./selectors"
|
healthPhase,
|
||||||
|
cleanupSession,
|
||||||
|
cleanupTestProject,
|
||||||
|
createTestProject,
|
||||||
|
setHealthPhase,
|
||||||
|
seedProjects,
|
||||||
|
sessionIDFromUrl,
|
||||||
|
waitSlug,
|
||||||
|
waitSession,
|
||||||
|
} from "./actions"
|
||||||
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
||||||
|
|
||||||
export const settingsKey = "settings.v3"
|
export const settingsKey = "settings.v3"
|
||||||
@@ -27,6 +36,29 @@ type WorkerFixtures = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||||
|
page: async ({ page }, use) => {
|
||||||
|
let boundary: string | undefined
|
||||||
|
setHealthPhase(page, "test")
|
||||||
|
const consoleHandler = (msg: { text(): string }) => {
|
||||||
|
const text = msg.text()
|
||||||
|
if (!text.includes("[e2e:error-boundary]")) return
|
||||||
|
if (healthPhase(page) === "cleanup") {
|
||||||
|
console.warn(`[e2e:error-boundary][cleanup-warning]\n${text}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
boundary ||= text
|
||||||
|
console.log(text)
|
||||||
|
}
|
||||||
|
const pageErrorHandler = (err: Error) => {
|
||||||
|
console.log(`[e2e:pageerror] ${err.stack || err.message}`)
|
||||||
|
}
|
||||||
|
page.on("console", consoleHandler)
|
||||||
|
page.on("pageerror", pageErrorHandler)
|
||||||
|
await use(page)
|
||||||
|
page.off("console", consoleHandler)
|
||||||
|
page.off("pageerror", pageErrorHandler)
|
||||||
|
if (boundary) throw new Error(boundary)
|
||||||
|
},
|
||||||
directory: [
|
directory: [
|
||||||
async ({}, use) => {
|
async ({}, use) => {
|
||||||
const directory = await getWorktree()
|
const directory = await getWorktree()
|
||||||
@@ -48,21 +80,20 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
|||||||
|
|
||||||
const gotoSession = async (sessionID?: string) => {
|
const gotoSession = async (sessionID?: string) => {
|
||||||
await page.goto(sessionPath(directory, sessionID))
|
await page.goto(sessionPath(directory, sessionID))
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
await waitSession(page, { directory, sessionID })
|
||||||
}
|
}
|
||||||
await use(gotoSession)
|
await use(gotoSession)
|
||||||
},
|
},
|
||||||
withProject: async ({ page }, use) => {
|
withProject: async ({ page }, use) => {
|
||||||
await use(async (callback, options) => {
|
await use(async (callback, options) => {
|
||||||
const root = await createTestProject()
|
const root = await createTestProject()
|
||||||
const slug = dirSlug(root)
|
|
||||||
const sessions = new Map<string, string>()
|
const sessions = new Map<string, string>()
|
||||||
const dirs = new Set<string>()
|
const dirs = new Set<string>()
|
||||||
await seedStorage(page, { directory: root, extra: options?.extra })
|
await seedStorage(page, { directory: root, extra: options?.extra })
|
||||||
|
|
||||||
const gotoSession = async (sessionID?: string) => {
|
const gotoSession = async (sessionID?: string) => {
|
||||||
await page.goto(sessionPath(root, sessionID))
|
await page.goto(sessionPath(root, sessionID))
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
await waitSession(page, { directory: root, sessionID })
|
||||||
const current = sessionIDFromUrl(page.url())
|
const current = sessionIDFromUrl(page.url())
|
||||||
if (current) trackSession(current)
|
if (current) trackSession(current)
|
||||||
}
|
}
|
||||||
@@ -77,13 +108,16 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
const slug = await waitSlug(page)
|
||||||
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
|
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
|
||||||
} finally {
|
} finally {
|
||||||
|
setHealthPhase(page, "cleanup")
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
|
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
|
||||||
)
|
)
|
||||||
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
|
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
|
||||||
await cleanupTestProject(root)
|
await cleanupTestProject(root)
|
||||||
|
setHealthPhase(page, "test")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
import { base64Decode } from "@opencode-ai/util/encode"
|
||||||
import type { Page } from "@playwright/test"
|
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import {
|
import {
|
||||||
defocus,
|
defocus,
|
||||||
@@ -7,43 +6,14 @@ import {
|
|||||||
cleanupTestProject,
|
cleanupTestProject,
|
||||||
openSidebar,
|
openSidebar,
|
||||||
sessionIDFromUrl,
|
sessionIDFromUrl,
|
||||||
waitDir,
|
setWorkspacesEnabled,
|
||||||
|
waitSession,
|
||||||
|
waitSessionSaved,
|
||||||
waitSlug,
|
waitSlug,
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||||
import { dirSlug, resolveDirectory } from "../utils"
|
import { dirSlug, resolveDirectory } from "../utils"
|
||||||
|
|
||||||
async function workspaces(page: Page, directory: string, enabled: boolean) {
|
|
||||||
await page.evaluate(
|
|
||||||
({ directory, enabled }: { directory: string; enabled: boolean }) => {
|
|
||||||
const key = "opencode.global.dat:layout"
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
const data = raw ? JSON.parse(raw) : {}
|
|
||||||
const sidebar = data.sidebar && typeof data.sidebar === "object" ? data.sidebar : {}
|
|
||||||
const current =
|
|
||||||
sidebar.workspaces && typeof sidebar.workspaces === "object" && !Array.isArray(sidebar.workspaces)
|
|
||||||
? sidebar.workspaces
|
|
||||||
: {}
|
|
||||||
const next = { ...current }
|
|
||||||
|
|
||||||
if (enabled) next[directory] = true
|
|
||||||
if (!enabled) delete next[directory]
|
|
||||||
|
|
||||||
localStorage.setItem(
|
|
||||||
key,
|
|
||||||
JSON.stringify({
|
|
||||||
...data,
|
|
||||||
sidebar: {
|
|
||||||
...sidebar,
|
|
||||||
workspaces: next,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{ directory, enabled },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("can switch between projects from sidebar", async ({ page, withProject }) => {
|
test("can switch between projects from sidebar", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
@@ -84,9 +54,7 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||||||
await withProject(
|
await withProject(
|
||||||
async ({ directory, slug, trackSession, trackDirectory }) => {
|
async ({ directory, slug, trackSession, trackDirectory }) => {
|
||||||
await defocus(page)
|
await defocus(page)
|
||||||
await workspaces(page, directory, true)
|
await setWorkspacesEnabled(page, slug, true)
|
||||||
await page.reload()
|
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||||
|
|
||||||
@@ -108,8 +76,7 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||||||
await expect(btn).toBeVisible()
|
await expect(btn).toBeVisible()
|
||||||
await btn.click({ force: true })
|
await btn.click({ force: true })
|
||||||
|
|
||||||
await waitSlug(page)
|
await waitSession(page, { directory: space })
|
||||||
await waitDir(page, space)
|
|
||||||
|
|
||||||
// Create a session by sending a prompt
|
// Create a session by sending a prompt
|
||||||
const prompt = page.locator(promptSelector)
|
const prompt = page.locator(promptSelector)
|
||||||
@@ -123,6 +90,7 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||||||
const created = sessionIDFromUrl(page.url())
|
const created = sessionIDFromUrl(page.url())
|
||||||
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
|
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
|
||||||
trackSession(created, space)
|
trackSession(created, space)
|
||||||
|
await waitSessionSaved(space, created)
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
|
||||||
|
|
||||||
@@ -130,15 +98,14 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||||||
|
|
||||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||||
await expect(otherButton).toBeVisible()
|
await expect(otherButton).toBeVisible()
|
||||||
await otherButton.click()
|
await otherButton.click({ force: true })
|
||||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
await waitSession(page, { directory: other })
|
||||||
|
|
||||||
const rootButton = page.locator(projectSwitchSelector(slug)).first()
|
const rootButton = page.locator(projectSwitchSelector(slug)).first()
|
||||||
await expect(rootButton).toBeVisible()
|
await expect(rootButton).toBeVisible()
|
||||||
await rootButton.click()
|
await rootButton.click({ force: true })
|
||||||
|
|
||||||
await waitDir(page, space)
|
await waitSession(page, { directory: space, sessionID: created })
|
||||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created)
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
|
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
|
||||||
},
|
},
|
||||||
{ extra: [other] },
|
{ extra: [other] },
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import type { Page } from "@playwright/test"
|
import type { Page } from "@playwright/test"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitDir, waitSlug } from "../actions"
|
import {
|
||||||
|
openSidebar,
|
||||||
|
resolveSlug,
|
||||||
|
sessionIDFromUrl,
|
||||||
|
setWorkspacesEnabled,
|
||||||
|
waitDir,
|
||||||
|
waitSession,
|
||||||
|
waitSessionSaved,
|
||||||
|
waitSlug,
|
||||||
|
} from "../actions"
|
||||||
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||||
import { createSdk } from "../utils"
|
import { createSdk } from "../utils"
|
||||||
|
|
||||||
@@ -14,20 +23,7 @@ function button(space: { slug: string; raw: string }) {
|
|||||||
|
|
||||||
async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) {
|
async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) {
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await expect
|
await expect(page.locator(item(space)).first()).toBeVisible({ timeout: 60_000 })
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const row = page.locator(item(space)).first()
|
|
||||||
try {
|
|
||||||
await row.hover({ timeout: 500 })
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createWorkspace(page: Page, root: string, seen: string[]) {
|
async function createWorkspace(page: Page, root: string, seen: string[]) {
|
||||||
@@ -49,7 +45,8 @@ async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: s
|
|||||||
await expect(next).toBeVisible()
|
await expect(next).toBeVisible()
|
||||||
await next.click({ force: true })
|
await next.click({ force: true })
|
||||||
|
|
||||||
return waitDir(page, space.directory)
|
await waitSession(page, { directory: space.directory })
|
||||||
|
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe("")
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createSessionFromWorkspace(
|
async function createSessionFromWorkspace(
|
||||||
@@ -57,39 +54,28 @@ async function createSessionFromWorkspace(
|
|||||||
space: { slug: string; raw: string; directory: string },
|
space: { slug: string; raw: string; directory: string },
|
||||||
text: string,
|
text: string,
|
||||||
) {
|
) {
|
||||||
const next = await openWorkspaceNewSession(page, space)
|
await openWorkspaceNewSession(page, space)
|
||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
const prompt = page.locator(promptSelector)
|
||||||
await expect(prompt).toBeVisible()
|
await expect(prompt).toBeVisible()
|
||||||
await expect(prompt).toBeEditable()
|
|
||||||
await prompt.click()
|
|
||||||
await expect(prompt).toBeFocused()
|
|
||||||
await prompt.fill(text)
|
await prompt.fill(text)
|
||||||
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
|
await page.keyboard.press("Enter")
|
||||||
await prompt.press("Enter")
|
|
||||||
|
|
||||||
await waitDir(page, next.directory)
|
|
||||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
|
||||||
|
|
||||||
|
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
|
||||||
const sessionID = sessionIDFromUrl(page.url())
|
const sessionID = sessionIDFromUrl(page.url())
|
||||||
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||||
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
|
|
||||||
return { sessionID, slug: next.slug }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sessionDirectory(directory: string, sessionID: string) {
|
await waitSessionSaved(space.directory, sessionID)
|
||||||
const info = await createSdk(directory)
|
await createSdk(space.directory)
|
||||||
.session.get({ sessionID })
|
.session.abort({ sessionID })
|
||||||
.then((x) => x.data)
|
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
if (!info) return ""
|
return sessionID
|
||||||
return info.directory
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
|
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => {
|
await withProject(async ({ slug: root, trackDirectory, trackSession }) => {
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await setWorkspacesEnabled(page, root, true)
|
await setWorkspacesEnabled(page, root, true)
|
||||||
|
|
||||||
@@ -101,17 +87,8 @@ test("new sessions from sidebar workspace actions stay in selected workspace", a
|
|||||||
trackDirectory(second.directory)
|
trackDirectory(second.directory)
|
||||||
await waitWorkspaceReady(page, second)
|
await waitWorkspaceReady(page, second)
|
||||||
|
|
||||||
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
|
trackSession(await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`), first.directory)
|
||||||
trackSession(firstSession.sessionID, first.directory)
|
trackSession(await createSessionFromWorkspace(page, second, `workspace two ${Date.now()}`), second.directory)
|
||||||
|
trackSession(await createSessionFromWorkspace(page, first, `workspace one again ${Date.now()}`), first.directory)
|
||||||
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
|
|
||||||
trackSession(secondSession.sessionID, second.directory)
|
|
||||||
|
|
||||||
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
|
|
||||||
trackSession(thirdSession.sessionID, first.directory)
|
|
||||||
|
|
||||||
await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
|
|
||||||
await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
|
|
||||||
await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import fs from "node:fs/promises"
|
import fs from "node:fs/promises"
|
||||||
import os from "node:os"
|
import os from "node:os"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
|
import { base64Decode } from "@opencode-ai/util/encode"
|
||||||
import type { Page } from "@playwright/test"
|
import type { Page } from "@playwright/test"
|
||||||
|
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import type { Locator, Page } from "@playwright/test"
|
import type { Locator, Page } from "@playwright/test"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions"
|
import {
|
||||||
|
openSidebar,
|
||||||
|
resolveSlug,
|
||||||
|
sessionIDFromUrl,
|
||||||
|
setWorkspacesEnabled,
|
||||||
|
waitSession,
|
||||||
|
waitSessionIdle,
|
||||||
|
waitSlug,
|
||||||
|
} from "../actions"
|
||||||
import {
|
import {
|
||||||
promptAgentSelector,
|
promptAgentSelector,
|
||||||
promptModelSelector,
|
promptModelSelector,
|
||||||
@@ -29,8 +37,6 @@ const text = async (locator: Locator) => ((await locator.textContent()) ?? "").t
|
|||||||
|
|
||||||
const modelKey = (state: Probe | null) => (state?.model ? `${state.model.providerID}:${state.model.modelID}` : null)
|
const modelKey = (state: Probe | null) => (state?.model ? `${state.model.providerID}:${state.model.modelID}` : null)
|
||||||
|
|
||||||
const dirKey = (state: Probe | null) => state?.dir ?? ""
|
|
||||||
|
|
||||||
async function probe(page: Page): Promise<Probe | null> {
|
async function probe(page: Page): Promise<Probe | null> {
|
||||||
return page.evaluate(() => {
|
return page.evaluate(() => {
|
||||||
const win = window as Window & {
|
const win = window as Window & {
|
||||||
@@ -44,21 +50,6 @@ async function probe(page: Page): Promise<Probe | null> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function currentDir(page: Page) {
|
|
||||||
let hit = ""
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const next = dirKey(await probe(page))
|
|
||||||
if (next) hit = next
|
|
||||||
return next
|
|
||||||
},
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.not.toBe("")
|
|
||||||
return hit
|
|
||||||
}
|
|
||||||
|
|
||||||
async function read(page: Page): Promise<Footer> {
|
async function read(page: Page): Promise<Footer> {
|
||||||
return {
|
return {
|
||||||
agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()),
|
agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()),
|
||||||
@@ -187,8 +178,7 @@ async function chooseOtherModel(page: Page): Promise<Footer> {
|
|||||||
|
|
||||||
async function goto(page: Page, directory: string, sessionID?: string) {
|
async function goto(page: Page, directory: string, sessionID?: string) {
|
||||||
await page.goto(sessionPath(directory, sessionID))
|
await page.goto(sessionPath(directory, sessionID))
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
await waitSession(page, { directory, sessionID })
|
||||||
await expect.poll(async () => dirKey(await probe(page)), { timeout: 30_000 }).toBe(directory)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit(page: Page, value: string) {
|
async function submit(page: Page, value: string) {
|
||||||
@@ -224,7 +214,7 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
|
|||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
|
||||||
const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
|
const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
|
||||||
await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`))
|
await waitSession(page, { directory: next.directory })
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,9 +246,7 @@ async function newWorkspaceSession(page: Page, slug: string) {
|
|||||||
await button.click({ force: true })
|
await button.click({ force: true })
|
||||||
|
|
||||||
const next = await resolveSlug(await waitSlug(page))
|
const next = await resolveSlug(await waitSlug(page))
|
||||||
await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`))
|
return waitSession(page, { directory: next.directory }).then((item) => item.directory)
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
return currentDir(page)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("session model and variant restore per session without leaking into new sessions", async ({
|
test("session model and variant restore per session without leaking into new sessions", async ({
|
||||||
@@ -277,7 +265,7 @@ test("session model and variant restore per session without leaking into new ses
|
|||||||
await waitUser(directory, first)
|
await waitUser(directory, first)
|
||||||
|
|
||||||
await page.reload()
|
await page.reload()
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
await waitSession(page, { directory, sessionID: first })
|
||||||
await waitFooter(page, firstState)
|
await waitFooter(page, firstState)
|
||||||
|
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Show, type Component } from "solid-js"
|
||||||
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
|
||||||
|
type Range = {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommentChipProps = {
|
||||||
|
variant?: "preview" | "full"
|
||||||
|
path: string
|
||||||
|
label: string
|
||||||
|
selection?: Range
|
||||||
|
comment?: string
|
||||||
|
class?: string
|
||||||
|
onOpen?: () => void
|
||||||
|
onRemove?: () => void
|
||||||
|
removeLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeClass =
|
||||||
|
"absolute top-0 right-0 size-6 opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto"
|
||||||
|
const removeIconClass =
|
||||||
|
"absolute top-1 right-1 size-4 rounded-[var(--radius-sm)] border border-border-weak-base flex items-center justify-center bg-[var(--surface-raised-stronger-non-alpha)] group-active/remove:bg-surface-base-active"
|
||||||
|
|
||||||
|
export const CommentChip: Component<CommentChipProps> = (props) => {
|
||||||
|
const variant = () => props.variant ?? "preview"
|
||||||
|
const range = () => {
|
||||||
|
const sel = props.selection
|
||||||
|
if (!sel) return
|
||||||
|
const start = Math.min(sel.start, sel.end)
|
||||||
|
const end = Math.max(sel.start, sel.end)
|
||||||
|
return { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad = () => (props.onRemove ? "pr-7" : "pr-2")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={`group relative flex flex-col rounded-[6px] cursor-default bg-background-stronger border border-border-weak-base ${
|
||||||
|
variant() === "full" ? `pl-2 py-1 ${pad()}` : `pl-2 py-1 h-12 ${pad()}`
|
||||||
|
} ${props.class ?? ""}`}
|
||||||
|
onClick={() => props.onOpen?.()}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-1.5 min-w-0">
|
||||||
|
<FileIcon node={{ path: props.path, type: "file" }} class="shrink-0 size-3.5" />
|
||||||
|
<div class="flex items-center text-11-regular min-w-0 font-medium">
|
||||||
|
<span class="text-text-strong whitespace-nowrap">{props.label}</span>
|
||||||
|
<Show when={range()}>
|
||||||
|
{(sel) => (
|
||||||
|
<span class="text-text-weak whitespace-nowrap shrink-0">
|
||||||
|
{sel().start === sel().end ? `:${sel().start}` : `:${sel().start}-${sel().end}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={(props.comment ?? "").trim().length > 0}>
|
||||||
|
<div
|
||||||
|
class={`text-base text-text-strong ml-5 ${
|
||||||
|
variant() === "full" ? "whitespace-pre-wrap break-words" : "truncate"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{props.comment}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.onRemove}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`${removeClass} group/remove`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
props.onRemove?.()
|
||||||
|
}}
|
||||||
|
aria-label={props.removeLabel}
|
||||||
|
>
|
||||||
|
<span class={removeIconClass}>
|
||||||
|
<Icon name="close-small" size="small" class="text-text-weak group-hover/remove:text-text-strong" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -52,7 +52,6 @@ import {
|
|||||||
import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit"
|
import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit"
|
||||||
import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
|
import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
|
||||||
import { PromptContextItems } from "./prompt-input/context-items"
|
import { PromptContextItems } from "./prompt-input/context-items"
|
||||||
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
|
||||||
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
||||||
import { promptPlaceholder } from "./prompt-input/placeholder"
|
import { promptPlaceholder } from "./prompt-input/placeholder"
|
||||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||||
@@ -1291,6 +1290,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
<PromptContextItems
|
<PromptContextItems
|
||||||
items={contextItems()}
|
items={contextItems()}
|
||||||
|
images={imageAttachments()}
|
||||||
active={(item) => {
|
active={(item) => {
|
||||||
const active = comments.active()
|
const active = comments.active()
|
||||||
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
|
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
|
||||||
@@ -1300,15 +1300,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
if (item.commentID) comments.remove(item.path, item.commentID)
|
if (item.commentID) comments.remove(item.path, item.commentID)
|
||||||
prompt.context.remove(item.key)
|
prompt.context.remove(item.key)
|
||||||
}}
|
}}
|
||||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
openImage={(attachment) =>
|
||||||
/>
|
|
||||||
<PromptImageAttachments
|
|
||||||
attachments={imageAttachments()}
|
|
||||||
onOpen={(attachment) =>
|
|
||||||
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
|
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
|
||||||
}
|
}
|
||||||
onRemove={removeAttachment}
|
removeImage={removeAttachment}
|
||||||
removeLabel={language.t("prompt.attachment.remove")}
|
imageRemoveLabel={language.t("prompt.attachment.remove")}
|
||||||
|
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="relative"
|
class="relative"
|
||||||
|
|||||||
@@ -1,30 +1,62 @@
|
|||||||
import { Component, For, Show } from "solid-js"
|
import { Component, For, Show, createMemo } from "solid-js"
|
||||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
|
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
|
||||||
import type { ContextItem } from "@/context/prompt"
|
import type { ContextItem, ImageAttachmentPart } from "@/context/prompt"
|
||||||
|
import { PromptImageAttachment } from "./image-attachments"
|
||||||
|
import { CommentChip } from "@/components/comment-chip"
|
||||||
|
|
||||||
type PromptContextItem = ContextItem & { key: string }
|
type PromptContextItem = ContextItem & { key: string }
|
||||||
|
|
||||||
type ContextItemsProps = {
|
type ContextItemsProps = {
|
||||||
items: PromptContextItem[]
|
items: PromptContextItem[]
|
||||||
|
images: ImageAttachmentPart[]
|
||||||
active: (item: PromptContextItem) => boolean
|
active: (item: PromptContextItem) => boolean
|
||||||
openComment: (item: PromptContextItem) => void
|
openComment: (item: PromptContextItem) => void
|
||||||
remove: (item: PromptContextItem) => void
|
remove: (item: PromptContextItem) => void
|
||||||
|
openImage: (attachment: ImageAttachmentPart) => void
|
||||||
|
removeImage: (id: string) => void
|
||||||
|
imageRemoveLabel: string
|
||||||
t: (key: string) => string
|
t: (key: string) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
||||||
|
const seen = new Map<string, number>()
|
||||||
|
let seq = 0
|
||||||
|
|
||||||
|
const rows = createMemo(() => {
|
||||||
|
const all = [
|
||||||
|
...props.items.map((item) => ({ type: "ctx" as const, key: `ctx:${item.key}`, item })),
|
||||||
|
...props.images.map((attachment) => ({ type: "img" as const, key: `img:${attachment.id}`, attachment })),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const row of all) {
|
||||||
|
if (seen.has(row.key)) continue
|
||||||
|
seen.set(row.key, seq)
|
||||||
|
seq += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return all.slice().sort((a, b) => (seen.get(a.key) ?? 0) - (seen.get(b.key) ?? 0))
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={props.items.length > 0}>
|
<Show when={rows().length > 0}>
|
||||||
<div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
|
<div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
|
||||||
<For each={props.items}>
|
<For each={rows()}>
|
||||||
{(item) => {
|
{(row) => {
|
||||||
const directory = getDirectory(item.path)
|
if (row.type === "img") {
|
||||||
const filename = getFilename(item.path)
|
return (
|
||||||
const label = getFilenameTruncated(item.path, 14)
|
<PromptImageAttachment
|
||||||
const selected = props.active(item)
|
attachment={row.attachment}
|
||||||
|
onOpen={props.openImage}
|
||||||
|
onRemove={props.removeImage}
|
||||||
|
removeLabel={props.imageRemoveLabel}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const directory = getDirectory(row.item.path)
|
||||||
|
const filename = getFilename(row.item.path)
|
||||||
|
const label = getFilenameTruncated(row.item.path, 14)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
@@ -38,46 +70,26 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
|||||||
}
|
}
|
||||||
placement="top"
|
placement="top"
|
||||||
openDelay={2000}
|
openDelay={2000}
|
||||||
|
class="shrink-0"
|
||||||
>
|
>
|
||||||
<div
|
<CommentChip
|
||||||
classList={{
|
variant="preview"
|
||||||
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 cursor-default transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
|
path={row.item.path}
|
||||||
"hover:bg-surface-interactive-weak": !!item.commentID && !selected,
|
label={label}
|
||||||
"bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover": selected,
|
selection={
|
||||||
"bg-background-stronger": !selected,
|
row.item.selection
|
||||||
}}
|
? {
|
||||||
onClick={() => props.openComment(item)}
|
start: row.item.selection.startLine,
|
||||||
>
|
end: row.item.selection.endLine,
|
||||||
<div class="flex items-center gap-1.5">
|
}
|
||||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
|
: undefined
|
||||||
<div class="flex items-center text-11-regular min-w-0 font-medium">
|
}
|
||||||
<span class="text-text-strong whitespace-nowrap">{label}</span>
|
comment={row.item.comment}
|
||||||
<Show when={item.selection}>
|
class="max-w-[200px]"
|
||||||
{(sel) => (
|
onOpen={() => props.openComment(row.item)}
|
||||||
<span class="text-text-weak whitespace-nowrap shrink-0">
|
onRemove={() => props.remove(row.item)}
|
||||||
{sel().startLine === sel().endLine
|
removeLabel={props.t("prompt.context.removeFile")}
|
||||||
? `:${sel().startLine}`
|
/>
|
||||||
: `:${sel().startLine}-${sel().endLine}`}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<IconButton
|
|
||||||
type="button"
|
|
||||||
icon="close-small"
|
|
||||||
variant="ghost"
|
|
||||||
class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
props.remove(item)
|
|
||||||
}}
|
|
||||||
aria-label={props.t("prompt.context.removeFile")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Show when={item.comment}>
|
|
||||||
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Component, For, Show } from "solid-js"
|
import { Component, Show } from "solid-js"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import type { ImageAttachmentPart } from "@/context/prompt"
|
import type { ImageAttachmentPart } from "@/context/prompt"
|
||||||
|
|
||||||
type PromptImageAttachmentsProps = {
|
type PromptImageAttachmentsProps = {
|
||||||
@@ -9,50 +11,68 @@ type PromptImageAttachmentsProps = {
|
|||||||
removeLabel: string
|
removeLabel: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"
|
type PromptImageAttachmentProps = {
|
||||||
const imageClass =
|
attachment: ImageAttachmentPart
|
||||||
"size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
|
onOpen: (attachment: ImageAttachmentPart) => void
|
||||||
|
onRemove: (id: string) => void
|
||||||
|
removeLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackClass =
|
||||||
|
"size-12 rounded-[6px] bg-background-stronger flex items-center justify-center border border-border-weak-base cursor-default"
|
||||||
|
const imageClass = "size-12 rounded-[6px] object-cover border border-border-weak-base"
|
||||||
const removeClass =
|
const removeClass =
|
||||||
"absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
|
"absolute top-0 right-0 size-6 opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto"
|
||||||
const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md"
|
const removeIconClass =
|
||||||
|
"absolute top-1 right-1 size-4 rounded-[var(--radius-sm)] border border-border-weak-base flex items-center justify-center bg-[var(--surface-raised-stronger-non-alpha)] group-active/remove:bg-surface-base-active"
|
||||||
|
|
||||||
export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
|
export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<Show when={props.attachments.length > 0}>
|
<Show when={props.attachments.length > 0}>
|
||||||
<div class="flex flex-wrap gap-2 px-3 pt-3">
|
<>
|
||||||
<For each={props.attachments}>
|
{props.attachments.map((attachment) => (
|
||||||
{(attachment) => (
|
<PromptImageAttachment
|
||||||
<div class="relative group">
|
attachment={attachment}
|
||||||
<Show
|
onOpen={props.onOpen}
|
||||||
when={attachment.mime.startsWith("image/")}
|
onRemove={props.onRemove}
|
||||||
fallback={
|
removeLabel={props.removeLabel}
|
||||||
<div class={fallbackClass}>
|
/>
|
||||||
<Icon name="folder" class="size-6 text-text-weak" />
|
))}
|
||||||
</div>
|
</>
|
||||||
}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={attachment.dataUrl}
|
|
||||||
alt={attachment.filename}
|
|
||||||
class={imageClass}
|
|
||||||
onClick={() => props.onOpen(attachment)}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => props.onRemove(attachment.id)}
|
|
||||||
class={removeClass}
|
|
||||||
aria-label={props.removeLabel}
|
|
||||||
>
|
|
||||||
<Icon name="close" class="size-3 text-text-weak" />
|
|
||||||
</button>
|
|
||||||
<div class={nameClass}>
|
|
||||||
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const PromptImageAttachment: Component<PromptImageAttachmentProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<Tooltip value={props.attachment.filename} placement="top" gutter={6} class="shrink-0">
|
||||||
|
<div class="relative group">
|
||||||
|
<Show
|
||||||
|
when={props.attachment.mime.startsWith("image/")}
|
||||||
|
fallback={
|
||||||
|
<div class={fallbackClass}>
|
||||||
|
<FileIcon node={{ path: props.attachment.filename, type: "file" }} class="size-5" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={props.attachment.dataUrl}
|
||||||
|
alt={props.attachment.filename}
|
||||||
|
class={imageClass}
|
||||||
|
onClick={() => props.onOpen(props.attachment)}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`${removeClass} group/remove`}
|
||||||
|
onClick={() => props.onRemove(props.attachment.id)}
|
||||||
|
aria-label={props.removeLabel}
|
||||||
|
>
|
||||||
|
<span class={removeIconClass}>
|
||||||
|
<Icon name="close-small" size="small" class="text-text-weak group-hover/remove:text-text-strong" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -378,6 +378,7 @@ function createGlobalSync() {
|
|||||||
return globalStore.error
|
return globalStore.error
|
||||||
},
|
},
|
||||||
child: children.child,
|
child: children.child,
|
||||||
|
peek: children.peek,
|
||||||
bootstrap,
|
bootstrap,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
project: projectApi,
|
project: projectApi,
|
||||||
|
|||||||
@@ -226,6 +226,15 @@ export function createChildStoreManager(input: {
|
|||||||
return childStore
|
return childStore
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function peek(directory: string, options: ChildOptions = {}) {
|
||||||
|
const childStore = ensureChild(directory)
|
||||||
|
const shouldBootstrap = options.bootstrap ?? true
|
||||||
|
if (shouldBootstrap && childStore[0].status === "loading") {
|
||||||
|
input.onBootstrap(directory)
|
||||||
|
}
|
||||||
|
return childStore
|
||||||
|
}
|
||||||
|
|
||||||
function projectMeta(directory: string, patch: ProjectMeta) {
|
function projectMeta(directory: string, patch: ProjectMeta) {
|
||||||
const [store, setStore] = ensureChild(directory)
|
const [store, setStore] = ensureChild(directory)
|
||||||
const cached = metaCache.get(directory)
|
const cached = metaCache.get(directory)
|
||||||
@@ -256,6 +265,7 @@ export function createChildStoreManager(input: {
|
|||||||
children,
|
children,
|
||||||
ensureChild,
|
ensureChild,
|
||||||
child,
|
child,
|
||||||
|
peek,
|
||||||
projectMeta,
|
projectMeta,
|
||||||
projectIcon,
|
projectIcon,
|
||||||
mark,
|
mark,
|
||||||
|
|||||||
@@ -477,7 +477,7 @@ export const dict = {
|
|||||||
"session.revertDock.summary.other": "{{count}} رسائل تم التراجع عنها",
|
"session.revertDock.summary.other": "{{count}} رسائل تم التراجع عنها",
|
||||||
"session.revertDock.collapse": "طي الرسائل التي تم التراجع عنها",
|
"session.revertDock.collapse": "طي الرسائل التي تم التراجع عنها",
|
||||||
"session.revertDock.expand": "توسيع الرسائل التي تم التراجع عنها",
|
"session.revertDock.expand": "توسيع الرسائل التي تم التراجع عنها",
|
||||||
"session.revertDock.restore": "استعادة الرسالة",
|
"session.revertDock.restore": "استعادة",
|
||||||
"session.new.title": "ابنِ أي شيء",
|
"session.new.title": "ابنِ أي شيء",
|
||||||
"session.new.worktree.main": "الفرع الرئيسي",
|
"session.new.worktree.main": "الفرع الرئيسي",
|
||||||
"session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})",
|
"session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})",
|
||||||
|
|||||||
@@ -481,7 +481,7 @@ export const dict = {
|
|||||||
"session.revertDock.summary.other": "{{count}} mensagens revertidas",
|
"session.revertDock.summary.other": "{{count}} mensagens revertidas",
|
||||||
"session.revertDock.collapse": "Recolher mensagens revertidas",
|
"session.revertDock.collapse": "Recolher mensagens revertidas",
|
||||||
"session.revertDock.expand": "Expandir mensagens revertidas",
|
"session.revertDock.expand": "Expandir mensagens revertidas",
|
||||||
"session.revertDock.restore": "Restaurar mensagem",
|
"session.revertDock.restore": "Restaurar",
|
||||||
"session.new.title": "Crie qualquer coisa",
|
"session.new.title": "Crie qualquer coisa",
|
||||||
"session.new.worktree.main": "Branch principal",
|
"session.new.worktree.main": "Branch principal",
|
||||||
"session.new.worktree.mainWithBranch": "Branch principal ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Branch principal ({{branch}})",
|
||||||
|
|||||||
@@ -536,7 +536,7 @@ export const dict = {
|
|||||||
"session.revertDock.summary.other": "{{count}} vraćenih poruka",
|
"session.revertDock.summary.other": "{{count}} vraćenih poruka",
|
||||||
"session.revertDock.collapse": "Sažmi vraćene poruke",
|
"session.revertDock.collapse": "Sažmi vraćene poruke",
|
||||||
"session.revertDock.expand": "Proširi vraćene poruke",
|
"session.revertDock.expand": "Proširi vraćene poruke",
|
||||||
"session.revertDock.restore": "Vrati poruku",
|
"session.revertDock.restore": "Vrati",
|
||||||
|
|
||||||
"session.new.title": "Napravi bilo šta",
|
"session.new.title": "Napravi bilo šta",
|
||||||
"session.new.worktree.main": "Glavna grana",
|
"session.new.worktree.main": "Glavna grana",
|
||||||
|
|||||||
@@ -531,7 +531,7 @@ export const dict = {
|
|||||||
"session.revertDock.summary.other": "{{count}} tilbagerullede beskeder",
|
"session.revertDock.summary.other": "{{count}} tilbagerullede beskeder",
|
||||||
"session.revertDock.collapse": "Skjul tilbagerullede beskeder",
|
"session.revertDock.collapse": "Skjul tilbagerullede beskeder",
|
||||||
"session.revertDock.expand": "Udvid tilbagerullede beskeder",
|
"session.revertDock.expand": "Udvid tilbagerullede beskeder",
|
||||||
"session.revertDock.restore": "Gendan besked",
|
"session.revertDock.restore": "Gendan",
|
||||||
|
|
||||||
"session.new.title": "Byg hvad som helst",
|
"session.new.title": "Byg hvad som helst",
|
||||||
"session.new.worktree.main": "Hovedgren",
|
"session.new.worktree.main": "Hovedgren",
|
||||||
|
|||||||
@@ -489,7 +489,7 @@ export const dict = {
|
|||||||
"session.revertDock.summary.other": "{{count}} zurückgesetzte Nachrichten",
|
"session.revertDock.summary.other": "{{count}} zurückgesetzte Nachrichten",
|
||||||
"session.revertDock.collapse": "Zurückgesetzte Nachrichten einklappen",
|
"session.revertDock.collapse": "Zurückgesetzte Nachrichten einklappen",
|
||||||
"session.revertDock.expand": "Zurückgesetzte Nachrichten ausklappen",
|
"session.revertDock.expand": "Zurückgesetzte Nachrichten ausklappen",
|
||||||
"session.revertDock.restore": "Nachricht wiederherstellen",
|
"session.revertDock.restore": "Wiederherstellen",
|
||||||
"session.new.title": "Baue, was du willst",
|
"session.new.title": "Baue, was du willst",
|
||||||
"session.new.worktree.main": "Haupt-Branch",
|
"session.new.worktree.main": "Haupt-Branch",
|
||||||
"session.new.worktree.mainWithBranch": "Haupt-Branch ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Haupt-Branch ({{branch}})",
|
||||||
|
|||||||
@@ -561,7 +561,7 @@ export const dict = {
|
|||||||
"session.revertDock.summary.other": "{{count}} rolled back messages",
|
"session.revertDock.summary.other": "{{count}} rolled back messages",
|
||||||
"session.revertDock.collapse": "Collapse rolled back messages",
|
"session.revertDock.collapse": "Collapse rolled back messages",
|
||||||
"session.revertDock.expand": "Expand rolled back messages",
|
"session.revertDock.expand": "Expand rolled back messages",
|
||||||
"session.revertDock.restore": "Restore message",
|
"session.revertDock.restore": "Restore",
|
||||||
|
|
||||||
"session.new.title": "Build anything",
|
"session.new.title": "Build anything",
|
||||||
"session.new.worktree.main": "Main branch",
|
"session.new.worktree.main": "Main branch",
|
||||||
|
|||||||
@@ -537,7 +537,7 @@ export const dict = {
|
|||||||
"session.revertDock.summary.other": "{{count}} mensajes revertidos",
|
"session.revertDock.summary.other": "{{count}} mensajes revertidos",
|
||||||
"session.revertDock.collapse": "Contraer mensajes revertidos",
|
"session.revertDock.collapse": "Contraer mensajes revertidos",
|
||||||
"session.revertDock.expand": "Expandir mensajes revertidos",
|
"session.revertDock.expand": "Expandir mensajes revertidos",
|
||||||
"session.revertDock.restore": "Restaurar mensaje",
|
"session.revertDock.restore": "Restaurar",
|
||||||
|
|
||||||
"session.new.title": "Construye lo que quieras",
|
"session.new.title": "Construye lo que quieras",
|
||||||
"session.new.worktree.main": "Rama principal",
|
"session.new.worktree.main": "Rama principal",
|
||||||
|
|||||||
@@ -486,7 +486,7 @@ export const dict = {
|
|||||||
"session.revertDock.summary.other": "{{count}} messages annulés",
|
"session.revertDock.summary.other": "{{count}} messages annulés",
|
||||||
"session.revertDock.collapse": "Réduire les messages annulés",
|
"session.revertDock.collapse": "Réduire les messages annulés",
|
||||||
"session.revertDock.expand": "Développer les messages annulés",
|
"session.revertDock.expand": "Développer les messages annulés",
|
||||||
"session.revertDock.restore": "Restaurer le message",
|
"session.revertDock.restore": "Restaurer",
|
||||||
"session.new.title": "Créez ce que vous voulez",
|
"session.new.title": "Créez ce que vous voulez",
|
||||||
"session.new.worktree.main": "Branche principale",
|
"session.new.worktree.main": "Branche principale",
|
||||||
"session.new.worktree.mainWithBranch": "Branche principale ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Branche principale ({{branch}})",
|
||||||
|
|||||||
@@ -478,7 +478,7 @@ export const dict = {
|
|||||||
"session.revertDock.summary.other": "{{count}} 件のロールバックされたメッセージ",
|
"session.revertDock.summary.other": "{{count}} 件のロールバックされたメッセージ",
|
||||||
"session.revertDock.collapse": "ロールバックされたメッセージを折りたたむ",
|
"session.revertDock.collapse": "ロールバックされたメッセージを折りたたむ",
|
||||||
"session.revertDock.expand": "ロールバックされたメッセージを展開",
|
"session.revertDock.expand": "ロールバックされたメッセージを展開",
|
||||||
"session.revertDock.restore": "メッセージを復元",
|
"session.revertDock.restore": "復元",
|
||||||
"session.new.title": "何でも作る",
|
"session.new.title": "何でも作る",
|
||||||
"session.new.worktree.main": "メインブランチ",
|
"session.new.worktree.main": "メインブランチ",
|
||||||
"session.new.worktree.mainWithBranch": "メインブランチ ({{branch}})",
|
"session.new.worktree.mainWithBranch": "メインブランチ ({{branch}})",
|
||||||
|
|||||||
@@ -480,7 +480,7 @@ export const dict = {
|
|||||||
"session.revertDock.summary.other": "{{count}}개의 롤백된 메시지",
|
"session.revertDock.summary.other": "{{count}}개의 롤백된 메시지",
|
||||||
"session.revertDock.collapse": "롤백된 메시지 접기",
|
"session.revertDock.collapse": "롤백된 메시지 접기",
|
||||||
"session.revertDock.expand": "롤백된 메시지 펼치기",
|
"session.revertDock.expand": "롤백된 메시지 펼치기",
|
||||||
"session.revertDock.restore": "메시지 복원",
|
"session.revertDock.restore": "복원",
|
||||||
"session.new.title": "무엇이든 만들기",
|
"session.new.title": "무엇이든 만들기",
|
||||||
"session.new.worktree.main": "메인 브랜치",
|
"session.new.worktree.main": "메인 브랜치",
|
||||||
"session.new.worktree.mainWithBranch": "메인 브랜치 ({{branch}})",
|
"session.new.worktree.mainWithBranch": "메인 브랜치 ({{branch}})",
|
||||||
|
|||||||
@@ -537,7 +537,7 @@ export const dict = {
|
|||||||
"session.revertDock.summary.other": "{{count}} tilbakestilte meldinger",
|
"session.revertDock.summary.other": "{{count}} tilbakestilte meldinger",
|
||||||
"session.revertDock.collapse": "Skjul tilbakestilte meldinger",
|
"session.revertDock.collapse": "Skjul tilbakestilte meldinger",
|
||||||
"session.revertDock.expand": "Utvid tilbakestilte meldinger",
|
"session.revertDock.expand": "Utvid tilbakestilte meldinger",
|
||||||
"session.revertDock.restore": "Gjenopprett melding",
|
"session.revertDock.restore": "Gjenopprett",
|
||||||
|
|
||||||
"session.new.title": "Bygg hva som helst",
|
"session.new.title": "Bygg hva som helst",
|
||||||
"session.new.worktree.main": "Hovedgren",
|
"session.new.worktree.main": "Hovedgren",
|
||||||
|
|||||||
@@ -479,7 +479,7 @@ export const dict = {
|
|||||||
"session.revertDock.summary.other": "{{count}} cofnięte wiadomości",
|
"session.revertDock.summary.other": "{{count}} cofnięte wiadomości",
|
||||||
"session.revertDock.collapse": "Zwiń cofnięte wiadomości",
|
"session.revertDock.collapse": "Zwiń cofnięte wiadomości",
|
||||||
"session.revertDock.expand": "Rozwiń cofnięte wiadomości",
|
"session.revertDock.expand": "Rozwiń cofnięte wiadomości",
|
||||||
"session.revertDock.restore": "Przywróć wiadomość",
|
"session.revertDock.restore": "Przywróć",
|
||||||
"session.new.title": "Zbuduj cokolwiek",
|
"session.new.title": "Zbuduj cokolwiek",
|
||||||
"session.new.worktree.main": "Główna gałąź",
|
"session.new.worktree.main": "Główna gałąź",
|
||||||
"session.new.worktree.mainWithBranch": "Główna gałąź ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Główna gałąź ({{branch}})",
|
||||||
|
|||||||
@@ -534,7 +534,7 @@ export const dict = {
|
|||||||
"session.revertDock.summary.other": "{{count}} сообщений возвращено",
|
"session.revertDock.summary.other": "{{count}} сообщений возвращено",
|
||||||
"session.revertDock.collapse": "Свернуть возвращённые сообщения",
|
"session.revertDock.collapse": "Свернуть возвращённые сообщения",
|
||||||
"session.revertDock.expand": "Развернуть возвращённые сообщения",
|
"session.revertDock.expand": "Развернуть возвращённые сообщения",
|
||||||
"session.revertDock.restore": "Восстановить сообщение",
|
"session.revertDock.restore": "Восстановить",
|
||||||
|
|
||||||
"session.new.title": "Создавайте что угодно",
|
"session.new.title": "Создавайте что угодно",
|
||||||
"session.new.worktree.main": "Основная ветка",
|
"session.new.worktree.main": "Основная ветка",
|
||||||
|
|||||||
@@ -531,7 +531,7 @@ export const dict = {
|
|||||||
"session.revertDock.summary.other": "{{count}} ข้อความที่ถูกย้อนกลับ",
|
"session.revertDock.summary.other": "{{count}} ข้อความที่ถูกย้อนกลับ",
|
||||||
"session.revertDock.collapse": "ย่อข้อความที่ถูกย้อนกลับ",
|
"session.revertDock.collapse": "ย่อข้อความที่ถูกย้อนกลับ",
|
||||||
"session.revertDock.expand": "ขยายข้อความที่ถูกย้อนกลับ",
|
"session.revertDock.expand": "ขยายข้อความที่ถูกย้อนกลับ",
|
||||||
"session.revertDock.restore": "กู้คืนข้อความ",
|
"session.revertDock.restore": "กู้คืน",
|
||||||
|
|
||||||
"session.new.title": "สร้างอะไรก็ได้",
|
"session.new.title": "สร้างอะไรก็ได้",
|
||||||
"session.new.worktree.main": "สาขาหลัก",
|
"session.new.worktree.main": "สาขาหลัก",
|
||||||
|
|||||||
@@ -541,7 +541,7 @@ export const dict = {
|
|||||||
"session.revertDock.summary.other": "{{count}} geri alınan mesaj",
|
"session.revertDock.summary.other": "{{count}} geri alınan mesaj",
|
||||||
"session.revertDock.collapse": "Geri alınan mesajları daralt",
|
"session.revertDock.collapse": "Geri alınan mesajları daralt",
|
||||||
"session.revertDock.expand": "Geri alınan mesajları genişlet",
|
"session.revertDock.expand": "Geri alınan mesajları genişlet",
|
||||||
"session.revertDock.restore": "Mesajı geri yükle",
|
"session.revertDock.restore": "Geri yükle",
|
||||||
|
|
||||||
"session.new.title": "İstediğini yap",
|
"session.new.title": "İstediğini yap",
|
||||||
"session.new.worktree.main": "Ana dal",
|
"session.new.worktree.main": "Ana dal",
|
||||||
|
|||||||
@@ -531,7 +531,7 @@ export const dict = {
|
|||||||
"session.revertDock.summary.other": "{{count}} 条已回滚消息",
|
"session.revertDock.summary.other": "{{count}} 条已回滚消息",
|
||||||
"session.revertDock.collapse": "折叠已回滚消息",
|
"session.revertDock.collapse": "折叠已回滚消息",
|
||||||
"session.revertDock.expand": "展开已回滚消息",
|
"session.revertDock.expand": "展开已回滚消息",
|
||||||
"session.revertDock.restore": "恢复消息",
|
"session.revertDock.restore": "恢复",
|
||||||
"session.new.title": "构建任何东西",
|
"session.new.title": "构建任何东西",
|
||||||
"session.new.worktree.main": "主分支",
|
"session.new.worktree.main": "主分支",
|
||||||
"session.new.worktree.mainWithBranch": "主分支({{branch}})",
|
"session.new.worktree.mainWithBranch": "主分支({{branch}})",
|
||||||
|
|||||||
@@ -527,7 +527,7 @@ export const dict = {
|
|||||||
"session.revertDock.summary.other": "{{count}} 則已回復訊息",
|
"session.revertDock.summary.other": "{{count}} 則已回復訊息",
|
||||||
"session.revertDock.collapse": "收合已回復訊息",
|
"session.revertDock.collapse": "收合已回復訊息",
|
||||||
"session.revertDock.expand": "展開已回復訊息",
|
"session.revertDock.expand": "展開已回復訊息",
|
||||||
"session.revertDock.restore": "還原訊息",
|
"session.revertDock.restore": "還原",
|
||||||
|
|
||||||
"session.new.title": "建構任何東西",
|
"session.new.title": "建構任何東西",
|
||||||
"session.new.worktree.main": "主分支",
|
"session.new.worktree.main": "主分支",
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { Logo } from "@opencode-ai/ui/logo"
|
import { Logo } from "@opencode-ai/ui/logo"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Component, Show } from "solid-js"
|
import { Component, Show, onMount } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import type { E2EWindow } from "@/testing/terminal"
|
||||||
|
|
||||||
export type InitError = {
|
export type InitError = {
|
||||||
name: string
|
name: string
|
||||||
@@ -226,6 +227,13 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
|||||||
actionError: undefined as string | undefined,
|
actionError: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const win = window as E2EWindow
|
||||||
|
if (!win.__opencode_e2e) return
|
||||||
|
const detail = formatError(props.error, language.t)
|
||||||
|
console.error(`[e2e:error-boundary] ${window.location.pathname}\n${detail}`)
|
||||||
|
})
|
||||||
|
|
||||||
async function checkForUpdates() {
|
async function checkForUpdates() {
|
||||||
if (!platform.checkUpdate) return
|
if (!platform.checkUpdate) return
|
||||||
setStore("checking", true)
|
setStore("checking", true)
|
||||||
|
|||||||
@@ -129,6 +129,16 @@ export default function Layout(props: ParentProps) {
|
|||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const initialDirectory = decode64(params.dir)
|
const initialDirectory = decode64(params.dir)
|
||||||
|
const route = createMemo(() => {
|
||||||
|
const slug = params.dir
|
||||||
|
if (!slug) return { slug, dir: "" }
|
||||||
|
const dir = decode64(slug)
|
||||||
|
if (!dir) return { slug, dir: "" }
|
||||||
|
return {
|
||||||
|
slug,
|
||||||
|
dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir,
|
||||||
|
}
|
||||||
|
})
|
||||||
const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
|
const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
|
||||||
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
|
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
|
||||||
const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
|
const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
|
||||||
@@ -137,7 +147,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
dark: "theme.scheme.dark",
|
dark: "theme.scheme.dark",
|
||||||
}
|
}
|
||||||
const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
|
const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
|
||||||
const currentDir = createMemo(() => decode64(params.dir) ?? "")
|
const currentDir = createMemo(() => route().dir)
|
||||||
|
|
||||||
const [state, setState] = createStore({
|
const [state, setState] = createStore({
|
||||||
autoselect: !initialDirectory,
|
autoselect: !initialDirectory,
|
||||||
@@ -484,8 +494,8 @@ export default function Layout(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentSession = params.id
|
const currentSession = params.id
|
||||||
if (directory === currentDir() && props.sessionID === currentSession) return
|
if (workspaceKey(directory) === workspaceKey(currentDir()) && props.sessionID === currentSession) return
|
||||||
if (directory === currentDir() && session?.parentID === currentSession) return
|
if (workspaceKey(directory) === workspaceKey(currentDir()) && session?.parentID === currentSession) return
|
||||||
|
|
||||||
dismissSessionAlert(sessionKey)
|
dismissSessionAlert(sessionKey)
|
||||||
|
|
||||||
@@ -620,7 +630,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
const activeDir = currentDir()
|
const activeDir = currentDir()
|
||||||
return workspaceIds(project).filter((directory) => {
|
return workspaceIds(project).filter((directory) => {
|
||||||
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
|
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
|
||||||
const active = directory === activeDir
|
const active = workspaceKey(directory) === workspaceKey(activeDir)
|
||||||
return expanded || active
|
return expanded || active
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -687,7 +697,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
seen: lru,
|
seen: lru,
|
||||||
keep: sessionID,
|
keep: sessionID,
|
||||||
limit: PREFETCH_MAX_SESSIONS_PER_DIR,
|
limit: PREFETCH_MAX_SESSIONS_PER_DIR,
|
||||||
preserve: directory === params.dir && params.id ? [params.id] : undefined,
|
preserve: params.id && workspaceKey(directory) === workspaceKey(currentDir()) ? [params.id] : undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -700,7 +710,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
params.dir
|
route()
|
||||||
globalSDK.url
|
globalSDK.url
|
||||||
|
|
||||||
prefetchToken.value += 1
|
prefetchToken.value += 1
|
||||||
@@ -1692,13 +1702,10 @@ export default function Layout(props: ParentProps) {
|
|||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => {
|
() => {
|
||||||
const dir = params.dir
|
return [pageReady(), route().slug, params.id, currentProject()?.worktree, currentDir()] as const
|
||||||
const directory = dir ? decode64(dir) : undefined
|
|
||||||
const resolved = directory ? globalSync.child(directory, { bootstrap: false })[0].path.directory : ""
|
|
||||||
return [pageReady(), dir, params.id, currentProject()?.worktree, directory, resolved] as const
|
|
||||||
},
|
},
|
||||||
([ready, dir, id, root, directory, resolved]) => {
|
([ready, slug, id, root, dir]) => {
|
||||||
if (!ready || !dir || !directory) {
|
if (!ready || !slug || !dir) {
|
||||||
activeRoute.session = ""
|
activeRoute.session = ""
|
||||||
activeRoute.sessionProject = ""
|
activeRoute.sessionProject = ""
|
||||||
activeRoute.directory = ""
|
activeRoute.directory = ""
|
||||||
@@ -1712,29 +1719,28 @@ export default function Layout(props: ParentProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = resolved || directory
|
const session = `${slug}/${id}`
|
||||||
const session = `${dir}/${id}`
|
|
||||||
|
|
||||||
if (!root) {
|
if (!root) {
|
||||||
activeRoute.session = session
|
activeRoute.session = session
|
||||||
activeRoute.directory = next
|
activeRoute.directory = dir
|
||||||
activeRoute.sessionProject = ""
|
activeRoute.sessionProject = ""
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (server.projects.last() !== root) server.projects.touch(root)
|
if (server.projects.last() !== root) server.projects.touch(root)
|
||||||
|
|
||||||
const changed = session !== activeRoute.session || next !== activeRoute.directory
|
const changed = session !== activeRoute.session || dir !== activeRoute.directory
|
||||||
if (changed) {
|
if (changed) {
|
||||||
activeRoute.session = session
|
activeRoute.session = session
|
||||||
activeRoute.directory = next
|
activeRoute.directory = dir
|
||||||
activeRoute.sessionProject = syncSessionRoute(next, id, root)
|
activeRoute.sessionProject = syncSessionRoute(dir, id, root)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (root === activeRoute.sessionProject) return
|
if (root === activeRoute.sessionProject) return
|
||||||
activeRoute.directory = next
|
activeRoute.directory = dir
|
||||||
activeRoute.sessionProject = rememberSessionRoute(next, id, root)
|
activeRoute.sessionProject = rememberSessionRoute(dir, id, root)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -1927,6 +1933,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
|
|
||||||
const projectSidebarCtx: ProjectSidebarContext = {
|
const projectSidebarCtx: ProjectSidebarContext = {
|
||||||
currentDir,
|
currentDir,
|
||||||
|
currentProject,
|
||||||
sidebarOpened: () => layout.sidebar.opened(),
|
sidebarOpened: () => layout.sidebar.opened(),
|
||||||
sidebarHovering,
|
sidebarHovering,
|
||||||
hoverProject: () => state.hoverProject,
|
hoverProject: () => state.hoverProject,
|
||||||
|
|||||||
@@ -40,10 +40,10 @@ export const latestRootSession = (stores: SessionStore[], now: number) =>
|
|||||||
stores.flatMap(roots).sort(sortSessions(now))[0]
|
stores.flatMap(roots).sort(sortSessions(now))[0]
|
||||||
|
|
||||||
export function hasProjectPermissions<T>(
|
export function hasProjectPermissions<T>(
|
||||||
request: Record<string, T[] | undefined>,
|
request: Record<string, T[] | undefined> | undefined,
|
||||||
include: (item: T) => boolean = () => true,
|
include: (item: T) => boolean = () => true,
|
||||||
) {
|
) {
|
||||||
return Object.values(request).some((list) => list?.some(include))
|
return Object.values(request ?? {}).some((list) => list?.some(include))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const childMapByParent = (sessions: Session[] | undefined) => {
|
export const childMapByParent = (sessions: Session[] | undefined) => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
|
|||||||
|
|
||||||
export type ProjectSidebarContext = {
|
export type ProjectSidebarContext = {
|
||||||
currentDir: Accessor<string>
|
currentDir: Accessor<string>
|
||||||
|
currentProject: Accessor<LocalProject | undefined>
|
||||||
sidebarOpened: Accessor<boolean>
|
sidebarOpened: Accessor<boolean>
|
||||||
sidebarHovering: Accessor<boolean>
|
sidebarHovering: Accessor<boolean>
|
||||||
hoverProject: Accessor<string | undefined>
|
hoverProject: Accessor<string | undefined>
|
||||||
@@ -278,11 +279,7 @@ export const SortableProject = (props: {
|
|||||||
const globalSync = useGlobalSync()
|
const globalSync = useGlobalSync()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const sortable = createSortable(props.project.worktree)
|
const sortable = createSortable(props.project.worktree)
|
||||||
const selected = createMemo(
|
const selected = createMemo(() => props.ctx.currentProject()?.worktree === props.project.worktree)
|
||||||
() =>
|
|
||||||
props.project.worktree === props.ctx.currentDir() ||
|
|
||||||
props.project.sandboxes?.includes(props.ctx.currentDir()) === true,
|
|
||||||
)
|
|
||||||
const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
|
const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
|
||||||
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
|
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
|
||||||
const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
|
const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { type LocalProject } from "@/context/layout"
|
|||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
|
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
|
||||||
import { childMapByParent, sortedRootSessions } from "./helpers"
|
import { childMapByParent, sortedRootSessions, workspaceKey } from "./helpers"
|
||||||
|
|
||||||
type InlineEditorComponent = (props: {
|
type InlineEditorComponent = (props: {
|
||||||
id: string
|
id: string
|
||||||
@@ -323,7 +323,7 @@ export const SortableWorkspace = (props: {
|
|||||||
const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
|
const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
|
||||||
const children = createMemo(() => childMapByParent(workspaceStore.session))
|
const children = createMemo(() => childMapByParent(workspaceStore.session))
|
||||||
const local = createMemo(() => props.directory === props.project.worktree)
|
const local = createMemo(() => props.directory === props.project.worktree)
|
||||||
const active = createMemo(() => props.ctx.currentDir() === props.directory)
|
const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory))
|
||||||
const workspaceValue = createMemo(() => {
|
const workspaceValue = createMemo(() => {
|
||||||
const branch = workspaceStore.vcs?.branch
|
const branch = workspaceStore.vcs?.branch
|
||||||
const name = branch ?? getFilename(props.directory)
|
const name = branch ?? getFilename(props.directory)
|
||||||
|
|||||||
@@ -1324,9 +1324,22 @@ export default function Page() {
|
|||||||
attachmentName: language.t("common.attachment"),
|
attachmentName: language.t("common.attachment"),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const tag = (mime: string | undefined) => {
|
||||||
|
if (mime === "application/pdf") return "pdf"
|
||||||
|
if (mime?.startsWith("image/")) return "image"
|
||||||
|
return "file"
|
||||||
|
}
|
||||||
|
|
||||||
|
const chip = (part: { filename: string; mime: string }) => `[${tag(part.mime)}:${part.filename}]`
|
||||||
|
|
||||||
const line = (id: string) => {
|
const line = (id: string) => {
|
||||||
const text = draft(id)
|
const text = draft(id)
|
||||||
.map((part) => (part.type === "image" ? `[image:${part.filename}]` : part.content))
|
.map((part) => {
|
||||||
|
if (part.type === "image") return chip(part)
|
||||||
|
if (part.type === "file") return `[file:${part.path}]`
|
||||||
|
if (part.type === "agent") return `@${part.name}`
|
||||||
|
return part.content
|
||||||
|
})
|
||||||
.join("")
|
.join("")
|
||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
.trim()
|
.trim()
|
||||||
@@ -1394,7 +1407,7 @@ export default function Page() {
|
|||||||
const followupText = (item: FollowupDraft) => {
|
const followupText = (item: FollowupDraft) => {
|
||||||
const text = item.prompt
|
const text = item.prompt
|
||||||
.map((part) => {
|
.map((part) => {
|
||||||
if (part.type === "image") return `[image:${part.filename}]`
|
if (part.type === "image") return chip(part)
|
||||||
if (part.type === "file") return `[file:${part.path}]`
|
if (part.type === "file") return `[file:${part.path}]`
|
||||||
if (part.type === "agent") return `@${part.name}`
|
if (part.type === "agent") return `@${part.name}`
|
||||||
return part.content
|
return part.content
|
||||||
|
|||||||
@@ -46,6 +46,14 @@ export function SessionComposerRegion(props: {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const route = useSessionKey()
|
const route = useSessionKey()
|
||||||
|
|
||||||
|
const tag = (mime: string) => {
|
||||||
|
if (mime === "application/pdf") return "pdf"
|
||||||
|
if (mime.startsWith("image/")) return "image"
|
||||||
|
return "file"
|
||||||
|
}
|
||||||
|
|
||||||
|
const chip = (part: { filename: string; mime: string }) => `[${tag(part.mime)}:${part.filename}]`
|
||||||
|
|
||||||
const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt)
|
const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt)
|
||||||
|
|
||||||
const previewPrompt = () =>
|
const previewPrompt = () =>
|
||||||
@@ -54,7 +62,7 @@ export function SessionComposerRegion(props: {
|
|||||||
.map((part) => {
|
.map((part) => {
|
||||||
if (part.type === "file") return `[file:${part.path}]`
|
if (part.type === "file") return `[file:${part.path}]`
|
||||||
if (part.type === "agent") return `@${part.name}`
|
if (part.type === "agent") return `@${part.name}`
|
||||||
if (part.type === "image") return `[image:${part.filename}]`
|
if (part.type === "image") return chip(part)
|
||||||
return part.content
|
return part.content
|
||||||
})
|
})
|
||||||
.join("")
|
.join("")
|
||||||
|
|||||||
@@ -259,24 +259,26 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
header={
|
header={
|
||||||
<>
|
<>
|
||||||
<div data-slot="question-header-title">{summary()}</div>
|
<div data-slot="question-header-title">{summary()}</div>
|
||||||
<div data-slot="question-progress">
|
<Show when={total() > 1}>
|
||||||
<For each={questions()}>
|
<div data-slot="question-progress">
|
||||||
{(_, i) => (
|
<For each={questions()}>
|
||||||
<button
|
{(_, i) => (
|
||||||
type="button"
|
<button
|
||||||
data-slot="question-progress-segment"
|
type="button"
|
||||||
data-active={i() === store.tab}
|
data-slot="question-progress-segment"
|
||||||
data-answered={
|
data-active={i() === store.tab}
|
||||||
(store.answers[i()]?.length ?? 0) > 0 ||
|
data-answered={
|
||||||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
|
(store.answers[i()]?.length ?? 0) > 0 ||
|
||||||
}
|
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
|
||||||
disabled={store.sending}
|
}
|
||||||
onClick={() => jump(i())}
|
disabled={store.sending}
|
||||||
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
onClick={() => jump(i())}
|
||||||
/>
|
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
||||||
)}
|
/>
|
||||||
</For>
|
)}
|
||||||
</div>
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
footer={
|
footer={
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } f
|
|||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
@@ -15,7 +14,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
|
|||||||
import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
|
import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
import { getFilename } from "@opencode-ai/util/path"
|
import { getFilenameTruncated } from "@opencode-ai/util/path"
|
||||||
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
||||||
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
||||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||||
@@ -29,6 +28,7 @@ import { useSDK } from "@/context/sdk"
|
|||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { messageAgentColor } from "@/utils/agent"
|
import { messageAgentColor } from "@/utils/agent"
|
||||||
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
|
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
|
||||||
|
import { CommentChip } from "@/components/comment-chip"
|
||||||
|
|
||||||
type MessageComment = {
|
type MessageComment = {
|
||||||
path: string
|
path: string
|
||||||
@@ -960,40 +960,36 @@ export function MessageTimeline(props: {
|
|||||||
>
|
>
|
||||||
<Show when={commentCount() > 0}>
|
<Show when={commentCount() > 0}>
|
||||||
<div class="w-full px-4 md:px-5 pb-2">
|
<div class="w-full px-4 md:px-5 pb-2">
|
||||||
<div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
|
<div class="w-full overflow-visible">
|
||||||
<div class="flex w-max min-w-full justify-end gap-2">
|
<div class="overflow-x-auto no-scrollbar">
|
||||||
<Index each={comments()}>
|
<div class="flex w-max min-w-full justify-end gap-2">
|
||||||
{(commentAccessor: () => MessageComment) => {
|
<Index each={comments()}>
|
||||||
const comment = createMemo(() => commentAccessor())
|
{(commentAccessor: () => MessageComment) => {
|
||||||
return (
|
const comment = createMemo(() => commentAccessor())
|
||||||
<Show when={comment()}>
|
return (
|
||||||
{(c) => (
|
<Show when={comment()}>
|
||||||
<div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
|
{(c) => (
|
||||||
<div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
|
<CommentChip
|
||||||
<FileIcon
|
variant="full"
|
||||||
node={{ path: c().path, type: "file" }}
|
path={c().path}
|
||||||
class="size-3.5 shrink-0"
|
label={getFilenameTruncated(c().path, 14)}
|
||||||
/>
|
selection={
|
||||||
<span class="truncate">{getFilename(c().path)}</span>
|
c().selection
|
||||||
<Show when={c().selection}>
|
? {
|
||||||
{(selection) => (
|
start: c().selection!.startLine,
|
||||||
<span class="shrink-0 text-text-weak">
|
end: c().selection!.endLine,
|
||||||
{selection().startLine === selection().endLine
|
}
|
||||||
? `:${selection().startLine}`
|
: undefined
|
||||||
: `:${selection().startLine}-${selection().endLine}`}
|
}
|
||||||
</span>
|
comment={c().comment}
|
||||||
)}
|
class="max-w-[260px]"
|
||||||
</Show>
|
/>
|
||||||
</div>
|
)}
|
||||||
<div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words">
|
</Show>
|
||||||
{c().comment}
|
)
|
||||||
</div>
|
}}
|
||||||
</div>
|
</Index>
|
||||||
)}
|
</div>
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</Index>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -76,6 +76,19 @@ export function IconAlipay(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IconUpi(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg {...props} viewBox="10 16 100 28" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M95.678 42.9 110 29.835l-6.784-13.516Z" />
|
||||||
|
<path d="M90.854 42.9 105.176 29.835l-6.784-13.516Z" />
|
||||||
|
<path
|
||||||
|
d="M22.41 16.47 16.38 37.945l21.407.15 5.88-21.625h5.427l-7.05 25.14c-.27.96-1.298 1.74-2.295 1.74H12.31c-1.664 0-2.65-1.3-2.2-2.9l6.724-23.98Zm66.182-.15h5.427l-7.538 27.03h-5.58ZM49.698 27.582l27.136-.15 1.81-5.707H51.054l1.658-5.256 29.4-.27c1.83-.017 2.92 1.4 2.438 3.167L81.78 29.49c-.483 1.766-2.36 3.197-4.19 3.197H53.316L50.454 43.8h-5.28Z"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function IconWechat(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
export function IconWechat(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||||
return (
|
return (
|
||||||
<svg {...props} viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
<svg {...props} viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|||||||
@@ -62,5 +62,6 @@
|
|||||||
font-size: var(--font-size-lg);
|
font-size: var(--font-size-lg);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -644,6 +644,8 @@ export const dict = {
|
|||||||
"تم تصميم الخطة بشكل أساسي للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة للحصول على وصول عالمي مستقر. قد تتغير الأسعار وحدود الاستخدام بناءً على تعلمنا من الاستخدام المبكر والملاحظات.",
|
"تم تصميم الخطة بشكل أساسي للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة للحصول على وصول عالمي مستقر. قد تتغير الأسعار وحدود الاستخدام بناءً على تعلمنا من الاستخدام المبكر والملاحظات.",
|
||||||
"workspace.lite.promo.subscribe": "الاشتراك في Go",
|
"workspace.lite.promo.subscribe": "الاشتراك في Go",
|
||||||
"workspace.lite.promo.subscribing": "جارٍ إعادة التوجيه...",
|
"workspace.lite.promo.subscribing": "جارٍ إعادة التوجيه...",
|
||||||
|
"workspace.lite.promo.otherMethods": "طرق دفع أخرى",
|
||||||
|
"workspace.lite.promo.selectMethod": "اختر طريقة الدفع",
|
||||||
|
|
||||||
"download.title": "OpenCode | تنزيل",
|
"download.title": "OpenCode | تنزيل",
|
||||||
"download.meta.description": "نزّل OpenCode لـ macOS، Windows، وLinux",
|
"download.meta.description": "نزّل OpenCode لـ macOS، Windows، وLinux",
|
||||||
|
|||||||
@@ -654,6 +654,8 @@ export const dict = {
|
|||||||
"O plano é projetado principalmente para usuários internacionais, com modelos hospedados nos EUA, UE e Singapura para acesso global estável. Preços e limites de uso podem mudar conforme aprendemos com o uso inicial e feedback.",
|
"O plano é projetado principalmente para usuários internacionais, com modelos hospedados nos EUA, UE e Singapura para acesso global estável. Preços e limites de uso podem mudar conforme aprendemos com o uso inicial e feedback.",
|
||||||
"workspace.lite.promo.subscribe": "Assinar Go",
|
"workspace.lite.promo.subscribe": "Assinar Go",
|
||||||
"workspace.lite.promo.subscribing": "Redirecionando...",
|
"workspace.lite.promo.subscribing": "Redirecionando...",
|
||||||
|
"workspace.lite.promo.otherMethods": "Outros métodos de pagamento",
|
||||||
|
"workspace.lite.promo.selectMethod": "Selecionar método de pagamento",
|
||||||
|
|
||||||
"download.title": "OpenCode | Baixar",
|
"download.title": "OpenCode | Baixar",
|
||||||
"download.meta.description": "Baixe o OpenCode para macOS, Windows e Linux",
|
"download.meta.description": "Baixe o OpenCode para macOS, Windows e Linux",
|
||||||
|
|||||||
@@ -651,6 +651,8 @@ export const dict = {
|
|||||||
"Planen er primært designet til internationale brugere, med modeller hostet i USA, EU og Singapore for stabil global adgang. Priser og forbrugsgrænser kan ændre sig, efterhånden som vi lærer af tidlig brug og feedback.",
|
"Planen er primært designet til internationale brugere, med modeller hostet i USA, EU og Singapore for stabil global adgang. Priser og forbrugsgrænser kan ændre sig, efterhånden som vi lærer af tidlig brug og feedback.",
|
||||||
"workspace.lite.promo.subscribe": "Abonner på Go",
|
"workspace.lite.promo.subscribe": "Abonner på Go",
|
||||||
"workspace.lite.promo.subscribing": "Omdirigerer...",
|
"workspace.lite.promo.subscribing": "Omdirigerer...",
|
||||||
|
"workspace.lite.promo.otherMethods": "Andre betalingsmetoder",
|
||||||
|
"workspace.lite.promo.selectMethod": "Vælg betalingsmetode",
|
||||||
|
|
||||||
"download.title": "OpenCode | Download",
|
"download.title": "OpenCode | Download",
|
||||||
"download.meta.description": "Download OpenCode til macOS, Windows og Linux",
|
"download.meta.description": "Download OpenCode til macOS, Windows og Linux",
|
||||||
|
|||||||
@@ -654,6 +654,8 @@ export const dict = {
|
|||||||
"Der Plan wurde hauptsächlich für internationale Nutzer entwickelt, wobei die Modelle in den USA, der EU und Singapur gehostet werden, um einen stabilen weltweiten Zugriff zu gewährleisten. Preise und Nutzungslimits können sich ändern, während wir aus der frühen Nutzung und dem Feedback lernen.",
|
"Der Plan wurde hauptsächlich für internationale Nutzer entwickelt, wobei die Modelle in den USA, der EU und Singapur gehostet werden, um einen stabilen weltweiten Zugriff zu gewährleisten. Preise und Nutzungslimits können sich ändern, während wir aus der frühen Nutzung und dem Feedback lernen.",
|
||||||
"workspace.lite.promo.subscribe": "Go abonnieren",
|
"workspace.lite.promo.subscribe": "Go abonnieren",
|
||||||
"workspace.lite.promo.subscribing": "Leite weiter...",
|
"workspace.lite.promo.subscribing": "Leite weiter...",
|
||||||
|
"workspace.lite.promo.otherMethods": "Andere Zahlungsmethoden",
|
||||||
|
"workspace.lite.promo.selectMethod": "Zahlungsmethode auswählen",
|
||||||
|
|
||||||
"download.title": "OpenCode | Download",
|
"download.title": "OpenCode | Download",
|
||||||
"download.meta.description": "Lade OpenCode für macOS, Windows und Linux herunter",
|
"download.meta.description": "Lade OpenCode für macOS, Windows und Linux herunter",
|
||||||
|
|||||||
@@ -646,6 +646,8 @@ export const dict = {
|
|||||||
"The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access. Pricing and usage limits may change as we learn from early usage and feedback.",
|
"The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access. Pricing and usage limits may change as we learn from early usage and feedback.",
|
||||||
"workspace.lite.promo.subscribe": "Subscribe to Go",
|
"workspace.lite.promo.subscribe": "Subscribe to Go",
|
||||||
"workspace.lite.promo.subscribing": "Redirecting...",
|
"workspace.lite.promo.subscribing": "Redirecting...",
|
||||||
|
"workspace.lite.promo.otherMethods": "Other payment methods",
|
||||||
|
"workspace.lite.promo.selectMethod": "Select payment method",
|
||||||
|
|
||||||
"download.title": "OpenCode | Download",
|
"download.title": "OpenCode | Download",
|
||||||
"download.meta.description": "Download OpenCode for macOS, Windows, and Linux",
|
"download.meta.description": "Download OpenCode for macOS, Windows, and Linux",
|
||||||
|
|||||||
@@ -654,6 +654,8 @@ export const dict = {
|
|||||||
"El plan está diseñado principalmente para usuarios internacionales, con modelos alojados en EE. UU., la UE y Singapur para un acceso global estable. Los precios y los límites de uso pueden cambiar a medida que aprendemos del uso inicial y los comentarios.",
|
"El plan está diseñado principalmente para usuarios internacionales, con modelos alojados en EE. UU., la UE y Singapur para un acceso global estable. Los precios y los límites de uso pueden cambiar a medida que aprendemos del uso inicial y los comentarios.",
|
||||||
"workspace.lite.promo.subscribe": "Suscribirse a Go",
|
"workspace.lite.promo.subscribe": "Suscribirse a Go",
|
||||||
"workspace.lite.promo.subscribing": "Redirigiendo...",
|
"workspace.lite.promo.subscribing": "Redirigiendo...",
|
||||||
|
"workspace.lite.promo.otherMethods": "Otros métodos de pago",
|
||||||
|
"workspace.lite.promo.selectMethod": "Seleccionar método de pago",
|
||||||
|
|
||||||
"download.title": "OpenCode | Descargar",
|
"download.title": "OpenCode | Descargar",
|
||||||
"download.meta.description": "Descarga OpenCode para macOS, Windows y Linux",
|
"download.meta.description": "Descarga OpenCode para macOS, Windows y Linux",
|
||||||
|
|||||||
@@ -661,6 +661,8 @@ export const dict = {
|
|||||||
"Le plan est conçu principalement pour les utilisateurs internationaux, avec des modèles hébergés aux États-Unis, dans l'UE et à Singapour pour un accès mondial stable. Les tarifs et les limites d'utilisation peuvent changer à mesure que nous apprenons des premières utilisations et des commentaires.",
|
"Le plan est conçu principalement pour les utilisateurs internationaux, avec des modèles hébergés aux États-Unis, dans l'UE et à Singapour pour un accès mondial stable. Les tarifs et les limites d'utilisation peuvent changer à mesure que nous apprenons des premières utilisations et des commentaires.",
|
||||||
"workspace.lite.promo.subscribe": "S'abonner à Go",
|
"workspace.lite.promo.subscribe": "S'abonner à Go",
|
||||||
"workspace.lite.promo.subscribing": "Redirection...",
|
"workspace.lite.promo.subscribing": "Redirection...",
|
||||||
|
"workspace.lite.promo.otherMethods": "Autres méthodes de paiement",
|
||||||
|
"workspace.lite.promo.selectMethod": "Sélectionner la méthode de paiement",
|
||||||
|
|
||||||
"download.title": "OpenCode | Téléchargement",
|
"download.title": "OpenCode | Téléchargement",
|
||||||
"download.meta.description": "Téléchargez OpenCode pour macOS, Windows et Linux",
|
"download.meta.description": "Téléchargez OpenCode pour macOS, Windows et Linux",
|
||||||
|
|||||||
@@ -652,6 +652,8 @@ export const dict = {
|
|||||||
"Il piano è progettato principalmente per gli utenti internazionali, con modelli ospitati in US, EU e Singapore per un accesso globale stabile. I prezzi e i limiti di utilizzo potrebbero cambiare man mano che impariamo dall'utilizzo iniziale e dal feedback.",
|
"Il piano è progettato principalmente per gli utenti internazionali, con modelli ospitati in US, EU e Singapore per un accesso globale stabile. I prezzi e i limiti di utilizzo potrebbero cambiare man mano che impariamo dall'utilizzo iniziale e dal feedback.",
|
||||||
"workspace.lite.promo.subscribe": "Abbonati a Go",
|
"workspace.lite.promo.subscribe": "Abbonati a Go",
|
||||||
"workspace.lite.promo.subscribing": "Reindirizzamento...",
|
"workspace.lite.promo.subscribing": "Reindirizzamento...",
|
||||||
|
"workspace.lite.promo.otherMethods": "Altri metodi di pagamento",
|
||||||
|
"workspace.lite.promo.selectMethod": "Seleziona metodo di pagamento",
|
||||||
|
|
||||||
"download.title": "OpenCode | Download",
|
"download.title": "OpenCode | Download",
|
||||||
"download.meta.description": "Scarica OpenCode per macOS, Windows e Linux",
|
"download.meta.description": "Scarica OpenCode per macOS, Windows e Linux",
|
||||||
|
|||||||
@@ -653,6 +653,8 @@ export const dict = {
|
|||||||
"このプランは主にグローバルユーザー向けに設計されており、米国、EU、シンガポールでホストされたモデルにより安定したグローバルアクセスを提供します。料金と利用制限は、初期の利用状況やフィードバックに基づいて変更される可能性があります。",
|
"このプランは主にグローバルユーザー向けに設計されており、米国、EU、シンガポールでホストされたモデルにより安定したグローバルアクセスを提供します。料金と利用制限は、初期の利用状況やフィードバックに基づいて変更される可能性があります。",
|
||||||
"workspace.lite.promo.subscribe": "Goを購読する",
|
"workspace.lite.promo.subscribe": "Goを購読する",
|
||||||
"workspace.lite.promo.subscribing": "リダイレクト中...",
|
"workspace.lite.promo.subscribing": "リダイレクト中...",
|
||||||
|
"workspace.lite.promo.otherMethods": "その他の支払い方法",
|
||||||
|
"workspace.lite.promo.selectMethod": "支払い方法を選択",
|
||||||
|
|
||||||
"download.title": "OpenCode | ダウンロード",
|
"download.title": "OpenCode | ダウンロード",
|
||||||
"download.meta.description": "OpenCode を macOS、Windows、Linux 向けにダウンロード",
|
"download.meta.description": "OpenCode を macOS、Windows、Linux 向けにダウンロード",
|
||||||
|
|||||||
@@ -645,6 +645,8 @@ export const dict = {
|
|||||||
"이 플랜은 주로 글로벌 사용자를 위해 설계되었으며, 안정적인 글로벌 액세스를 위해 미국, EU 및 싱가포르에 모델이 호스팅되어 있습니다. 가격 및 사용 한도는 초기 사용을 통해 학습하고 피드백을 수집함에 따라 변경될 수 있습니다.",
|
"이 플랜은 주로 글로벌 사용자를 위해 설계되었으며, 안정적인 글로벌 액세스를 위해 미국, EU 및 싱가포르에 모델이 호스팅되어 있습니다. 가격 및 사용 한도는 초기 사용을 통해 학습하고 피드백을 수집함에 따라 변경될 수 있습니다.",
|
||||||
"workspace.lite.promo.subscribe": "Go 구독하기",
|
"workspace.lite.promo.subscribe": "Go 구독하기",
|
||||||
"workspace.lite.promo.subscribing": "리디렉션 중...",
|
"workspace.lite.promo.subscribing": "리디렉션 중...",
|
||||||
|
"workspace.lite.promo.otherMethods": "기타 결제 수단",
|
||||||
|
"workspace.lite.promo.selectMethod": "결제 수단 선택",
|
||||||
|
|
||||||
"download.title": "OpenCode | 다운로드",
|
"download.title": "OpenCode | 다운로드",
|
||||||
"download.meta.description": "macOS, Windows, Linux용 OpenCode 다운로드",
|
"download.meta.description": "macOS, Windows, Linux용 OpenCode 다운로드",
|
||||||
|
|||||||
@@ -651,6 +651,8 @@ export const dict = {
|
|||||||
"Planen er primært designet for internasjonale brukere, med modeller driftet i USA, EU og Singapore for stabil global tilgang. Priser og bruksgrenser kan endres etter hvert som vi lærer fra tidlig bruk og tilbakemeldinger.",
|
"Planen er primært designet for internasjonale brukere, med modeller driftet i USA, EU og Singapore for stabil global tilgang. Priser og bruksgrenser kan endres etter hvert som vi lærer fra tidlig bruk og tilbakemeldinger.",
|
||||||
"workspace.lite.promo.subscribe": "Abonner på Go",
|
"workspace.lite.promo.subscribe": "Abonner på Go",
|
||||||
"workspace.lite.promo.subscribing": "Omdirigerer...",
|
"workspace.lite.promo.subscribing": "Omdirigerer...",
|
||||||
|
"workspace.lite.promo.otherMethods": "Andre betalingsmetoder",
|
||||||
|
"workspace.lite.promo.selectMethod": "Velg betalingsmetode",
|
||||||
|
|
||||||
"download.title": "OpenCode | Last ned",
|
"download.title": "OpenCode | Last ned",
|
||||||
"download.meta.description": "Last ned OpenCode for macOS, Windows og Linux",
|
"download.meta.description": "Last ned OpenCode for macOS, Windows og Linux",
|
||||||
|
|||||||
@@ -652,6 +652,8 @@ export const dict = {
|
|||||||
"Plan został zaprojektowany głównie dla użytkowników międzynarodowych, z modelami hostowanymi w USA, UE i Singapurze, aby zapewnić stabilny globalny dostęp. Ceny i limity użycia mogą ulec zmianie w miarę analizy wczesnego użycia i zbierania opinii.",
|
"Plan został zaprojektowany głównie dla użytkowników międzynarodowych, z modelami hostowanymi w USA, UE i Singapurze, aby zapewnić stabilny globalny dostęp. Ceny i limity użycia mogą ulec zmianie w miarę analizy wczesnego użycia i zbierania opinii.",
|
||||||
"workspace.lite.promo.subscribe": "Subskrybuj Go",
|
"workspace.lite.promo.subscribe": "Subskrybuj Go",
|
||||||
"workspace.lite.promo.subscribing": "Przekierowywanie...",
|
"workspace.lite.promo.subscribing": "Przekierowywanie...",
|
||||||
|
"workspace.lite.promo.otherMethods": "Inne metody płatności",
|
||||||
|
"workspace.lite.promo.selectMethod": "Wybierz metodę płatności",
|
||||||
|
|
||||||
"download.title": "OpenCode | Pobierz",
|
"download.title": "OpenCode | Pobierz",
|
||||||
"download.meta.description": "Pobierz OpenCode na macOS, Windows i Linux",
|
"download.meta.description": "Pobierz OpenCode na macOS, Windows i Linux",
|
||||||
|
|||||||
@@ -658,6 +658,8 @@ export const dict = {
|
|||||||
"План предназначен в первую очередь для международных пользователей. Модели размещены в США, ЕС и Сингапуре для стабильного глобального доступа. Цены и лимиты использования могут меняться по мере того, как мы изучаем раннее использование и собираем отзывы.",
|
"План предназначен в первую очередь для международных пользователей. Модели размещены в США, ЕС и Сингапуре для стабильного глобального доступа. Цены и лимиты использования могут меняться по мере того, как мы изучаем раннее использование и собираем отзывы.",
|
||||||
"workspace.lite.promo.subscribe": "Подписаться на Go",
|
"workspace.lite.promo.subscribe": "Подписаться на Go",
|
||||||
"workspace.lite.promo.subscribing": "Перенаправление...",
|
"workspace.lite.promo.subscribing": "Перенаправление...",
|
||||||
|
"workspace.lite.promo.otherMethods": "Другие способы оплаты",
|
||||||
|
"workspace.lite.promo.selectMethod": "Выберите способ оплаты",
|
||||||
|
|
||||||
"download.title": "OpenCode | Скачать",
|
"download.title": "OpenCode | Скачать",
|
||||||
"download.meta.description": "Скачать OpenCode для macOS, Windows и Linux",
|
"download.meta.description": "Скачать OpenCode для macOS, Windows и Linux",
|
||||||
|
|||||||
@@ -648,6 +648,8 @@ export const dict = {
|
|||||||
"แผนนี้ออกแบบมาสำหรับผู้ใช้งานต่างประเทศเป็นหลัก โดยมีโมเดลโฮสต์อยู่ในสหรัฐอเมริกา สหภาพยุโรป และสิงคโปร์ เพื่อการเข้าถึงที่เสถียรทั่วโลก ราคาและขีดจำกัดการใช้งานอาจมีการเปลี่ยนแปลงตามที่เราได้เรียนรู้จากการใช้งานในช่วงแรกและข้อเสนอแนะ",
|
"แผนนี้ออกแบบมาสำหรับผู้ใช้งานต่างประเทศเป็นหลัก โดยมีโมเดลโฮสต์อยู่ในสหรัฐอเมริกา สหภาพยุโรป และสิงคโปร์ เพื่อการเข้าถึงที่เสถียรทั่วโลก ราคาและขีดจำกัดการใช้งานอาจมีการเปลี่ยนแปลงตามที่เราได้เรียนรู้จากการใช้งานในช่วงแรกและข้อเสนอแนะ",
|
||||||
"workspace.lite.promo.subscribe": "สมัครสมาชิก Go",
|
"workspace.lite.promo.subscribe": "สมัครสมาชิก Go",
|
||||||
"workspace.lite.promo.subscribing": "กำลังเปลี่ยนเส้นทาง...",
|
"workspace.lite.promo.subscribing": "กำลังเปลี่ยนเส้นทาง...",
|
||||||
|
"workspace.lite.promo.otherMethods": "วิธีการชำระเงินอื่นๆ",
|
||||||
|
"workspace.lite.promo.selectMethod": "เลือกวิธีการชำระเงิน",
|
||||||
|
|
||||||
"download.title": "OpenCode | ดาวน์โหลด",
|
"download.title": "OpenCode | ดาวน์โหลด",
|
||||||
"download.meta.description": "ดาวน์โหลด OpenCode สำหรับ macOS, Windows และ Linux",
|
"download.meta.description": "ดาวน์โหลด OpenCode สำหรับ macOS, Windows และ Linux",
|
||||||
|
|||||||
@@ -655,6 +655,8 @@ export const dict = {
|
|||||||
"Plan öncelikle uluslararası kullanıcılar için tasarlanmıştır; modeller istikrarlı küresel erişim için ABD, AB ve Singapur'da barındırılmaktadır. Erken kullanımdan öğrendikçe ve geri bildirim topladıkça fiyatlandırma ve kullanım limitleri değişebilir.",
|
"Plan öncelikle uluslararası kullanıcılar için tasarlanmıştır; modeller istikrarlı küresel erişim için ABD, AB ve Singapur'da barındırılmaktadır. Erken kullanımdan öğrendikçe ve geri bildirim topladıkça fiyatlandırma ve kullanım limitleri değişebilir.",
|
||||||
"workspace.lite.promo.subscribe": "Go'ya Abone Ol",
|
"workspace.lite.promo.subscribe": "Go'ya Abone Ol",
|
||||||
"workspace.lite.promo.subscribing": "Yönlendiriliyor...",
|
"workspace.lite.promo.subscribing": "Yönlendiriliyor...",
|
||||||
|
"workspace.lite.promo.otherMethods": "Diğer ödeme yöntemleri",
|
||||||
|
"workspace.lite.promo.selectMethod": "Ödeme yöntemini seçin",
|
||||||
|
|
||||||
"download.title": "OpenCode | İndir",
|
"download.title": "OpenCode | İndir",
|
||||||
"download.meta.description": "OpenCode'u macOS, Windows ve Linux için indirin",
|
"download.meta.description": "OpenCode'u macOS, Windows ve Linux için indirin",
|
||||||
|
|||||||
@@ -626,6 +626,8 @@ export const dict = {
|
|||||||
"该计划主要面向国际用户设计,模型部署在美国、欧盟和新加坡,以确保全球范围内的稳定访问体验。定价和使用额度可能会根据早期用户的使用情况和反馈持续调整与优化。",
|
"该计划主要面向国际用户设计,模型部署在美国、欧盟和新加坡,以确保全球范围内的稳定访问体验。定价和使用额度可能会根据早期用户的使用情况和反馈持续调整与优化。",
|
||||||
"workspace.lite.promo.subscribe": "订阅 Go",
|
"workspace.lite.promo.subscribe": "订阅 Go",
|
||||||
"workspace.lite.promo.subscribing": "正在重定向...",
|
"workspace.lite.promo.subscribing": "正在重定向...",
|
||||||
|
"workspace.lite.promo.otherMethods": "其他付款方式",
|
||||||
|
"workspace.lite.promo.selectMethod": "选择付款方式",
|
||||||
|
|
||||||
"download.title": "OpenCode | 下载",
|
"download.title": "OpenCode | 下载",
|
||||||
"download.meta.description": "下载适用于 macOS, Windows, 和 Linux 的 OpenCode",
|
"download.meta.description": "下载适用于 macOS, Windows, 和 Linux 的 OpenCode",
|
||||||
|
|||||||
@@ -626,6 +626,8 @@ export const dict = {
|
|||||||
"該計畫主要面向國際用戶設計,模型部署在美國、歐盟和新加坡,以確保全球範圍內的穩定存取體驗。定價和使用額度可能會根據早期用戶的使用情況和回饋持續調整與優化。",
|
"該計畫主要面向國際用戶設計,模型部署在美國、歐盟和新加坡,以確保全球範圍內的穩定存取體驗。定價和使用額度可能會根據早期用戶的使用情況和回饋持續調整與優化。",
|
||||||
"workspace.lite.promo.subscribe": "訂閱 Go",
|
"workspace.lite.promo.subscribe": "訂閱 Go",
|
||||||
"workspace.lite.promo.subscribing": "重新導向中...",
|
"workspace.lite.promo.subscribing": "重新導向中...",
|
||||||
|
"workspace.lite.promo.otherMethods": "其他付款方式",
|
||||||
|
"workspace.lite.promo.selectMethod": "選擇付款方式",
|
||||||
|
|
||||||
"download.title": "OpenCode | 下載",
|
"download.title": "OpenCode | 下載",
|
||||||
"download.meta.description": "下載適用於 macOS、Windows 與 Linux 的 OpenCode",
|
"download.meta.description": "下載適用於 macOS、Windows 與 Linux 的 OpenCode",
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ export async function POST(input: APIEvent) {
|
|||||||
customerID,
|
customerID,
|
||||||
enrichment: {
|
enrichment: {
|
||||||
type: productID === LiteData.productID() ? "lite" : "subscription",
|
type: productID === LiteData.productID() ? "lite" : "subscription",
|
||||||
|
currency: body.data.object.currency === "inr" ? "inr" : undefined,
|
||||||
couponID,
|
couponID,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -331,16 +332,17 @@ export async function POST(input: APIEvent) {
|
|||||||
)
|
)
|
||||||
if (!workspaceID) throw new Error("Workspace ID not found")
|
if (!workspaceID) throw new Error("Workspace ID not found")
|
||||||
|
|
||||||
const amount = await Database.use((tx) =>
|
const payment = await Database.use((tx) =>
|
||||||
tx
|
tx
|
||||||
.select({
|
.select({
|
||||||
amount: PaymentTable.amount,
|
amount: PaymentTable.amount,
|
||||||
|
enrichment: PaymentTable.enrichment,
|
||||||
})
|
})
|
||||||
.from(PaymentTable)
|
.from(PaymentTable)
|
||||||
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
|
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
|
||||||
.then((rows) => rows[0]?.amount),
|
.then((rows) => rows[0]),
|
||||||
)
|
)
|
||||||
if (!amount) throw new Error("Payment not found")
|
if (!payment) throw new Error("Payment not found")
|
||||||
|
|
||||||
await Database.transaction(async (tx) => {
|
await Database.transaction(async (tx) => {
|
||||||
await tx
|
await tx
|
||||||
@@ -350,12 +352,15 @@ export async function POST(input: APIEvent) {
|
|||||||
})
|
})
|
||||||
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
|
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
|
||||||
|
|
||||||
await tx
|
// deduct balance only for top up
|
||||||
.update(BillingTable)
|
if (!payment.enrichment?.type) {
|
||||||
.set({
|
await tx
|
||||||
balance: sql`${BillingTable.balance} - ${amount}`,
|
.update(BillingTable)
|
||||||
})
|
.set({
|
||||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
balance: sql`${BillingTable.balance} - ${payment.amount}`,
|
||||||
|
})
|
||||||
|
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { createMemo, Match, Show, Switch, createEffect } from "solid-js"
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||||
import { withActor } from "~/context/auth.withActor"
|
import { withActor } from "~/context/auth.withActor"
|
||||||
import { IconAlipay, IconCreditCard, IconStripe, IconWechat } from "~/component/icon"
|
import { IconAlipay, IconCreditCard, IconStripe, IconUpi, IconWechat } from "~/component/icon"
|
||||||
import styles from "./billing-section.module.css"
|
import styles from "./billing-section.module.css"
|
||||||
import { createCheckoutUrl, formatBalance, queryBillingInfo } from "../../common"
|
import { createCheckoutUrl, formatBalance, queryBillingInfo } from "../../common"
|
||||||
import { useI18n } from "~/context/i18n"
|
import { useI18n } from "~/context/i18n"
|
||||||
@@ -211,6 +211,9 @@ export function BillingSection() {
|
|||||||
<Match when={billingInfo()?.paymentMethodType === "wechat_pay"}>
|
<Match when={billingInfo()?.paymentMethodType === "wechat_pay"}>
|
||||||
<IconWechat style={{ width: "24px", height: "24px" }} />
|
<IconWechat style={{ width: "24px", height: "24px" }} />
|
||||||
</Match>
|
</Match>
|
||||||
|
<Match when={billingInfo()?.paymentMethodType === "upi"}>
|
||||||
|
<IconUpi style={{ width: "auto", height: "16px" }} />
|
||||||
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
<div data-slot="card-details">
|
<div data-slot="card-details">
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ import { formatDateUTC, formatDateForTable } from "../../common"
|
|||||||
import styles from "./payment-section.module.css"
|
import styles from "./payment-section.module.css"
|
||||||
import { useI18n } from "~/context/i18n"
|
import { useI18n } from "~/context/i18n"
|
||||||
|
|
||||||
|
function money(amount: number, currency?: string) {
|
||||||
|
const formatter =
|
||||||
|
currency === "inr"
|
||||||
|
? new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR" })
|
||||||
|
: new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
|
||||||
|
return formatter.format(amount / 100_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
const getPaymentsInfo = query(async (workspaceID: string) => {
|
const getPaymentsInfo = query(async (workspaceID: string) => {
|
||||||
"use server"
|
"use server"
|
||||||
return withActor(async () => {
|
return withActor(async () => {
|
||||||
@@ -81,6 +89,10 @@ export function PaymentSection() {
|
|||||||
const date = new Date(payment.timeCreated)
|
const date = new Date(payment.timeCreated)
|
||||||
const amount =
|
const amount =
|
||||||
payment.enrichment?.type === "subscription" && payment.enrichment.couponID ? 0 : payment.amount
|
payment.enrichment?.type === "subscription" && payment.enrichment.couponID ? 0 : payment.amount
|
||||||
|
const currency =
|
||||||
|
payment.enrichment?.type === "subscription" || payment.enrichment?.type === "lite"
|
||||||
|
? payment.enrichment.currency
|
||||||
|
: undefined
|
||||||
return (
|
return (
|
||||||
<tr>
|
<tr>
|
||||||
<td data-slot="payment-date" title={formatDateUTC(date)}>
|
<td data-slot="payment-date" title={formatDateUTC(date)}>
|
||||||
@@ -88,7 +100,7 @@ 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}>
|
||||||
${((amount ?? 0) / 100000000).toFixed(2)}
|
{money(amount, currency)}
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={payment.enrichment?.type === "credit"}>
|
<Match when={payment.enrichment?.type === "credit"}>
|
||||||
{" "}
|
{" "}
|
||||||
|
|||||||
@@ -188,8 +188,45 @@
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="subscribe-button"] {
|
[data-slot="subscribe-actions"] {
|
||||||
align-self: flex-start;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
margin-top: var(--space-4);
|
margin-top: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-slot="subscribe-button"] {
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="other-methods"] {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="other-methods-icons"] {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="modal-actions"] {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
|
||||||
|
button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="method-button"] {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: var(--space-2);
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { action, useParams, useAction, useSubmission, json, query, createAsync } from "@solidjs/router"
|
import { action, useParams, useAction, useSubmission, json, query, createAsync } from "@solidjs/router"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createMemo, For, Show } from "solid-js"
|
import { createMemo, For, Show } from "solid-js"
|
||||||
|
import { Modal } from "~/component/modal"
|
||||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||||
import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.js"
|
import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.js"
|
||||||
import { BillingTable, LiteTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
import { BillingTable, LiteTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||||
@@ -14,6 +15,8 @@ import { useI18n } from "~/context/i18n"
|
|||||||
import { useLanguage } from "~/context/language"
|
import { useLanguage } from "~/context/language"
|
||||||
import { formError } from "~/lib/form-error"
|
import { formError } from "~/lib/form-error"
|
||||||
|
|
||||||
|
import { IconAlipay, IconUpi } from "~/component/icon"
|
||||||
|
|
||||||
const queryLiteSubscription = query(async (workspaceID: string) => {
|
const queryLiteSubscription = query(async (workspaceID: string) => {
|
||||||
"use server"
|
"use server"
|
||||||
return withActor(async () => {
|
return withActor(async () => {
|
||||||
@@ -78,22 +81,25 @@ function formatResetTime(seconds: number, i18n: ReturnType<typeof useI18n>) {
|
|||||||
return `${minutes} ${minutes === 1 ? i18n.t("workspace.lite.time.minute") : i18n.t("workspace.lite.time.minutes")}`
|
return `${minutes} ${minutes === 1 ? i18n.t("workspace.lite.time.minute") : i18n.t("workspace.lite.time.minutes")}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const createLiteCheckoutUrl = action(async (workspaceID: string, successUrl: string, cancelUrl: string) => {
|
const createLiteCheckoutUrl = action(
|
||||||
"use server"
|
async (workspaceID: string, successUrl: string, cancelUrl: string, method?: "alipay" | "upi") => {
|
||||||
return json(
|
"use server"
|
||||||
await withActor(
|
return json(
|
||||||
() =>
|
await withActor(
|
||||||
Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl })
|
() =>
|
||||||
.then((data) => ({ error: undefined, data }))
|
Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl, method })
|
||||||
.catch((e) => ({
|
.then((data) => ({ error: undefined, data }))
|
||||||
error: e.message as string,
|
.catch((e) => ({
|
||||||
data: undefined,
|
error: e.message as string,
|
||||||
})),
|
data: undefined,
|
||||||
workspaceID,
|
})),
|
||||||
),
|
workspaceID,
|
||||||
{ revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
|
),
|
||||||
)
|
{ revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
|
||||||
}, "liteCheckoutUrl")
|
)
|
||||||
|
},
|
||||||
|
"liteCheckoutUrl",
|
||||||
|
)
|
||||||
|
|
||||||
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
|
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
|
||||||
"use server"
|
"use server"
|
||||||
@@ -147,23 +153,30 @@ export function LiteSection() {
|
|||||||
const checkoutSubmission = useSubmission(createLiteCheckoutUrl)
|
const checkoutSubmission = useSubmission(createLiteCheckoutUrl)
|
||||||
const useBalanceSubmission = useSubmission(setLiteUseBalance)
|
const useBalanceSubmission = useSubmission(setLiteUseBalance)
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
redirecting: false,
|
loading: undefined as undefined | "session" | "checkout" | "alipay" | "upi",
|
||||||
|
showModal: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const busy = createMemo(() => !!store.loading)
|
||||||
|
|
||||||
async function onClickSession() {
|
async function onClickSession() {
|
||||||
|
setStore("loading", "session")
|
||||||
const result = await sessionAction(params.id!, window.location.href)
|
const result = await sessionAction(params.id!, window.location.href)
|
||||||
if (result.data) {
|
if (result.data) {
|
||||||
setStore("redirecting", true)
|
|
||||||
window.location.href = result.data
|
window.location.href = result.data
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
setStore("loading", undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onClickSubscribe() {
|
async function onClickSubscribe(method?: "alipay" | "upi") {
|
||||||
const result = await checkoutAction(params.id!, window.location.href, window.location.href)
|
setStore("loading", method ?? "checkout")
|
||||||
|
const result = await checkoutAction(params.id!, window.location.href, window.location.href, method)
|
||||||
if (result.data) {
|
if (result.data) {
|
||||||
setStore("redirecting", true)
|
|
||||||
window.location.href = result.data
|
window.location.href = result.data
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
setStore("loading", undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -179,12 +192,8 @@ export function LiteSection() {
|
|||||||
<div data-slot="section-title">
|
<div data-slot="section-title">
|
||||||
<div data-slot="title-row">
|
<div data-slot="title-row">
|
||||||
<p>{i18n.t("workspace.lite.subscription.message")}</p>
|
<p>{i18n.t("workspace.lite.subscription.message")}</p>
|
||||||
<button
|
<button data-color="primary" disabled={sessionSubmission.pending || busy()} onClick={onClickSession}>
|
||||||
data-color="primary"
|
{store.loading === "session"
|
||||||
disabled={sessionSubmission.pending || store.redirecting}
|
|
||||||
onClick={onClickSession}
|
|
||||||
>
|
|
||||||
{sessionSubmission.pending || store.redirecting
|
|
||||||
? i18n.t("workspace.lite.loading")
|
? i18n.t("workspace.lite.loading")
|
||||||
: i18n.t("workspace.lite.subscription.manage")}
|
: i18n.t("workspace.lite.subscription.manage")}
|
||||||
</button>
|
</button>
|
||||||
@@ -282,16 +291,64 @@ export function LiteSection() {
|
|||||||
<li>MiniMax M2.7</li>
|
<li>MiniMax M2.7</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p data-slot="promo-description">{i18n.t("workspace.lite.promo.footer")}</p>
|
<p data-slot="promo-description">{i18n.t("workspace.lite.promo.footer")}</p>
|
||||||
<button
|
<div data-slot="subscribe-actions">
|
||||||
data-slot="subscribe-button"
|
<button
|
||||||
data-color="primary"
|
data-slot="subscribe-button"
|
||||||
disabled={checkoutSubmission.pending || store.redirecting}
|
data-color="primary"
|
||||||
onClick={onClickSubscribe}
|
disabled={checkoutSubmission.pending || busy()}
|
||||||
|
onClick={() => onClickSubscribe()}
|
||||||
|
>
|
||||||
|
{store.loading === "checkout"
|
||||||
|
? i18n.t("workspace.lite.promo.subscribing")
|
||||||
|
: i18n.t("workspace.lite.promo.subscribe")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-slot="other-methods"
|
||||||
|
data-color="ghost"
|
||||||
|
onClick={() => setStore("showModal", true)}
|
||||||
|
>
|
||||||
|
<span>{i18n.t("workspace.lite.promo.otherMethods")}</span>
|
||||||
|
<span data-slot="other-methods-icons">
|
||||||
|
<span> </span>
|
||||||
|
<IconAlipay style={{ width: "16px", height: "16px" }} />
|
||||||
|
<span> </span>
|
||||||
|
<IconUpi style={{ width: "auto", height: "10px" }} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Modal
|
||||||
|
open={store.showModal}
|
||||||
|
onClose={() => setStore("showModal", false)}
|
||||||
|
title={i18n.t("workspace.lite.promo.selectMethod")}
|
||||||
>
|
>
|
||||||
{checkoutSubmission.pending || store.redirecting
|
<div data-slot="modal-actions">
|
||||||
? i18n.t("workspace.lite.promo.subscribing")
|
<button
|
||||||
: i18n.t("workspace.lite.promo.subscribe")}
|
type="button"
|
||||||
</button>
|
data-slot="method-button"
|
||||||
|
data-color="ghost"
|
||||||
|
disabled={checkoutSubmission.pending || busy()}
|
||||||
|
onClick={() => onClickSubscribe("alipay")}
|
||||||
|
>
|
||||||
|
<Show when={store.loading !== "alipay"}>
|
||||||
|
<IconAlipay style={{ width: "24px", height: "24px" }} />
|
||||||
|
</Show>
|
||||||
|
{store.loading === "alipay" ? i18n.t("workspace.lite.promo.subscribing") : "Alipay"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-slot="method-button"
|
||||||
|
data-color="ghost"
|
||||||
|
disabled={checkoutSubmission.pending || busy()}
|
||||||
|
onClick={() => onClickSubscribe("upi")}
|
||||||
|
>
|
||||||
|
<Show when={store.loading !== "upi"}>
|
||||||
|
<IconUpi style={{ width: "auto", height: "16px" }} />
|
||||||
|
</Show>
|
||||||
|
{store.loading === "upi" ? i18n.t("workspace.lite.promo.subscribing") : "UPI"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</section>
|
</section>
|
||||||
</Show>
|
</Show>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -239,10 +239,11 @@ export namespace Billing {
|
|||||||
z.object({
|
z.object({
|
||||||
successUrl: z.string(),
|
successUrl: z.string(),
|
||||||
cancelUrl: z.string(),
|
cancelUrl: z.string(),
|
||||||
|
method: z.enum(["alipay", "upi"]).optional(),
|
||||||
}),
|
}),
|
||||||
async (input) => {
|
async (input) => {
|
||||||
const user = Actor.assert("user")
|
const user = Actor.assert("user")
|
||||||
const { successUrl, cancelUrl } = input
|
const { successUrl, cancelUrl, method } = input
|
||||||
|
|
||||||
const email = await User.getAuthEmail(user.properties.userID)
|
const email = await User.getAuthEmail(user.properties.userID)
|
||||||
const billing = await Billing.get()
|
const billing = await Billing.get()
|
||||||
@@ -250,38 +251,102 @@ export namespace Billing {
|
|||||||
if (billing.subscriptionID) throw new Error("Already subscribed to Black")
|
if (billing.subscriptionID) throw new Error("Already subscribed to Black")
|
||||||
if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite")
|
if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite")
|
||||||
|
|
||||||
const session = await Billing.stripe().checkout.sessions.create({
|
const createSession = () =>
|
||||||
mode: "subscription",
|
Billing.stripe().checkout.sessions.create({
|
||||||
billing_address_collection: "required",
|
mode: "subscription",
|
||||||
line_items: [{ price: LiteData.priceID(), quantity: 1 }],
|
discounts: [{ coupon: LiteData.firstMonth50Coupon() }],
|
||||||
discounts: [{ coupon: LiteData.firstMonth50Coupon() }],
|
...(billing.customerID
|
||||||
...(billing.customerID
|
? {
|
||||||
? {
|
customer: billing.customerID,
|
||||||
customer: billing.customerID,
|
customer_update: {
|
||||||
customer_update: {
|
name: "auto",
|
||||||
name: "auto",
|
address: "auto",
|
||||||
address: "auto",
|
},
|
||||||
},
|
}
|
||||||
|
: {
|
||||||
|
customer_email: email!,
|
||||||
|
}),
|
||||||
|
...(() => {
|
||||||
|
if (method === "alipay") {
|
||||||
|
return {
|
||||||
|
line_items: [{ price: LiteData.priceID(), quantity: 1 }],
|
||||||
|
payment_method_types: ["alipay"],
|
||||||
|
adaptive_pricing: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
: {
|
if (method === "upi") {
|
||||||
customer_email: email!,
|
return {
|
||||||
}),
|
line_items: [
|
||||||
currency: "usd",
|
{
|
||||||
tax_id_collection: {
|
price_data: {
|
||||||
enabled: true,
|
currency: "inr",
|
||||||
},
|
product: LiteData.productID(),
|
||||||
success_url: successUrl,
|
recurring: {
|
||||||
cancel_url: cancelUrl,
|
interval: "month",
|
||||||
subscription_data: {
|
interval_count: 1,
|
||||||
metadata: {
|
},
|
||||||
workspaceID: Actor.workspace(),
|
unit_amount: LiteData.priceInr(),
|
||||||
userID: user.properties.userID,
|
},
|
||||||
type: "lite",
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
payment_method_types: ["upi"] as any,
|
||||||
|
adaptive_pricing: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
line_items: [{ price: LiteData.priceID(), quantity: 1 }],
|
||||||
|
billing_address_collection: "required",
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
tax_id_collection: {
|
||||||
|
enabled: true,
|
||||||
},
|
},
|
||||||
},
|
success_url: successUrl,
|
||||||
})
|
cancel_url: cancelUrl,
|
||||||
|
subscription_data: {
|
||||||
|
metadata: {
|
||||||
|
workspaceID: Actor.workspace(),
|
||||||
|
userID: user.properties.userID,
|
||||||
|
type: "lite",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
return session.url
|
try {
|
||||||
|
const session = await createSession()
|
||||||
|
return session.url
|
||||||
|
} catch (e: any) {
|
||||||
|
if (
|
||||||
|
e.type !== "StripeInvalidRequestError" ||
|
||||||
|
!e.message.includes("You cannot combine currencies on a single customer")
|
||||||
|
)
|
||||||
|
throw e
|
||||||
|
|
||||||
|
// get pending payment intent
|
||||||
|
const intents = await Billing.stripe().paymentIntents.search({
|
||||||
|
query: `-status:'canceled' AND -status:'processing' AND -status:'succeeded' AND customer:'${billing.customerID}'`,
|
||||||
|
})
|
||||||
|
if (intents.data.length === 0) throw e
|
||||||
|
|
||||||
|
for (const intent of intents.data) {
|
||||||
|
// get checkout session
|
||||||
|
const sessions = await Billing.stripe().checkout.sessions.list({
|
||||||
|
customer: billing.customerID!,
|
||||||
|
payment_intent: intent.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
// delete pending payment intent
|
||||||
|
await Billing.stripe().checkout.sessions.expire(sessions.data[0].id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await createSession()
|
||||||
|
return session.url
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export namespace LiteData {
|
|||||||
|
|
||||||
export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product)
|
export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product)
|
||||||
export const priceID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.price)
|
export const priceID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.price)
|
||||||
|
export const priceInr = fn(z.void(), () => Resource.ZEN_LITE_PRICE.priceInr)
|
||||||
export const firstMonth50Coupon = fn(z.void(), () => Resource.ZEN_LITE_PRICE.firstMonth50Coupon)
|
export const firstMonth50Coupon = fn(z.void(), () => Resource.ZEN_LITE_PRICE.firstMonth50Coupon)
|
||||||
export const planName = fn(z.void(), () => "lite")
|
export const planName = fn(z.void(), () => "lite")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export const PaymentTable = mysqlTable(
|
|||||||
enrichment: json("enrichment").$type<
|
enrichment: json("enrichment").$type<
|
||||||
| {
|
| {
|
||||||
type: "subscription" | "lite"
|
type: "subscription" | "lite"
|
||||||
|
currency?: "inr"
|
||||||
couponID?: string
|
couponID?: string
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|||||||
Vendored
+1
@@ -145,6 +145,7 @@ declare module "sst" {
|
|||||||
"ZEN_LITE_PRICE": {
|
"ZEN_LITE_PRICE": {
|
||||||
"firstMonth50Coupon": string
|
"firstMonth50Coupon": string
|
||||||
"price": string
|
"price": string
|
||||||
|
"priceInr": number
|
||||||
"product": string
|
"product": string
|
||||||
"type": "sst.sst.Linkable"
|
"type": "sst.sst.Linkable"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -145,6 +145,7 @@ declare module "sst" {
|
|||||||
"ZEN_LITE_PRICE": {
|
"ZEN_LITE_PRICE": {
|
||||||
"firstMonth50Coupon": string
|
"firstMonth50Coupon": string
|
||||||
"price": string
|
"price": string
|
||||||
|
"priceInr": number
|
||||||
"product": string
|
"product": string
|
||||||
"type": "sst.sst.Linkable"
|
"type": "sst.sst.Linkable"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -145,6 +145,7 @@ declare module "sst" {
|
|||||||
"ZEN_LITE_PRICE": {
|
"ZEN_LITE_PRICE": {
|
||||||
"firstMonth50Coupon": string
|
"firstMonth50Coupon": string
|
||||||
"price": string
|
"price": string
|
||||||
|
"priceInr": number
|
||||||
"product": string
|
"product": string
|
||||||
"type": "sst.sst.Linkable"
|
"type": "sst.sst.Linkable"
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
@@ -145,6 +145,7 @@ declare module "sst" {
|
|||||||
"ZEN_LITE_PRICE": {
|
"ZEN_LITE_PRICE": {
|
||||||
"firstMonth50Coupon": string
|
"firstMonth50Coupon": string
|
||||||
"price": string
|
"price": string
|
||||||
|
"priceInr": number
|
||||||
"product": string
|
"product": string
|
||||||
"type": "sst.sst.Linkable"
|
"type": "sst.sst.Linkable"
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
@@ -145,6 +145,7 @@ declare module "sst" {
|
|||||||
"ZEN_LITE_PRICE": {
|
"ZEN_LITE_PRICE": {
|
||||||
"firstMonth50Coupon": string
|
"firstMonth50Coupon": string
|
||||||
"price": string
|
"price": string
|
||||||
|
"priceInr": number
|
||||||
"product": string
|
"product": string
|
||||||
"type": "sst.sst.Linkable"
|
"type": "sst.sst.Linkable"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,13 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
"./*": "./src/*.ts"
|
"./*": "./src/*.ts"
|
||||||
},
|
},
|
||||||
|
"imports": {
|
||||||
|
"#db": {
|
||||||
|
"bun": "./src/storage/db.bun.ts",
|
||||||
|
"node": "./src/storage/db.node.ts",
|
||||||
|
"default": "./src/storage/db.bun.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.28.4",
|
"@babel/core": "7.28.4",
|
||||||
"@effect/language-service": "0.79.0",
|
"@effect/language-service": "0.79.0",
|
||||||
@@ -50,8 +57,8 @@
|
|||||||
"@types/which": "3.0.4",
|
"@types/which": "3.0.4",
|
||||||
"@types/yargs": "17.0.33",
|
"@types/yargs": "17.0.33",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
"drizzle-kit": "catalog:",
|
||||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
"drizzle-orm": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vscode-languageserver-types": "3.17.5",
|
"vscode-languageserver-types": "3.17.5",
|
||||||
"why-is-node-running": "3.2.2",
|
"why-is-node-running": "3.2.2",
|
||||||
@@ -82,6 +89,7 @@
|
|||||||
"@ai-sdk/xai": "2.0.51",
|
"@ai-sdk/xai": "2.0.51",
|
||||||
"@aws-sdk/credential-providers": "3.993.0",
|
"@aws-sdk/credential-providers": "3.993.0",
|
||||||
"@clack/prompts": "1.0.0-alpha.1",
|
"@clack/prompts": "1.0.0-alpha.1",
|
||||||
|
"@effect/platform-node": "catalog:",
|
||||||
"@gitlab/gitlab-ai-provider": "3.6.0",
|
"@gitlab/gitlab-ai-provider": "3.6.0",
|
||||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||||
"@hono/standard-validator": "0.1.5",
|
"@hono/standard-validator": "0.1.5",
|
||||||
@@ -97,7 +105,6 @@
|
|||||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||||
"@opentui/core": "0.1.87",
|
"@opentui/core": "0.1.87",
|
||||||
"@opentui/solid": "0.1.87",
|
"@opentui/solid": "0.1.87",
|
||||||
"@effect/platform-node": "catalog:",
|
|
||||||
"@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",
|
||||||
@@ -113,7 +120,7 @@
|
|||||||
"cross-spawn": "^7.0.6",
|
"cross-spawn": "^7.0.6",
|
||||||
"decimal.js": "10.5.0",
|
"decimal.js": "10.5.0",
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"glob": "13.0.5",
|
"glob": "13.0.5",
|
||||||
@@ -144,6 +151,6 @@
|
|||||||
"zod-to-json-schema": "3.24.5"
|
"zod-to-json-schema": "3.24.5"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"drizzle-orm": "1.0.0-beta.16-ea816b6"
|
"drizzle-orm": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
import fs from "fs"
|
||||||
|
import path from "path"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
const dir = path.resolve(__dirname, "..")
|
||||||
|
|
||||||
|
process.chdir(dir)
|
||||||
|
|
||||||
|
// Load migrations from migration directories
|
||||||
|
const migrationDirs = (
|
||||||
|
await fs.promises.readdir(path.join(dir, "migration"), {
|
||||||
|
withFileTypes: true,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.filter((entry) => entry.isDirectory() && /^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}/.test(entry.name))
|
||||||
|
.map((entry) => entry.name)
|
||||||
|
.sort()
|
||||||
|
|
||||||
|
const migrations = await Promise.all(
|
||||||
|
migrationDirs.map(async (name) => {
|
||||||
|
const file = path.join(dir, "migration", name, "migration.sql")
|
||||||
|
const sql = await Bun.file(file).text()
|
||||||
|
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(name)
|
||||||
|
const timestamp = match
|
||||||
|
? Date.UTC(
|
||||||
|
Number(match[1]),
|
||||||
|
Number(match[2]) - 1,
|
||||||
|
Number(match[3]),
|
||||||
|
Number(match[4]),
|
||||||
|
Number(match[5]),
|
||||||
|
Number(match[6]),
|
||||||
|
)
|
||||||
|
: 0
|
||||||
|
return { sql, timestamp, name }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
console.log(`Loaded ${migrations.length} migrations`)
|
||||||
|
|
||||||
|
await Bun.build({
|
||||||
|
target: "node",
|
||||||
|
entrypoints: ["./src/node.ts"],
|
||||||
|
outdir: "./dist",
|
||||||
|
format: "esm",
|
||||||
|
external: ["jsonc-parser"],
|
||||||
|
define: {
|
||||||
|
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log("Build complete")
|
||||||
@@ -148,6 +148,12 @@ export namespace AccountEffect {
|
|||||||
mapAccountServiceError("HTTP request failed"),
|
mapAccountServiceError("HTTP request failed"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
||||||
|
request.pipe(
|
||||||
|
Effect.flatMap((req) => http.execute(req)),
|
||||||
|
mapAccountServiceError("HTTP request failed"),
|
||||||
|
)
|
||||||
|
|
||||||
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
|
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
|
||||||
const now = yield* Clock.currentTimeMillis
|
const now = yield* Clock.currentTimeMillis
|
||||||
if (row.token_expiry && row.token_expiry > now) return row.access_token
|
if (row.token_expiry && row.token_expiry > now) return row.access_token
|
||||||
@@ -290,7 +296,7 @@ export namespace AccountEffect {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const poll = Effect.fn("Account.poll")(function* (input: Login) {
|
const poll = Effect.fn("Account.poll")(function* (input: Login) {
|
||||||
const response = yield* executeEffectOk(
|
const response = yield* executeEffect(
|
||||||
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
|
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
|
||||||
HttpClientRequest.acceptJson,
|
HttpClientRequest.acceptJson,
|
||||||
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
|
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
|
||||||
|
|||||||
@@ -260,7 +260,10 @@ export namespace Agent {
|
|||||||
return pipe(
|
return pipe(
|
||||||
await state(),
|
await state(),
|
||||||
values(),
|
values(),
|
||||||
sortBy([(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"]),
|
sortBy(
|
||||||
|
[(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"],
|
||||||
|
[(x) => x.name, "asc"],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ export namespace Bus {
|
|||||||
})
|
})
|
||||||
const pending = []
|
const pending = []
|
||||||
for (const key of [def.type, "*"]) {
|
for (const key of [def.type, "*"]) {
|
||||||
const match = state().subscriptions.get(key)
|
const match = [...(state().subscriptions.get(key) ?? [])]
|
||||||
for (const sub of match ?? []) {
|
for (const sub of match) {
|
||||||
pending.push(sub(payload))
|
pending.push(sub(payload))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useToast } from "../ui/toast"
|
|||||||
import { useKeybind } from "../context/keybind"
|
import { useKeybind } from "../context/keybind"
|
||||||
import { DialogSessionList } from "./workspace/dialog-session-list"
|
import { DialogSessionList } from "./workspace/dialog-session-list"
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||||
|
import { setTimeout as sleep } from "node:timers/promises"
|
||||||
|
|
||||||
async function openWorkspace(input: {
|
async function openWorkspace(input: {
|
||||||
dialog: ReturnType<typeof useDialog>
|
dialog: ReturnType<typeof useDialog>
|
||||||
@@ -56,7 +57,7 @@ async function openWorkspace(input: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (result.response.status >= 500 && result.response.status < 600) {
|
if (result.response.status >= 500 && result.response.status < 600) {
|
||||||
await Bun.sleep(1000)
|
await sleep(1000)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (!result.data) {
|
if (!result.data) {
|
||||||
|
|||||||
@@ -907,12 +907,12 @@ export function Session() {
|
|||||||
const filename = options.filename.trim()
|
const filename = options.filename.trim()
|
||||||
const filepath = path.join(exportDir, filename)
|
const filepath = path.join(exportDir, filename)
|
||||||
|
|
||||||
await Bun.write(filepath, transcript)
|
await Filesystem.write(filepath, transcript)
|
||||||
|
|
||||||
// Open with EDITOR if available
|
// Open with EDITOR if available
|
||||||
const result = await Editor.open({ value: transcript, renderer })
|
const result = await Editor.open({ value: transcript, renderer })
|
||||||
if (result !== undefined) {
|
if (result !== undefined) {
|
||||||
await Bun.write(filepath, result)
|
await Filesystem.write(filepath, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.show({ message: `Session exported to ${filename}`, variant: "success" })
|
toast.show({ message: `Session exported to ${filename}`, variant: "success" })
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { upgrade } from "@/cli/upgrade"
|
|||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { GlobalBus } from "@/bus/global"
|
import { GlobalBus } from "@/bus/global"
|
||||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
|
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
|
||||||
import type { BunWebSocketData } from "hono/bun"
|
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { setTimeout as sleep } from "node:timers/promises"
|
import { setTimeout as sleep } from "node:timers/promises"
|
||||||
|
|
||||||
@@ -38,7 +37,7 @@ GlobalBus.on("event", (event) => {
|
|||||||
Rpc.emit("global.event", event)
|
Rpc.emit("global.event", event)
|
||||||
})
|
})
|
||||||
|
|
||||||
let server: Bun.Server<BunWebSocketData> | undefined
|
let server: Awaited<ReturnType<typeof Server.listen>> | undefined
|
||||||
|
|
||||||
const eventStream = {
|
const eventStream = {
|
||||||
abort: undefined as AbortController | undefined,
|
abort: undefined as AbortController | undefined,
|
||||||
@@ -120,7 +119,7 @@ export const rpc = {
|
|||||||
},
|
},
|
||||||
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
|
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
|
||||||
if (server) await server.stop(true)
|
if (server) await server.stop(true)
|
||||||
server = Server.listen(input)
|
server = await Server.listen(input)
|
||||||
return { url: server.url.toString() }
|
return { url: server.url.toString() }
|
||||||
},
|
},
|
||||||
async checkUpgrade(input: { directory: string }) {
|
async checkUpgrade(input: { directory: string }) {
|
||||||
@@ -143,7 +142,7 @@ export const rpc = {
|
|||||||
Log.Default.info("worker shutting down")
|
Log.Default.info("worker shutting down")
|
||||||
if (eventStream.abort) eventStream.abort.abort()
|
if (eventStream.abort) eventStream.abort.abort()
|
||||||
await Instance.disposeAll()
|
await Instance.disposeAll()
|
||||||
if (server) server.stop(true)
|
if (server) await server.stop(true)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
import { setTimeout as sleep } from "node:timers/promises"
|
||||||
import { fn } from "@/util/fn"
|
import { fn } from "@/util/fn"
|
||||||
import { Database, eq } from "@/storage/db"
|
import { Database, eq } from "@/storage/db"
|
||||||
import { Project } from "@/project/project"
|
import { Project } from "@/project/project"
|
||||||
@@ -117,7 +118,7 @@ export namespace Workspace {
|
|||||||
const adaptor = await getAdaptor(space.type)
|
const adaptor = await getAdaptor(space.type)
|
||||||
const res = await adaptor.fetch(space, "/event", { method: "GET", signal: stop }).catch(() => undefined)
|
const res = await adaptor.fetch(space, "/event", { method: "GET", signal: stop }).catch(() => undefined)
|
||||||
if (!res || !res.ok || !res.body) {
|
if (!res || !res.ok || !res.body) {
|
||||||
await Bun.sleep(1000)
|
await sleep(1000)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
await parseSSE(res.body, stop, (event) => {
|
await parseSSE(res.body, stop, (event) => {
|
||||||
@@ -127,7 +128,7 @@ export namespace Workspace {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
// Wait 250ms and retry if SSE connection fails
|
// Wait 250ms and retry if SSE connection fails
|
||||||
await Bun.sleep(250)
|
await sleep(250)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export namespace Global {
|
|||||||
return process.env.OPENCODE_TEST_HOME || os.homedir()
|
return process.env.OPENCODE_TEST_HOME || os.homedir()
|
||||||
},
|
},
|
||||||
data,
|
data,
|
||||||
bin: path.join(data, "bin"),
|
bin: path.join(cache, "bin"),
|
||||||
log: path.join(data, "log"),
|
log: path.join(data, "log"),
|
||||||
cache,
|
cache,
|
||||||
config,
|
config,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "@modelcontextprotocol/sdk/types.js"
|
} from "@modelcontextprotocol/sdk/types.js"
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
|
import { Process } from "../util/process"
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
import z from "zod/v4"
|
import z from "zod/v4"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
@@ -166,14 +167,10 @@ export namespace MCP {
|
|||||||
const queue = [pid]
|
const queue = [pid]
|
||||||
while (queue.length > 0) {
|
while (queue.length > 0) {
|
||||||
const current = queue.shift()!
|
const current = queue.shift()!
|
||||||
const proc = Bun.spawn(["pgrep", "-P", String(current)], { stdout: "pipe", stderr: "pipe" })
|
const lines = await Process.lines(["pgrep", "-P", String(current)], { nothrow: true })
|
||||||
const [code, out] = await Promise.all([proc.exited, new Response(proc.stdout).text()]).catch(
|
for (const tok of lines) {
|
||||||
() => [-1, ""] as const,
|
|
||||||
)
|
|
||||||
if (code !== 0) continue
|
|
||||||
for (const tok of out.trim().split(/\s+/)) {
|
|
||||||
const cpid = parseInt(tok, 10)
|
const cpid = parseInt(tok, 10)
|
||||||
if (!isNaN(cpid) && pids.indexOf(cpid) === -1) {
|
if (!isNaN(cpid) && !pids.includes(cpid)) {
|
||||||
pids.push(cpid)
|
pids.push(cpid)
|
||||||
queue.push(cpid)
|
queue.push(cpid)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { Server } from "./server/server"
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Wildcard } from "@/util/wildcard"
|
||||||
|
|
||||||
|
type Rule = {
|
||||||
|
permission: string
|
||||||
|
pattern: string
|
||||||
|
action: "allow" | "deny" | "ask"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluate(permission: string, pattern: string, ...rulesets: Rule[][]): Rule {
|
||||||
|
const rules = rulesets.flat()
|
||||||
|
const match = rules.findLast(
|
||||||
|
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
|
||||||
|
)
|
||||||
|
return match ?? { action: "ask", permission, pattern: "*" }
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { Wildcard } from "@/util/wildcard"
|
|||||||
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
import { evaluate as evalRule } from "./evaluate"
|
||||||
import { PermissionID } from "./schema"
|
import { PermissionID } from "./schema"
|
||||||
|
|
||||||
export namespace PermissionNext {
|
export namespace PermissionNext {
|
||||||
@@ -125,12 +126,8 @@ export namespace PermissionNext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
||||||
const rules = rulesets.flat()
|
log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
|
||||||
log.info("evaluate", { permission, pattern, ruleset: rules })
|
return evalRule(permission, pattern, ...rulesets)
|
||||||
const match = rules.findLast(
|
|
||||||
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
|
|
||||||
)
|
|
||||||
return match ?? { action: "ask", permission, pattern: "*" }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/PermissionNext") {}
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/PermissionNext") {}
|
||||||
|
|||||||
@@ -47,8 +47,6 @@ import { ProviderTransform } from "./transform"
|
|||||||
import { Installation } from "../installation"
|
import { Installation } from "../installation"
|
||||||
import { ModelID, ProviderID } from "./schema"
|
import { ModelID, ProviderID } from "./schema"
|
||||||
|
|
||||||
const DEFAULT_CHUNK_TIMEOUT = 300_000
|
|
||||||
|
|
||||||
export namespace Provider {
|
export namespace Provider {
|
||||||
const log = Log.create({ service: "provider" })
|
const log = Log.create({ service: "provider" })
|
||||||
|
|
||||||
@@ -186,6 +184,15 @@ export namespace Provider {
|
|||||||
options: {},
|
options: {},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
xai: async () => {
|
||||||
|
return {
|
||||||
|
autoload: false,
|
||||||
|
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
|
||||||
|
return sdk.responses(modelID)
|
||||||
|
},
|
||||||
|
options: {},
|
||||||
|
}
|
||||||
|
},
|
||||||
"github-copilot": async () => {
|
"github-copilot": async () => {
|
||||||
return {
|
return {
|
||||||
autoload: false,
|
autoload: false,
|
||||||
@@ -1130,7 +1137,7 @@ export namespace Provider {
|
|||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
|
|
||||||
const customFetch = options["fetch"]
|
const customFetch = options["fetch"]
|
||||||
const chunkTimeout = options["chunkTimeout"] || DEFAULT_CHUNK_TIMEOUT
|
const chunkTimeout = options["chunkTimeout"]
|
||||||
delete options["chunkTimeout"]
|
delete options["chunkTimeout"]
|
||||||
|
|
||||||
options["fetch"] = async (input: any, init?: BunFetchRequestInit) => {
|
options["fetch"] = async (input: any, init?: BunFetchRequestInit) => {
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Hono } from "hono"
|
||||||
|
import { describeRoute, resolver } from "hono-openapi"
|
||||||
|
import { streamSSE } from "hono/streaming"
|
||||||
|
import { Log } from "@/util/log"
|
||||||
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
|
import { Bus } from "@/bus"
|
||||||
|
import { lazy } from "../../util/lazy"
|
||||||
|
import { AsyncQueue } from "../../util/queue"
|
||||||
|
import { Instance } from "@/project/instance"
|
||||||
|
|
||||||
|
const log = Log.create({ service: "server" })
|
||||||
|
|
||||||
|
export const EventRoutes = lazy(() =>
|
||||||
|
new Hono().get(
|
||||||
|
"/event",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Subscribe to events",
|
||||||
|
description: "Get events",
|
||||||
|
operationId: "event.subscribe",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Event stream",
|
||||||
|
content: {
|
||||||
|
"text/event-stream": {
|
||||||
|
schema: resolver(BusEvent.payloads()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
log.info("event connected")
|
||||||
|
c.header("X-Accel-Buffering", "no")
|
||||||
|
c.header("X-Content-Type-Options", "nosniff")
|
||||||
|
return streamSSE(c, async (stream) => {
|
||||||
|
const q = new AsyncQueue<string | null>()
|
||||||
|
let done = false
|
||||||
|
|
||||||
|
q.push(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "server.connected",
|
||||||
|
properties: {},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send heartbeat every 10s to prevent stalled proxy streams.
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
q.push(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "server.heartbeat",
|
||||||
|
properties: {},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}, 10_000)
|
||||||
|
|
||||||
|
const unsub = Bus.subscribeAll((event) => {
|
||||||
|
q.push(JSON.stringify(event))
|
||||||
|
if (event.type === Bus.InstanceDisposed.type) {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (done) return
|
||||||
|
done = true
|
||||||
|
clearInterval(heartbeat)
|
||||||
|
unsub()
|
||||||
|
q.push(null)
|
||||||
|
log.info("event disconnected")
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.onAbort(stop)
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const data of q) {
|
||||||
|
if (data === null) return
|
||||||
|
await stream.writeSSE({ data })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -4,6 +4,7 @@ import { streamSSE } from "hono/streaming"
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { GlobalBus } from "@/bus/global"
|
import { GlobalBus } from "@/bus/global"
|
||||||
|
import { AsyncQueue } from "@/util/queue"
|
||||||
import { Instance } from "../../project/instance"
|
import { Instance } from "../../project/instance"
|
||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
import { Log } from "../../util/log"
|
import { Log } from "../../util/log"
|
||||||
@@ -69,41 +70,54 @@ export const GlobalRoutes = lazy(() =>
|
|||||||
c.header("X-Accel-Buffering", "no")
|
c.header("X-Accel-Buffering", "no")
|
||||||
c.header("X-Content-Type-Options", "nosniff")
|
c.header("X-Content-Type-Options", "nosniff")
|
||||||
return streamSSE(c, async (stream) => {
|
return streamSSE(c, async (stream) => {
|
||||||
stream.writeSSE({
|
const q = new AsyncQueue<string | null>()
|
||||||
data: JSON.stringify({
|
let done = false
|
||||||
|
|
||||||
|
q.push(
|
||||||
|
JSON.stringify({
|
||||||
payload: {
|
payload: {
|
||||||
type: "server.connected",
|
type: "server.connected",
|
||||||
properties: {},
|
properties: {},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
})
|
)
|
||||||
async function handler(event: any) {
|
|
||||||
await stream.writeSSE({
|
|
||||||
data: JSON.stringify(event),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
GlobalBus.on("event", handler)
|
|
||||||
|
|
||||||
// Send heartbeat every 10s to prevent stalled proxy streams.
|
// Send heartbeat every 10s to prevent stalled proxy streams.
|
||||||
const heartbeat = setInterval(() => {
|
const heartbeat = setInterval(() => {
|
||||||
stream.writeSSE({
|
q.push(
|
||||||
data: JSON.stringify({
|
JSON.stringify({
|
||||||
payload: {
|
payload: {
|
||||||
type: "server.heartbeat",
|
type: "server.heartbeat",
|
||||||
properties: {},
|
properties: {},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
})
|
)
|
||||||
}, 10_000)
|
}, 10_000)
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
async function handler(event: any) {
|
||||||
stream.onAbort(() => {
|
q.push(JSON.stringify(event))
|
||||||
clearInterval(heartbeat)
|
}
|
||||||
GlobalBus.off("event", handler)
|
GlobalBus.on("event", handler)
|
||||||
resolve()
|
|
||||||
log.info("global event disconnected")
|
const stop = () => {
|
||||||
})
|
if (done) return
|
||||||
})
|
done = true
|
||||||
|
clearInterval(heartbeat)
|
||||||
|
GlobalBus.off("event", handler)
|
||||||
|
q.push(null)
|
||||||
|
log.info("event disconnected")
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.onAbort(stop)
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const data of q) {
|
||||||
|
if (data === null) return
|
||||||
|
await stream.writeSSE({ data })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const ProjectRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const projects = await Project.list()
|
const projects = Project.list()
|
||||||
return c.json(projects)
|
return c.json(projects)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
|
||||||
import { Bus } from "@/bus"
|
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
|
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { cors } from "hono/cors"
|
import { cors } from "hono/cors"
|
||||||
import { streamSSE } from "hono/streaming"
|
|
||||||
import { proxy } from "hono/proxy"
|
import { proxy } from "hono/proxy"
|
||||||
import { basicAuth } from "hono/basic-auth"
|
import { basicAuth } from "hono/basic-auth"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
@@ -34,6 +31,7 @@ import { FileRoutes } from "./routes/file"
|
|||||||
import { ConfigRoutes } from "./routes/config"
|
import { ConfigRoutes } from "./routes/config"
|
||||||
import { ExperimentalRoutes } from "./routes/experimental"
|
import { ExperimentalRoutes } from "./routes/experimental"
|
||||||
import { ProviderRoutes } from "./routes/provider"
|
import { ProviderRoutes } from "./routes/provider"
|
||||||
|
import { EventRoutes } from "./routes/event"
|
||||||
import { InstanceBootstrap } from "../project/bootstrap"
|
import { InstanceBootstrap } from "../project/bootstrap"
|
||||||
import { NotFoundError } from "../storage/db"
|
import { NotFoundError } from "../storage/db"
|
||||||
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
||||||
@@ -251,6 +249,7 @@ export namespace Server {
|
|||||||
.route("/question", QuestionRoutes())
|
.route("/question", QuestionRoutes())
|
||||||
.route("/provider", ProviderRoutes())
|
.route("/provider", ProviderRoutes())
|
||||||
.route("/", FileRoutes())
|
.route("/", FileRoutes())
|
||||||
|
.route("/", EventRoutes())
|
||||||
.route("/mcp", McpRoutes())
|
.route("/mcp", McpRoutes())
|
||||||
.route("/tui", TuiRoutes())
|
.route("/tui", TuiRoutes())
|
||||||
.post(
|
.post(
|
||||||
@@ -498,64 +497,6 @@ export namespace Server {
|
|||||||
return c.json(await Format.status())
|
return c.json(await Format.status())
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.get(
|
|
||||||
"/event",
|
|
||||||
describeRoute({
|
|
||||||
summary: "Subscribe to events",
|
|
||||||
description: "Get events",
|
|
||||||
operationId: "event.subscribe",
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Event stream",
|
|
||||||
content: {
|
|
||||||
"text/event-stream": {
|
|
||||||
schema: resolver(BusEvent.payloads()),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
async (c) => {
|
|
||||||
log.info("event connected")
|
|
||||||
c.header("X-Accel-Buffering", "no")
|
|
||||||
c.header("X-Content-Type-Options", "nosniff")
|
|
||||||
return streamSSE(c, async (stream) => {
|
|
||||||
stream.writeSSE({
|
|
||||||
data: JSON.stringify({
|
|
||||||
type: "server.connected",
|
|
||||||
properties: {},
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
const unsub = Bus.subscribeAll(async (event) => {
|
|
||||||
await stream.writeSSE({
|
|
||||||
data: JSON.stringify(event),
|
|
||||||
})
|
|
||||||
if (event.type === Bus.InstanceDisposed.type) {
|
|
||||||
stream.close()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Send heartbeat every 10s to prevent stalled proxy streams.
|
|
||||||
const heartbeat = setInterval(() => {
|
|
||||||
stream.writeSSE({
|
|
||||||
data: JSON.stringify({
|
|
||||||
type: "server.heartbeat",
|
|
||||||
properties: {},
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}, 10_000)
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
stream.onAbort(() => {
|
|
||||||
clearInterval(heartbeat)
|
|
||||||
unsub()
|
|
||||||
resolve()
|
|
||||||
log.info("event disconnected")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.all("/*", async (c) => {
|
.all("/*", async (c) => {
|
||||||
const path = c.req.path
|
const path = c.req.path
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user