Merge branch 'dev' into fix/daytona-plugin-link-15976
This commit is contained in:
commit
86a2379af2
667 changed files with 28833 additions and 7917 deletions
41
.github/workflows/docs-locale-sync.yml
vendored
41
.github/workflows/docs-locale-sync.yml
vendored
|
|
@ -59,43 +59,10 @@ jobs:
|
||||||
{
|
{
|
||||||
"permission": {
|
"permission": {
|
||||||
"*": "deny",
|
"*": "deny",
|
||||||
"read": {
|
"read": "allow",
|
||||||
"*": "deny",
|
"edit": "allow",
|
||||||
"packages/web/src/content/docs": "allow",
|
"glob": "allow",
|
||||||
"packages/web/src/content/docs/*": "allow",
|
"task": "allow"
|
||||||
"packages/web/src/content/docs/*.mdx": "allow",
|
|
||||||
"packages/web/src/content/docs/*/*.mdx": "allow",
|
|
||||||
".opencode": "allow",
|
|
||||||
".opencode/agent": "allow",
|
|
||||||
".opencode/glossary": "allow",
|
|
||||||
".opencode/agent/translator.md": "allow",
|
|
||||||
".opencode/glossary/*.md": "allow"
|
|
||||||
},
|
|
||||||
"edit": {
|
|
||||||
"*": "deny",
|
|
||||||
"packages/web/src/content/docs/*/*.mdx": "allow"
|
|
||||||
},
|
|
||||||
"glob": {
|
|
||||||
"*": "deny",
|
|
||||||
"packages/web/src/content/docs*": "allow",
|
|
||||||
".opencode/glossary*": "allow"
|
|
||||||
},
|
|
||||||
"task": {
|
|
||||||
"*": "deny",
|
|
||||||
"translator": "allow"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"agent": {
|
|
||||||
"translator": {
|
|
||||||
"permission": {
|
|
||||||
"*": "deny",
|
|
||||||
"read": {
|
|
||||||
"*": "deny",
|
|
||||||
".opencode/agent/translator.md": "allow",
|
|
||||||
".opencode/glossary/*.md": "allow"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
127
.github/workflows/publish.yml
vendored
127
.github/workflows/publish.yml
vendored
|
|
@ -99,7 +99,6 @@ jobs:
|
||||||
with:
|
with:
|
||||||
name: opencode-cli
|
name: opencode-cli
|
||||||
path: packages/opencode/dist
|
path: packages/opencode/dist
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ needs.version.outputs.version }}
|
version: ${{ needs.version.outputs.version }}
|
||||||
|
|
||||||
|
|
@ -240,11 +239,130 @@ jobs:
|
||||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||||
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
|
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
|
||||||
|
|
||||||
|
build-electron:
|
||||||
|
needs:
|
||||||
|
- build-cli
|
||||||
|
- version
|
||||||
|
continue-on-error: false
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
settings:
|
||||||
|
- host: macos-latest
|
||||||
|
target: x86_64-apple-darwin
|
||||||
|
platform_flag: --mac --x64
|
||||||
|
- host: macos-latest
|
||||||
|
target: aarch64-apple-darwin
|
||||||
|
platform_flag: --mac --arm64
|
||||||
|
- host: "blacksmith-4vcpu-windows-2025"
|
||||||
|
target: x86_64-pc-windows-msvc
|
||||||
|
platform_flag: --win
|
||||||
|
- host: "blacksmith-4vcpu-ubuntu-2404"
|
||||||
|
target: x86_64-unknown-linux-gnu
|
||||||
|
platform_flag: --linux
|
||||||
|
- host: "blacksmith-4vcpu-ubuntu-2404"
|
||||||
|
target: aarch64-unknown-linux-gnu
|
||||||
|
platform_flag: --linux
|
||||||
|
runs-on: ${{ matrix.settings.host }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- uses: apple-actions/import-codesign-certs@v2
|
||||||
|
if: runner.os == 'macOS'
|
||||||
|
with:
|
||||||
|
keychain: build
|
||||||
|
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
|
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Setup Apple API Key
|
||||||
|
if: runner.os == 'macOS'
|
||||||
|
run: echo "${{ secrets.APPLE_API_KEY_PATH }}" > $RUNNER_TEMP/apple-api-key.p8
|
||||||
|
|
||||||
|
- uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
|
||||||
|
- name: Cache apt packages
|
||||||
|
if: contains(matrix.settings.host, 'ubuntu')
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/apt-cache
|
||||||
|
key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-electron-${{ hashFiles('.github/workflows/publish.yml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-${{ matrix.settings.target }}-apt-electron-
|
||||||
|
|
||||||
|
- name: Install dependencies (ubuntu only)
|
||||||
|
if: contains(matrix.settings.host, 'ubuntu')
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/apt-cache && chmod -R a+rw ~/apt-cache
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends -o dir::cache::archives="$HOME/apt-cache" rpm
|
||||||
|
sudo chmod -R a+rw ~/apt-cache
|
||||||
|
|
||||||
|
- name: Setup git committer
|
||||||
|
id: committer
|
||||||
|
uses: ./.github/actions/setup-git-committer
|
||||||
|
with:
|
||||||
|
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||||
|
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||||
|
|
||||||
|
- name: Prepare
|
||||||
|
run: bun ./scripts/prepare.ts
|
||||||
|
working-directory: packages/desktop-electron
|
||||||
|
env:
|
||||||
|
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||||
|
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||||
|
RUST_TARGET: ${{ matrix.settings.target }}
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: bun run build
|
||||||
|
working-directory: packages/desktop-electron
|
||||||
|
env:
|
||||||
|
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||||
|
|
||||||
|
- name: Package and publish
|
||||||
|
if: needs.version.outputs.release
|
||||||
|
run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish always --config electron-builder.config.ts
|
||||||
|
working-directory: packages/desktop-electron
|
||||||
|
timeout-minutes: 60
|
||||||
|
env:
|
||||||
|
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||||
|
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||||
|
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
|
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8
|
||||||
|
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
|
||||||
|
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||||
|
|
||||||
|
- name: Package (no publish)
|
||||||
|
if: ${{ !needs.version.outputs.release }}
|
||||||
|
run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish never --config electron-builder.config.ts
|
||||||
|
working-directory: packages/desktop-electron
|
||||||
|
timeout-minutes: 60
|
||||||
|
env:
|
||||||
|
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: opencode-electron-${{ matrix.settings.target }}
|
||||||
|
path: packages/desktop-electron/dist/*
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: needs.version.outputs.release
|
||||||
|
with:
|
||||||
|
name: latest-yml-${{ matrix.settings.target }}
|
||||||
|
path: packages/desktop-electron/dist/latest*.yml
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
needs:
|
needs:
|
||||||
- version
|
- version
|
||||||
- build-cli
|
- build-cli
|
||||||
- build-tauri
|
- build-tauri
|
||||||
|
- build-electron
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
@ -281,6 +399,12 @@ jobs:
|
||||||
name: opencode-cli
|
name: opencode-cli
|
||||||
path: packages/opencode/dist
|
path: packages/opencode/dist
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
if: needs.version.outputs.release
|
||||||
|
with:
|
||||||
|
pattern: latest-yml-*
|
||||||
|
path: /tmp/latest-yml
|
||||||
|
|
||||||
- name: Cache apt packages (AUR)
|
- name: Cache apt packages (AUR)
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
|
|
@ -308,3 +432,4 @@ jobs:
|
||||||
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
||||||
GH_REPO: ${{ needs.version.outputs.repo }}
|
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||||
NPM_CONFIG_PROVENANCE: false
|
NPM_CONFIG_PROVENANCE: false
|
||||||
|
LATEST_YML_DIR: /tmp/latest-yml
|
||||||
|
|
|
||||||
38
.opencode/glossary/tr.md
Normal file
38
.opencode/glossary/tr.md
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# tr Glossary
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- PR #15835: https://github.com/anomalyco/opencode/pull/15835
|
||||||
|
|
||||||
|
## Do Not Translate (Locale Additions)
|
||||||
|
|
||||||
|
- `OpenCode` (preserve casing in prose, docs, and UI copy)
|
||||||
|
- Keep lowercase `opencode` in commands, package names, paths, URLs, and other exact identifiers
|
||||||
|
- `<TAB>` stays the literal key token in code blocks; use `Tab` for the nearby explanatory label in prose
|
||||||
|
- Commands, flags, file paths, and code literals (keep exactly as written)
|
||||||
|
|
||||||
|
## Preferred Terms
|
||||||
|
|
||||||
|
These are PR-backed wording preferences and may evolve.
|
||||||
|
|
||||||
|
| English / Context | Preferred | Notes |
|
||||||
|
| ------------------------- | --------------------------------------- | ------------------------------------------------------------- |
|
||||||
|
| available in beta | `beta olarak mevcut` | Prefer this over `beta olarak kullanılabilir` |
|
||||||
|
| privacy-first | `Gizlilik öncelikli tasarlandı` | Prefer this over `Önce gizlilik için tasarlandı` |
|
||||||
|
| connect your local models | `yerel modellerinizi bağlayabilirsiniz` | Use the fuller, more direct action phrase |
|
||||||
|
| `<TAB>` key label | `Tab` | Use `Tab` in prose; keep `<TAB>` in literal UI or code blocks |
|
||||||
|
| cross-platform | `cross-platform (tüm platformlarda)` | Keep the English term, add a short clarification when helpful |
|
||||||
|
|
||||||
|
## Guidance
|
||||||
|
|
||||||
|
- Prefer natural Turkish phrasing over literal translation
|
||||||
|
- Merge broken sentence fragments into one clear sentence when the source is a single thought
|
||||||
|
- Keep product naming consistent: `OpenCode` in prose, `opencode` only for exact technical identifiers
|
||||||
|
- When an English technical term is intentionally kept, add a short Turkish clarification only if it improves readability
|
||||||
|
|
||||||
|
## Avoid
|
||||||
|
|
||||||
|
- Avoid `beta olarak kullanılabilir` when `beta olarak mevcut` fits
|
||||||
|
- Avoid `Önce gizlilik için tasarlandı`; use the more natural reviewed wording instead
|
||||||
|
- Avoid `Sekme` for the translated key label in prose when referring to `<TAB>`
|
||||||
|
- Avoid changing `opencode` to `OpenCode` inside commands, URLs, package names, or code literals
|
||||||
11
AGENTS.md
11
AGENTS.md
|
|
@ -20,6 +20,17 @@
|
||||||
|
|
||||||
Prefer single word names for variables and functions. Only use multiple words if necessary.
|
Prefer single word names for variables and functions. Only use multiple words if necessary.
|
||||||
|
|
||||||
|
### Naming Enforcement (Read This)
|
||||||
|
|
||||||
|
THIS RULE IS MANDATORY FOR AGENT WRITTEN CODE.
|
||||||
|
|
||||||
|
- Use single word names by default for new locals, params, and helper functions.
|
||||||
|
- Multi-word names are allowed only when a single word would be unclear or ambiguous.
|
||||||
|
- Do not introduce new camelCase compounds when a short single-word alternative is clear.
|
||||||
|
- Before finishing edits, review touched lines and shorten newly introduced identifiers where possible.
|
||||||
|
- Good short names to prefer: `pid`, `cfg`, `err`, `opts`, `dir`, `root`, `child`, `state`, `timeout`.
|
||||||
|
- Examples to avoid unless truly required: `inputPID`, `existingClient`, `connectTimeout`, `workerPath`.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// Good
|
// Good
|
||||||
const foo = 1
|
const foo = 1
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
|
|
|
||||||
6
flake.lock
generated
6
flake.lock
generated
|
|
@ -2,11 +2,11 @@
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1770812194,
|
"lastModified": 1772091128,
|
||||||
"narHash": "sha256-OH+lkaIKAvPXR3nITO7iYZwew2nW9Y7Xxq0yfM/UcUU=",
|
"narHash": "sha256-TnrYykX8Mf/Ugtkix6V+PjW7miU2yClA6uqWl/v6KWM=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "8482c7ded03bae7550f3d69884f1e611e3bd19e8",
|
"rev": "3f0336406035444b4a24b942788334af5f906259",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,6 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
|
||||||
price: zenLitePrice.id,
|
price: zenLitePrice.id,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const ZEN_LITE_LIMITS = new sst.Secret("ZEN_LITE_LIMITS")
|
|
||||||
|
|
||||||
const zenBlackProduct = new stripe.Product("ZenBlack", {
|
const zenBlackProduct = new stripe.Product("ZenBlack", {
|
||||||
name: "OpenCode Black",
|
name: "OpenCode Black",
|
||||||
|
|
@ -142,7 +141,6 @@ const ZEN_BLACK_PRICE = new sst.Linkable("ZEN_BLACK_PRICE", {
|
||||||
plan20: zenBlackPrice20.id,
|
plan20: zenBlackPrice20.id,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const ZEN_BLACK_LIMITS = new sst.Secret("ZEN_BLACK_LIMITS")
|
|
||||||
|
|
||||||
const ZEN_MODELS = [
|
const ZEN_MODELS = [
|
||||||
new sst.Secret("ZEN_MODELS1"),
|
new sst.Secret("ZEN_MODELS1"),
|
||||||
|
|
@ -215,9 +213,8 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||||
AWS_SES_ACCESS_KEY_ID,
|
AWS_SES_ACCESS_KEY_ID,
|
||||||
AWS_SES_SECRET_ACCESS_KEY,
|
AWS_SES_SECRET_ACCESS_KEY,
|
||||||
ZEN_BLACK_PRICE,
|
ZEN_BLACK_PRICE,
|
||||||
ZEN_BLACK_LIMITS,
|
|
||||||
ZEN_LITE_PRICE,
|
ZEN_LITE_PRICE,
|
||||||
ZEN_LITE_LIMITS,
|
new sst.Secret("ZEN_LIMITS"),
|
||||||
new sst.Secret("ZEN_SESSION_SECRET"),
|
new sst.Secret("ZEN_SESSION_SECRET"),
|
||||||
...ZEN_MODELS,
|
...ZEN_MODELS,
|
||||||
...($dev
|
...($dev
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-dZoLhWe4smBsOF7WczMySLXSAB1YRO1vfhiOCL1rBf0=",
|
"x86_64-linux": "sha256-jtBYpfiE9g0otqZEtOksW1Nbg+O8CJP9OEOEhsa7sa8=",
|
||||||
"aarch64-linux": "sha256-J7nIz1xuVZEHun5WRZkYRySz29B0A8g5g0RRxnIWTYU=",
|
"aarch64-linux": "sha256-m+YNZIB7I7EMPyfqkKsvDvmBX9R1szmEKxXpxTNFLH8=",
|
||||||
"aarch64-darwin": "sha256-R2PuhX+EjUBuLE8MF0G0fcUwNaU+5n6V6uVeK89ulzw=",
|
"aarch64-darwin": "sha256-1gVmtkC1/I8sdHZcaeSFJheySVlpCyKCjf9zbVsVqAQ=",
|
||||||
"x86_64-darwin": "sha256-Bvzfz9TsTpYriZNLSLgpNcNb+BgtkgpjoWqdOtF2IBg="
|
"x86_64-darwin": "sha256-Tvk5YL6Z0xRul4jopbGme/997iHBylXC0Cq3RnjQb+I="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ stdenvNoCC.mkDerivation {
|
||||||
../package.json
|
../package.json
|
||||||
../patches
|
../patches
|
||||||
../install # required by desktop build (cli.rs include_str!)
|
../install # required by desktop build (cli.rs include_str!)
|
||||||
|
../.github/TEAM_MEMBERS # required by @opencode-ai/script
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||||
"dev:desktop": "bun --cwd packages/desktop tauri dev",
|
"dev:desktop": "bun --cwd packages/desktop tauri dev",
|
||||||
"dev:web": "bun --cwd packages/app dev",
|
"dev:web": "bun --cwd packages/app dev",
|
||||||
|
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||||
"typecheck": "bun turbo typecheck",
|
"typecheck": "bun turbo typecheck",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"random": "echo 'Random script'",
|
"random": "echo 'Random script'",
|
||||||
|
|
@ -35,7 +36,7 @@
|
||||||
"@tsconfig/bun": "1.0.9",
|
"@tsconfig/bun": "1.0.9",
|
||||||
"@cloudflare/workers-types": "4.20251008.0",
|
"@cloudflare/workers-types": "4.20251008.0",
|
||||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||||
"@pierre/diffs": "1.1.0-beta.13",
|
"@pierre/diffs": "1.1.0-beta.18",
|
||||||
"@solid-primitives/storage": "4.3.3",
|
"@solid-primitives/storage": "4.3.3",
|
||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
"diff": "8.0.2",
|
"diff": "8.0.2",
|
||||||
|
|
@ -98,7 +99,8 @@
|
||||||
"protobufjs",
|
"protobufjs",
|
||||||
"tree-sitter",
|
"tree-sitter",
|
||||||
"tree-sitter-bash",
|
"tree-sitter-bash",
|
||||||
"web-tree-sitter"
|
"web-tree-sitter",
|
||||||
|
"electron"
|
||||||
],
|
],
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
|
|
|
||||||
515
packages/app/create-effect-simplification-spec.md
Normal file
515
packages/app/create-effect-simplification-spec.md
Normal file
|
|
@ -0,0 +1,515 @@
|
||||||
|
# CreateEffect Simplification Implementation Spec
|
||||||
|
|
||||||
|
Reduce reactive misuse across `packages/app`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This work targets `packages/app/src`, which currently has 101 `createEffect` calls across 37 files.
|
||||||
|
|
||||||
|
The biggest clusters are `pages/session.tsx` (19), `pages/layout.tsx` (13), `pages/session/file-tabs.tsx` (6), and several context providers that mirror one store into another.
|
||||||
|
|
||||||
|
Key issues from the audit:
|
||||||
|
|
||||||
|
- Derived state is being written through effects instead of computed directly
|
||||||
|
- Session and file resets are handled by watch-and-clear effects instead of keyed state boundaries
|
||||||
|
- User-driven actions are hidden inside reactive effects
|
||||||
|
- Context layers mirror and hydrate child stores with multiple sync effects
|
||||||
|
- Several areas repeat the same imperative trigger pattern in multiple effects
|
||||||
|
|
||||||
|
Keep the implementation focused on removing unnecessary effects, not on broad UI redesign.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Cut high-churn `createEffect` usage in the hottest files first
|
||||||
|
- Replace effect-driven derived state with reactive derivation
|
||||||
|
- Replace reset-on-key effects with keyed ownership boundaries
|
||||||
|
- Move event-driven work to direct actions and write paths
|
||||||
|
- Remove mirrored store hydration where a single source of truth can exist
|
||||||
|
- Leave necessary external sync effects in place, but make them narrower and clearer
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Do not rewrite unrelated component structure just to reduce the count
|
||||||
|
- Do not change product behavior, navigation flow, or persisted data shape unless required for a cleaner write boundary
|
||||||
|
- Do not remove effects that bridge to DOM, editors, polling, or external APIs unless there is a clearly safer equivalent
|
||||||
|
- Do not attempt a repo-wide cleanup outside `packages/app`
|
||||||
|
|
||||||
|
## Effect Taxonomy And Replacement Rules
|
||||||
|
|
||||||
|
Use these rules during implementation.
|
||||||
|
|
||||||
|
### Prefer `createMemo`
|
||||||
|
|
||||||
|
Use `createMemo` when the target value is pure derived state from other signals or stores.
|
||||||
|
|
||||||
|
Do this when an effect only reads reactive inputs and writes another reactive value that could be computed instead.
|
||||||
|
|
||||||
|
Apply this to:
|
||||||
|
|
||||||
|
- `packages/app/src/pages/session.tsx:141`
|
||||||
|
- `packages/app/src/pages/layout.tsx:557`
|
||||||
|
- `packages/app/src/components/terminal.tsx:261`
|
||||||
|
- `packages/app/src/components/session/session-header.tsx:309`
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- If no external system is touched, do not use `createEffect`
|
||||||
|
- Derive once, then read the memo where needed
|
||||||
|
- If normalization is required, prefer normalizing at the write boundary before falling back to a memo
|
||||||
|
|
||||||
|
### Prefer Keyed Remounts
|
||||||
|
|
||||||
|
Use keyed remounts when local UI state should reset because an identity changed.
|
||||||
|
|
||||||
|
Do this with `sessionKey`, `scope()`, or another stable identity instead of watching the key and manually clearing signals.
|
||||||
|
|
||||||
|
Apply this to:
|
||||||
|
|
||||||
|
- `packages/app/src/pages/session.tsx:325`
|
||||||
|
- `packages/app/src/pages/session.tsx:336`
|
||||||
|
- `packages/app/src/pages/session.tsx:477`
|
||||||
|
- `packages/app/src/pages/session.tsx:869`
|
||||||
|
- `packages/app/src/pages/session.tsx:963`
|
||||||
|
- `packages/app/src/pages/session/message-timeline.tsx:149`
|
||||||
|
- `packages/app/src/context/file.tsx:100`
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- If the desired behavior is "new identity, fresh local state," key the owner subtree
|
||||||
|
- Keep state local to the keyed boundary so teardown and recreation handle the reset naturally
|
||||||
|
|
||||||
|
### Prefer Event Handlers And Actions
|
||||||
|
|
||||||
|
Use direct handlers, store actions, and async command functions when work happens because a user clicked, selected, reloaded, or navigated.
|
||||||
|
|
||||||
|
Do this when an effect is just watching for a flag change, command token, or event-bus signal to trigger imperative logic.
|
||||||
|
|
||||||
|
Apply this to:
|
||||||
|
|
||||||
|
- `packages/app/src/pages/layout.tsx:484`
|
||||||
|
- `packages/app/src/pages/layout.tsx:652`
|
||||||
|
- `packages/app/src/pages/layout.tsx:776`
|
||||||
|
- `packages/app/src/pages/layout.tsx:1489`
|
||||||
|
- `packages/app/src/pages/layout.tsx:1519`
|
||||||
|
- `packages/app/src/components/file-tree.tsx:328`
|
||||||
|
- `packages/app/src/pages/session/terminal-panel.tsx:55`
|
||||||
|
- `packages/app/src/context/global-sync.tsx:148`
|
||||||
|
- Duplicated trigger sets in:
|
||||||
|
- `packages/app/src/pages/session/review-tab.tsx:122`
|
||||||
|
- `packages/app/src/pages/session/review-tab.tsx:130`
|
||||||
|
- `packages/app/src/pages/session/review-tab.tsx:138`
|
||||||
|
- `packages/app/src/pages/session/file-tabs.tsx:367`
|
||||||
|
- `packages/app/src/pages/session/file-tabs.tsx:378`
|
||||||
|
- `packages/app/src/pages/session/file-tabs.tsx:389`
|
||||||
|
- `packages/app/src/pages/session/use-session-hash-scroll.ts:144`
|
||||||
|
- `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
|
||||||
|
- `packages/app/src/pages/session/use-session-hash-scroll.ts:167`
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- If the trigger is user intent, call the action at the source of that intent
|
||||||
|
- If the same imperative work is triggered from multiple places, extract one function and call it directly
|
||||||
|
|
||||||
|
### Prefer `onMount` And `onCleanup`
|
||||||
|
|
||||||
|
Use `onMount` and `onCleanup` for lifecycle-only setup and teardown.
|
||||||
|
|
||||||
|
This is the right fit for subscriptions, one-time wiring, timers, and imperative integration that should not rerun for ordinary reactive changes.
|
||||||
|
|
||||||
|
Use this when:
|
||||||
|
|
||||||
|
- Setup should happen once per owner lifecycle
|
||||||
|
- Cleanup should always pair with teardown
|
||||||
|
- The work is not conceptually derived state
|
||||||
|
|
||||||
|
### Keep `createEffect` When It Is A Real Bridge
|
||||||
|
|
||||||
|
Keep `createEffect` when it synchronizes reactive data to an external imperative sink.
|
||||||
|
|
||||||
|
Examples that should remain, though they may be narrowed or split:
|
||||||
|
|
||||||
|
- DOM/editor sync in `packages/app/src/components/prompt-input.tsx:690`
|
||||||
|
- Scroll sync in `packages/app/src/pages/session.tsx:685`
|
||||||
|
- Scroll/hash sync in `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
|
||||||
|
- External sync in:
|
||||||
|
- `packages/app/src/context/language.tsx:207`
|
||||||
|
- `packages/app/src/context/settings.tsx:110`
|
||||||
|
- `packages/app/src/context/sdk.tsx:26`
|
||||||
|
- Polling in:
|
||||||
|
- `packages/app/src/components/status-popover.tsx:59`
|
||||||
|
- `packages/app/src/components/dialog-select-server.tsx:273`
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Keep the effect single-purpose
|
||||||
|
- Make dependencies explicit and narrow
|
||||||
|
- Avoid writing back into the same reactive graph unless absolutely required
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Phase 0: Classification Pass
|
||||||
|
|
||||||
|
Before changing code, tag each targeted effect as one of: derive, reset, event, lifecycle, or external bridge.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Every targeted effect in this spec is tagged with a replacement strategy before refactoring starts
|
||||||
|
- Shared helpers to be introduced are identified up front to avoid repeating patterns
|
||||||
|
|
||||||
|
### Phase 1: Derived-State Cleanup
|
||||||
|
|
||||||
|
Tackle highest-value, lowest-risk derived-state cleanup first.
|
||||||
|
|
||||||
|
Priority items:
|
||||||
|
|
||||||
|
- Normalize tabs at write boundaries and remove `packages/app/src/pages/session.tsx:141`
|
||||||
|
- Stop syncing `workspaceOrder` in `packages/app/src/pages/layout.tsx:557`
|
||||||
|
- Make prompt slash filtering reactive so `packages/app/src/components/prompt-input.tsx:652` can be removed
|
||||||
|
- Replace other obvious derived-state effects in terminal and session header
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- No behavior change in tab ordering, prompt filtering, terminal display, or header state
|
||||||
|
- Targeted derived-state effects are deleted, not just moved
|
||||||
|
|
||||||
|
### Phase 2: Keyed Reset Cleanup
|
||||||
|
|
||||||
|
Replace reset-on-key effects with keyed ownership boundaries.
|
||||||
|
|
||||||
|
Priority items:
|
||||||
|
|
||||||
|
- Key session-scoped UI and state by `sessionKey`
|
||||||
|
- Key file-scoped state by `scope()`
|
||||||
|
- Remove manual clear-and-reseed effects in session and file context
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Switching session or file scope recreates the intended local state cleanly
|
||||||
|
- No stale state leaks across session or scope changes
|
||||||
|
- Target reset effects are deleted
|
||||||
|
|
||||||
|
### Phase 3: Event-Driven Work Extraction
|
||||||
|
|
||||||
|
Move event-driven work out of reactive effects.
|
||||||
|
|
||||||
|
Priority items:
|
||||||
|
|
||||||
|
- Replace `globalStore.reload` effect dispatching with direct calls
|
||||||
|
- Split mixed-responsibility effect in `packages/app/src/pages/layout.tsx:1489`
|
||||||
|
- Collapse duplicated imperative trigger triplets into single functions
|
||||||
|
- Move file-tree and terminal-panel imperative work to explicit handlers
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- User-triggered behavior still fires exactly once per intended action
|
||||||
|
- No effect remains whose only job is to notice a command-like state and trigger an imperative function
|
||||||
|
|
||||||
|
### Phase 4: Context Ownership Cleanup
|
||||||
|
|
||||||
|
Remove mirrored child-store hydration patterns.
|
||||||
|
|
||||||
|
Priority items:
|
||||||
|
|
||||||
|
- Remove child-store hydration mirrors in `packages/app/src/context/global-sync/child-store.ts:184`, `:190`, `:193`
|
||||||
|
- Simplify mirror logic in `packages/app/src/context/global-sync.tsx:130`, `:138`
|
||||||
|
- Revisit `packages/app/src/context/layout.tsx:424` if it still mirrors instead of deriving
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- There is one clear source of truth for each synced value
|
||||||
|
- Child stores no longer need effect-based hydration to stay consistent
|
||||||
|
- Initialization and updates both work without manual mirror effects
|
||||||
|
|
||||||
|
### Phase 5: Cleanup And Keeper Review
|
||||||
|
|
||||||
|
Clean up remaining targeted hotspots and narrow the effects that should stay.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Remaining `createEffect` calls in touched files are all true bridges or clearly justified lifecycle sync
|
||||||
|
- Mixed-responsibility effects are split into smaller units where still needed
|
||||||
|
|
||||||
|
## Detailed Work Items By Area
|
||||||
|
|
||||||
|
### 1. Normalize Tab State
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `packages/app/src/pages/session.tsx:141`
|
||||||
|
|
||||||
|
Work:
|
||||||
|
|
||||||
|
- Move tab normalization into the functions that create, load, or update tab state
|
||||||
|
- Make readers consume already-normalized tab data
|
||||||
|
- Remove the effect that rewrites derived tab state after the fact
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- Tabs should become valid when written, not be repaired later
|
||||||
|
- This removes a feedback loop and makes state easier to trust
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- The effect at `packages/app/src/pages/session.tsx:141` is removed
|
||||||
|
- Newly created and restored tabs are normalized before they enter local state
|
||||||
|
- Tab rendering still matches current behavior for valid and edge-case inputs
|
||||||
|
|
||||||
|
### 2. Key Session-Owned State
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `packages/app/src/pages/session.tsx:325`
|
||||||
|
- `packages/app/src/pages/session.tsx:336`
|
||||||
|
- `packages/app/src/pages/session.tsx:477`
|
||||||
|
- `packages/app/src/pages/session.tsx:869`
|
||||||
|
- `packages/app/src/pages/session.tsx:963`
|
||||||
|
- `packages/app/src/pages/session/message-timeline.tsx:149`
|
||||||
|
|
||||||
|
Work:
|
||||||
|
|
||||||
|
- Identify state that should reset when `sessionKey` changes
|
||||||
|
- Move that state under a keyed subtree or keyed owner boundary
|
||||||
|
- Remove effects that watch `sessionKey` just to clear local state, refs, or temporary UI flags
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- Session identity already defines the lifetime of this UI state
|
||||||
|
- Keyed ownership makes reset behavior automatic and easier to reason about
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- The targeted reset effects are removed
|
||||||
|
- Changing sessions resets only the intended session-local state
|
||||||
|
- Scroll and editor state that should persist are not accidentally reset
|
||||||
|
|
||||||
|
### 3. Derive Workspace Order
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `packages/app/src/pages/layout.tsx:557`
|
||||||
|
|
||||||
|
Work:
|
||||||
|
|
||||||
|
- Stop writing `workspaceOrder` from live workspace data in an effect
|
||||||
|
- Represent user overrides separately from live workspace data
|
||||||
|
- Compute effective order from current data plus overrides with a memo or pure helper
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- Persisted user intent and live source data should not mirror each other through an effect
|
||||||
|
- A computed effective order avoids drift and racey resync behavior
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- The effect at `packages/app/src/pages/layout.tsx:557` is removed
|
||||||
|
- Workspace order updates correctly when workspaces appear, disappear, or are reordered by the user
|
||||||
|
- User overrides persist without requiring a sync-back effect
|
||||||
|
|
||||||
|
### 4. Remove Child-Store Mirrors
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `packages/app/src/context/global-sync.tsx:130`
|
||||||
|
- `packages/app/src/context/global-sync.tsx:138`
|
||||||
|
- `packages/app/src/context/global-sync.tsx:148`
|
||||||
|
- `packages/app/src/context/global-sync/child-store.ts:184`
|
||||||
|
- `packages/app/src/context/global-sync/child-store.ts:190`
|
||||||
|
- `packages/app/src/context/global-sync/child-store.ts:193`
|
||||||
|
- `packages/app/src/context/layout.tsx:424`
|
||||||
|
|
||||||
|
Work:
|
||||||
|
|
||||||
|
- Trace the actual ownership of global and child store values
|
||||||
|
- Replace hydration and mirror effects with explicit initialization and direct updates
|
||||||
|
- Remove the `globalStore.reload` event-bus pattern and call the needed reload paths directly
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- Mirrors make it hard to tell which state is authoritative
|
||||||
|
- Event-bus style state toggles hide control flow and create accidental reruns
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Child store hydration no longer depends on effect-based copying
|
||||||
|
- Reload work can be followed from the event source to the handler without a reactive relay
|
||||||
|
- State remains correct on first load, child creation, and subsequent updates
|
||||||
|
|
||||||
|
### 5. Key File-Scoped State
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `packages/app/src/context/file.tsx:100`
|
||||||
|
|
||||||
|
Work:
|
||||||
|
|
||||||
|
- Move file-scoped local state under a boundary keyed by `scope()`
|
||||||
|
- Remove any effect that watches `scope()` only to reset file-local state
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- File scope changes are identity changes
|
||||||
|
- Keyed ownership gives a cleaner reset than manual clear logic
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- The effect at `packages/app/src/context/file.tsx:100` is removed
|
||||||
|
- Switching scopes resets only scope-local state
|
||||||
|
- No previous-scope data appears after a scope change
|
||||||
|
|
||||||
|
### 6. Split Layout Side Effects
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `packages/app/src/pages/layout.tsx:1489`
|
||||||
|
- Related event-driven effects near `packages/app/src/pages/layout.tsx:484`, `:652`, `:776`, `:1519`
|
||||||
|
|
||||||
|
Work:
|
||||||
|
|
||||||
|
- Break the mixed-responsibility effect at `:1489` into direct actions and smaller bridge effects only where required
|
||||||
|
- Move user-triggered branches into the actual command or handler that causes them
|
||||||
|
- Remove any branch that only exists because one effect is handling unrelated concerns
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- Mixed effects hide cause and make reruns hard to predict
|
||||||
|
- Smaller units reduce accidental coupling and make future cleanup safer
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- The effect at `packages/app/src/pages/layout.tsx:1489` no longer mixes unrelated responsibilities
|
||||||
|
- Event-driven branches execute from direct handlers
|
||||||
|
- Remaining effects in this area each have one clear external sync purpose
|
||||||
|
|
||||||
|
### 7. Remove Duplicate Triggers
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `packages/app/src/pages/session/review-tab.tsx:122`
|
||||||
|
- `packages/app/src/pages/session/review-tab.tsx:130`
|
||||||
|
- `packages/app/src/pages/session/review-tab.tsx:138`
|
||||||
|
- `packages/app/src/pages/session/file-tabs.tsx:367`
|
||||||
|
- `packages/app/src/pages/session/file-tabs.tsx:378`
|
||||||
|
- `packages/app/src/pages/session/file-tabs.tsx:389`
|
||||||
|
- `packages/app/src/pages/session/use-session-hash-scroll.ts:144`
|
||||||
|
- `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
|
||||||
|
- `packages/app/src/pages/session/use-session-hash-scroll.ts:167`
|
||||||
|
|
||||||
|
Work:
|
||||||
|
|
||||||
|
- Extract one explicit imperative function per behavior
|
||||||
|
- Call that function from each source event instead of replicating the same effect pattern multiple times
|
||||||
|
- Preserve the scroll-sync effect that is truly syncing with the DOM, but remove duplicate trigger scaffolding around it
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- Duplicate triggers make it easy to miss a case or fire twice
|
||||||
|
- One named action is easier to test and reason about
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Repeated imperative effect triplets are collapsed into shared functions
|
||||||
|
- Scroll behavior still works, including hash-based navigation
|
||||||
|
- No duplicate firing is introduced
|
||||||
|
|
||||||
|
### 8. Make Prompt Filtering Reactive
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `packages/app/src/components/prompt-input.tsx:652`
|
||||||
|
- Keep `packages/app/src/components/prompt-input.tsx:690` as needed
|
||||||
|
|
||||||
|
Work:
|
||||||
|
|
||||||
|
- Convert slash filtering into a pure reactive derivation from the current input and candidate command list
|
||||||
|
- Keep only the editor or DOM bridge effect if it is still needed for imperative syncing
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- Filtering is classic derived state
|
||||||
|
- It should not need an effect if it can be computed from current inputs
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- The effect at `packages/app/src/components/prompt-input.tsx:652` is removed
|
||||||
|
- Filtered slash-command results update correctly as the input changes
|
||||||
|
- The editor sync effect at `:690` still behaves correctly
|
||||||
|
|
||||||
|
### 9. Clean Up Smaller Derived-State Cases
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `packages/app/src/components/terminal.tsx:261`
|
||||||
|
- `packages/app/src/components/session/session-header.tsx:309`
|
||||||
|
|
||||||
|
Work:
|
||||||
|
|
||||||
|
- Replace effect-written local state with memos or inline derivation
|
||||||
|
- Remove intermediate setters when the value can be computed directly
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- These are low-risk wins that reinforce the same pattern
|
||||||
|
- They also help keep follow-up cleanup consistent
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Targeted effects are removed
|
||||||
|
- UI output remains unchanged under the same inputs
|
||||||
|
|
||||||
|
## Verification And Regression Checks
|
||||||
|
|
||||||
|
Run focused checks after each phase, not only at the end.
|
||||||
|
|
||||||
|
### Suggested Verification
|
||||||
|
|
||||||
|
- Switch between sessions rapidly and confirm local session UI resets only where intended
|
||||||
|
- Open, close, and reorder tabs and confirm order and normalization remain stable
|
||||||
|
- Change workspaces, reload workspace data, and verify effective ordering is correct
|
||||||
|
- Change file scope and confirm stale file state does not bleed across scopes
|
||||||
|
- Trigger layout actions that previously depended on effects and confirm they still fire once
|
||||||
|
- Use slash commands in the prompt and verify filtering updates as you type
|
||||||
|
- Test review tab, file tab, and hash-scroll flows for duplicate or missing triggers
|
||||||
|
- Verify global sync initialization, reload, and child-store creation paths
|
||||||
|
|
||||||
|
### Regression Checks
|
||||||
|
|
||||||
|
- No accidental infinite reruns
|
||||||
|
- No double-firing network or command actions
|
||||||
|
- No lost cleanup for listeners, timers, or scroll handlers
|
||||||
|
- No preserved stale state after identity changes
|
||||||
|
- No removed effect that was actually bridging to DOM or an external API
|
||||||
|
|
||||||
|
If available, add or update tests around pure helpers introduced during this cleanup.
|
||||||
|
|
||||||
|
Favor tests for derived ordering, normalization, and action extraction, since those are easiest to lock down.
|
||||||
|
|
||||||
|
## Definition Of Done
|
||||||
|
|
||||||
|
This work is done when all of the following are true:
|
||||||
|
|
||||||
|
- The highest-leverage targets in this spec are implemented
|
||||||
|
- Each removed effect has been replaced by a clearer pattern: memo, keyed boundary, direct action, or lifecycle hook
|
||||||
|
- The "should remain" effects still exist only where they serve a real external sync purpose
|
||||||
|
- Touched files have fewer mixed-responsibility effects and clearer ownership of state
|
||||||
|
- Manual verification covers session switching, file scope changes, workspace ordering, prompt filtering, and reload flows
|
||||||
|
- No behavior regressions are found in the targeted areas
|
||||||
|
|
||||||
|
A reduced raw `createEffect` count is helpful, but it is not the main success metric.
|
||||||
|
|
||||||
|
The main success metric is clearer ownership and fewer effect-driven state repairs.
|
||||||
|
|
||||||
|
## Risks And Rollout Notes
|
||||||
|
|
||||||
|
Main risks:
|
||||||
|
|
||||||
|
- Keyed remounts can reset too much if state boundaries are drawn too high
|
||||||
|
- Store mirror removal can break initialization order if ownership is not mapped first
|
||||||
|
- Moving event work out of effects can accidentally skip triggers that were previously implicit
|
||||||
|
|
||||||
|
Rollout notes:
|
||||||
|
|
||||||
|
- Land in small phases, with each phase keeping the app behaviorally stable
|
||||||
|
- Prefer isolated PRs by phase or by file cluster, especially for context-store changes
|
||||||
|
- Review each remaining effect in touched files and leave it only if it clearly bridges to something external
|
||||||
|
|
@ -92,14 +92,19 @@ test("switching back to a project opens the latest workspace session", async ({
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session(?:[/?#]|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session(?:[/?#]|$)`))
|
||||||
|
|
||||||
const created = await createSdk(workspaceDir)
|
// Create a session by sending a prompt
|
||||||
.session.create()
|
const prompt = page.locator(promptSelector)
|
||||||
.then((x) => x.data?.id)
|
await expect(prompt).toBeVisible()
|
||||||
if (!created) throw new Error(`Failed to create session for workspace: ${workspaceDir}`)
|
await prompt.fill("test")
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
// Wait for the URL to update with the new session ID
|
||||||
|
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
|
||||||
|
|
||||||
|
const created = sessionIDFromUrl(page.url())
|
||||||
|
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
|
||||||
sessionID = created
|
sessionID = created
|
||||||
|
|
||||||
await page.goto(sessionPath(workspaceDir, created))
|
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,17 @@ test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("auto-accept toggle works before first submit", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const button = page.locator('[data-action="prompt-permissions"]').first()
|
||||||
|
await expect(button).toBeVisible()
|
||||||
|
await expect(button).toHaveAttribute("aria-pressed", "false")
|
||||||
|
|
||||||
|
await setAutoAccept(page, true)
|
||||||
|
await setAutoAccept(page, false)
|
||||||
|
})
|
||||||
|
|
||||||
test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
|
test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
|
||||||
await withDockSession(sdk, "e2e composer dock question", async (session) => {
|
await withDockSession(sdk, "e2e composer dock question", async (session) => {
|
||||||
await withDockSeed(sdk, session.id, async () => {
|
await withDockSeed(sdk, session.id, async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,7 @@ try {
|
||||||
Object.assign(process.env, serverEnv)
|
Object.assign(process.env, serverEnv)
|
||||||
process.env.AGENT = "1"
|
process.env.AGENT = "1"
|
||||||
process.env.OPENCODE = "1"
|
process.env.OPENCODE = "1"
|
||||||
|
process.env.OPENCODE_PID = String(process.pid)
|
||||||
|
|
||||||
const log = await import("../../opencode/src/util/log")
|
const log = await import("../../opencode/src/util/log")
|
||||||
const install = await import("../../opencode/src/installation")
|
const install = await import("../../opencode/src/installation")
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
||||||
import { Font } from "@opencode-ai/ui/font"
|
import { Font } from "@opencode-ai/ui/font"
|
||||||
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
||||||
import { MetaProvider } from "@solidjs/meta"
|
import { MetaProvider } from "@solidjs/meta"
|
||||||
import { Navigate, Route, Router } from "@solidjs/router"
|
import { BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
|
||||||
import { ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
|
import { Component, ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
|
||||||
import { CommandProvider } from "@/context/command"
|
import { CommandProvider } from "@/context/command"
|
||||||
import { CommentsProvider } from "@/context/comments"
|
import { CommentsProvider } from "@/context/comments"
|
||||||
import { FileProvider } from "@/context/file"
|
import { FileProvider } from "@/context/file"
|
||||||
|
|
@ -28,6 +28,7 @@ import { TerminalProvider } from "@/context/terminal"
|
||||||
import DirectoryLayout from "@/pages/directory-layout"
|
import DirectoryLayout from "@/pages/directory-layout"
|
||||||
import Layout from "@/pages/layout"
|
import Layout from "@/pages/layout"
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
|
import { Dynamic } from "solid-js/web"
|
||||||
|
|
||||||
const Home = lazy(() => import("@/pages/home"))
|
const Home = lazy(() => import("@/pages/home"))
|
||||||
const Session = lazy(() => import("@/pages/session"))
|
const Session = lazy(() => import("@/pages/session"))
|
||||||
|
|
@ -144,13 +145,15 @@ export function AppInterface(props: {
|
||||||
children?: JSX.Element
|
children?: JSX.Element
|
||||||
defaultServer: ServerConnection.Key
|
defaultServer: ServerConnection.Key
|
||||||
servers?: Array<ServerConnection.Any>
|
servers?: Array<ServerConnection.Any>
|
||||||
|
router?: Component<BaseRouterProps>
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
|
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
|
||||||
<ServerKey>
|
<ServerKey>
|
||||||
<GlobalSDKProvider>
|
<GlobalSDKProvider>
|
||||||
<GlobalSyncProvider>
|
<GlobalSyncProvider>
|
||||||
<Router
|
<Dynamic
|
||||||
|
component={props.router ?? Router}
|
||||||
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
|
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
|
||||||
>
|
>
|
||||||
<Route path="/" component={HomeRoute} />
|
<Route path="/" component={HomeRoute} />
|
||||||
|
|
@ -158,7 +161,7 @@ export function AppInterface(props: {
|
||||||
<Route path="/" component={SessionIndexRoute} />
|
<Route path="/" component={SessionIndexRoute} />
|
||||||
<Route path="/session/:id?" component={SessionRoute} />
|
<Route path="/session/:id?" component={SessionRoute} />
|
||||||
</Route>
|
</Route>
|
||||||
</Router>
|
</Dynamic>
|
||||||
</GlobalSyncProvider>
|
</GlobalSyncProvider>
|
||||||
</GlobalSDKProvider>
|
</GlobalSDKProvider>
|
||||||
</ServerKey>
|
</ServerKey>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
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 type { IconName } from "@opencode-ai/ui/icons/provider"
|
|
||||||
import { List, type ListRef } from "@opencode-ai/ui/list"
|
import { List, type ListRef } from "@opencode-ai/ui/list"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
|
|
@ -447,7 +446,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-6 px-2.5 pb-3">
|
<div class="flex flex-col gap-6 px-2.5 pb-3">
|
||||||
<div class="px-2.5 flex gap-4 items-center">
|
<div class="px-2.5 flex gap-4 items-center">
|
||||||
<ProviderIcon id={props.provider as IconName} class="size-5 shrink-0 icon-strong-base" />
|
<ProviderIcon id={props.provider} class="size-5 shrink-0 icon-strong-base" />
|
||||||
<div class="text-16-medium text-text-strong">
|
<div class="text-16-medium text-text-strong">
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
|
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
|
||||||
import { List, type ListRef } from "@opencode-ai/ui/list"
|
import { List, type ListRef } from "@opencode-ai/ui/list"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { Tag } from "@opencode-ai/ui/tag"
|
import { Tag } from "@opencode-ai/ui/tag"
|
||||||
|
|
@ -95,7 +94,7 @@ export const DialogSelectModelUnpaid: Component = () => {
|
||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
<div class="w-full flex items-center gap-x-3">
|
<div class="w-full flex items-center gap-x-3">
|
||||||
<ProviderIcon data-slot="list-item-extra-icon" id={i.id as IconName} />
|
<ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
|
||||||
<span>{i.name}</span>
|
<span>{i.name}</span>
|
||||||
<Show when={i.id === "opencode"}>
|
<Show when={i.id === "opencode"}>
|
||||||
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
|
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,12 @@ import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { Tag } from "@opencode-ai/ui/tag"
|
import { Tag } from "@opencode-ai/ui/tag"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { iconNames, type IconName } from "@opencode-ai/ui/icons/provider"
|
|
||||||
import { DialogConnectProvider } from "./dialog-connect-provider"
|
import { DialogConnectProvider } from "./dialog-connect-provider"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { DialogCustomProvider } from "./dialog-custom-provider"
|
import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||||
|
|
||||||
const CUSTOM_ID = "_custom"
|
const CUSTOM_ID = "_custom"
|
||||||
|
|
||||||
function icon(id: string): IconName {
|
|
||||||
if (iconNames.includes(id as IconName)) return id as IconName
|
|
||||||
return "synthetic"
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DialogSelectProvider: Component = () => {
|
export const DialogSelectProvider: Component = () => {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const providers = useProviders()
|
const providers = useProviders()
|
||||||
|
|
@ -69,7 +63,7 @@ export const DialogSelectProvider: Component = () => {
|
||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
<div class="px-1.25 w-full flex items-center gap-x-3">
|
<div class="px-1.25 w-full flex items-center gap-x-3">
|
||||||
<ProviderIcon data-slot="list-item-extra-icon" id={icon(i.id)} />
|
<ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
|
||||||
<span>{i.name}</span>
|
<span>{i.name}</span>
|
||||||
<Show when={i.id === "opencode"}>
|
<Show when={i.id === "opencode"}>
|
||||||
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
|
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
|
||||||
|
|
|
||||||
|
|
@ -325,12 +325,6 @@ export default function FileTree(props: {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const dir = file.tree.state(props.path)
|
|
||||||
if (!shouldListExpanded({ level, dir })) return
|
|
||||||
void file.tree.list(props.path)
|
|
||||||
})
|
|
||||||
|
|
||||||
const nodes = createMemo(() => {
|
const nodes = createMemo(() => {
|
||||||
const nodes = file.tree.children(props.path)
|
const nodes = file.tree.children(props.path)
|
||||||
const current = filter()
|
const current = filter()
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||||
|
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||||
import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
|
import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createFocusSignal } from "@solid-primitives/active-element"
|
import { createFocusSignal } from "@solid-primitives/active-element"
|
||||||
|
|
@ -23,7 +24,6 @@ import { Button } from "@opencode-ai/ui/button"
|
||||||
import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
|
import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
|
||||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { Select } from "@opencode-ai/ui/select"
|
import { Select } from "@opencode-ai/ui/select"
|
||||||
|
|
@ -244,6 +244,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
draggingType: "image" | "@mention" | null
|
draggingType: "image" | "@mention" | null
|
||||||
mode: "normal" | "shell"
|
mode: "normal" | "shell"
|
||||||
applyingHistory: boolean
|
applyingHistory: boolean
|
||||||
|
pendingAutoAccept: boolean
|
||||||
}>({
|
}>({
|
||||||
popover: null,
|
popover: null,
|
||||||
historyIndex: -1,
|
historyIndex: -1,
|
||||||
|
|
@ -252,8 +253,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
draggingType: null,
|
draggingType: null,
|
||||||
mode: "normal",
|
mode: "normal",
|
||||||
applyingHistory: false,
|
applyingHistory: false,
|
||||||
|
pendingAutoAccept: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
|
||||||
|
|
||||||
const commentCount = createMemo(() => {
|
const commentCount = createMemo(() => {
|
||||||
if (store.mode === "shell") return 0
|
if (store.mode === "shell") return 0
|
||||||
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
|
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
|
||||||
|
|
@ -302,6 +306,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(sessionKey, () => {
|
||||||
|
setStore("pendingAutoAccept", false)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const historyComments = () => {
|
const historyComments = () => {
|
||||||
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
|
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
|
||||||
return prompt.context.items().flatMap((item) => {
|
return prompt.context.items().flatMap((item) => {
|
||||||
|
|
@ -592,7 +602,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
setActive: setSlashActive,
|
setActive: setSlashActive,
|
||||||
onInput: slashOnInput,
|
onInput: slashOnInput,
|
||||||
onKeyDown: slashOnKeyDown,
|
onKeyDown: slashOnKeyDown,
|
||||||
refetch: slashRefetch,
|
|
||||||
} = useFilteredList<SlashCommand>({
|
} = useFilteredList<SlashCommand>({
|
||||||
items: slashCommands,
|
items: slashCommands,
|
||||||
key: (x) => x?.id,
|
key: (x) => x?.id,
|
||||||
|
|
@ -649,14 +658,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => sync.data.command,
|
|
||||||
() => slashRefetch(),
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Auto-scroll active command into view when navigating with keyboard
|
// Auto-scroll active command into view when navigating with keyboard
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const activeId = slashActive()
|
const activeId = slashActive()
|
||||||
|
|
@ -957,10 +958,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
readClipboardImage: platform.readClipboardImage,
|
readClipboardImage: platform.readClipboardImage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const variants = createMemo(() => ["default", ...local.model.variant.list()])
|
||||||
|
const accepting = createMemo(() => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return store.pendingAutoAccept
|
||||||
|
return permission.isAutoAccepting(id, sdk.directory)
|
||||||
|
})
|
||||||
|
|
||||||
const { abort, handleSubmit } = createPromptSubmit({
|
const { abort, handleSubmit } = createPromptSubmit({
|
||||||
info,
|
info,
|
||||||
imageAttachments,
|
imageAttachments,
|
||||||
commentCount,
|
commentCount,
|
||||||
|
autoAccept: () => accepting(),
|
||||||
mode: () => store.mode,
|
mode: () => store.mode,
|
||||||
working,
|
working,
|
||||||
editor: () => editorRef,
|
editor: () => editorRef,
|
||||||
|
|
@ -1125,13 +1134,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const variants = createMemo(() => ["default", ...local.model.variant.list()])
|
|
||||||
const accepting = createMemo(() => {
|
|
||||||
const id = params.id
|
|
||||||
if (!id) return false
|
|
||||||
return permission.isAutoAccepting(id, sdk.directory)
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative size-full _max-h-[320px] flex flex-col gap-0">
|
<div class="relative size-full _max-h-[320px] flex flex-col gap-0">
|
||||||
<PromptPopover
|
<PromptPopover
|
||||||
|
|
@ -1251,10 +1253,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
|
|
||||||
<div
|
<div
|
||||||
aria-hidden={store.mode !== "normal"}
|
aria-hidden={store.mode !== "normal"}
|
||||||
class="flex items-center gap-1 transition-all duration-200 ease-out"
|
class="flex items-center gap-1"
|
||||||
classList={{
|
style={{
|
||||||
"opacity-100 translate-y-0 scale-100 pointer-events-auto": store.mode === "normal",
|
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||||
"opacity-0 translate-y-2 scale-95 pointer-events-none": store.mode !== "normal",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
|
|
@ -1267,6 +1268,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="size-8 p-0"
|
class="size-8 p-0"
|
||||||
|
style={{
|
||||||
|
opacity: buttonsSpring(),
|
||||||
|
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||||
|
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||||
|
}}
|
||||||
onClick={pick}
|
onClick={pick}
|
||||||
disabled={store.mode !== "normal"}
|
disabled={store.mode !== "normal"}
|
||||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||||
|
|
@ -1304,6 +1310,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
icon={working() ? "stop" : "arrow-up"}
|
icon={working() ? "stop" : "arrow-up"}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
class="size-8"
|
class="size-8"
|
||||||
|
style={{
|
||||||
|
opacity: buttonsSpring(),
|
||||||
|
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||||
|
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||||
|
}}
|
||||||
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
@ -1323,9 +1334,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
<Button
|
<Button
|
||||||
data-action="prompt-permissions"
|
data-action="prompt-permissions"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
disabled={!params.id}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!params.id) return
|
if (!params.id) {
|
||||||
|
setStore("pendingAutoAccept", (value) => !value)
|
||||||
|
return
|
||||||
|
}
|
||||||
permission.toggleAutoAccept(params.id, sdk.directory)
|
permission.toggleAutoAccept(params.id, sdk.directory)
|
||||||
}}
|
}}
|
||||||
classList={{
|
classList={{
|
||||||
|
|
@ -1354,14 +1367,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
<Show when={store.mode === "normal" || store.mode === "shell"}>
|
<Show when={store.mode === "normal" || store.mode === "shell"}>
|
||||||
<DockTray attach="top">
|
<DockTray attach="top">
|
||||||
<div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
|
<div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
|
||||||
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
<div class="flex items-center gap-1.5 min-w-0 flex-1 relative">
|
||||||
<Show when={store.mode === "shell"}>
|
<div
|
||||||
<div class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0" style={{ padding: "0 4px 0 8px" }}>
|
class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0"
|
||||||
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
|
style={{
|
||||||
<div class="size-4 shrink-0" />
|
padding: "0 4px 0 8px",
|
||||||
</div>
|
opacity: 1 - buttonsSpring(),
|
||||||
</Show>
|
transform: `scale(${0.95 + (1 - buttonsSpring()) * 0.05})`,
|
||||||
<Show when={store.mode === "normal"}>
|
filter: `blur(${buttonsSpring() * 2}px)`,
|
||||||
|
"pointer-events": buttonsSpring() < 0.5 ? "auto" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
|
||||||
|
<div class="size-4 shrink-0" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
placement="top"
|
placement="top"
|
||||||
gutter={4}
|
gutter={4}
|
||||||
|
|
@ -1375,7 +1395,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
onSelect={local.agent.set}
|
onSelect={local.agent.set}
|
||||||
class="capitalize max-w-[160px]"
|
class="capitalize max-w-[160px]"
|
||||||
valueClass="truncate text-13-regular"
|
valueClass="truncate text-13-regular"
|
||||||
triggerStyle={{ height: "28px" }}
|
triggerStyle={{
|
||||||
|
height: "28px",
|
||||||
|
opacity: buttonsSpring(),
|
||||||
|
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||||
|
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||||
|
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||||
|
}}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
/>
|
/>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
|
|
@ -1393,12 +1419,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="normal"
|
size="normal"
|
||||||
class="min-w-0 max-w-[320px] text-13-regular group"
|
class="min-w-0 max-w-[320px] text-13-regular group"
|
||||||
style={{ height: "28px" }}
|
style={{
|
||||||
|
height: "28px",
|
||||||
|
opacity: buttonsSpring(),
|
||||||
|
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||||
|
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||||
|
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||||
|
}}
|
||||||
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
|
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
|
||||||
>
|
>
|
||||||
<Show when={local.model.current()?.provider?.id}>
|
<Show when={local.model.current()?.provider?.id}>
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
id={local.model.current()!.provider.id as IconName}
|
id={local.model.current()!.provider.id}
|
||||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1422,13 +1454,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
triggerProps={{
|
triggerProps={{
|
||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
size: "normal",
|
size: "normal",
|
||||||
style: { height: "28px" },
|
style: {
|
||||||
|
height: "28px",
|
||||||
|
opacity: buttonsSpring(),
|
||||||
|
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||||
|
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||||
|
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||||
|
},
|
||||||
class: "min-w-0 max-w-[320px] text-13-regular group",
|
class: "min-w-0 max-w-[320px] text-13-regular group",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={local.model.current()?.provider?.id}>
|
<Show when={local.model.current()?.provider?.id}>
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
id={local.model.current()!.provider.id as IconName}
|
id={local.model.current()!.provider.id}
|
||||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1454,11 +1492,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
|
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
|
||||||
class="capitalize max-w-[160px]"
|
class="capitalize max-w-[160px]"
|
||||||
valueClass="truncate text-13-regular"
|
valueClass="truncate text-13-regular"
|
||||||
triggerStyle={{ height: "28px" }}
|
triggerStyle={{
|
||||||
|
height: "28px",
|
||||||
|
opacity: buttonsSpring(),
|
||||||
|
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||||
|
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||||
|
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||||
|
}}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
/>
|
/>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
</Show>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="shrink-0">
|
<div class="shrink-0">
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||||
|
|
||||||
const createdClients: string[] = []
|
const createdClients: string[] = []
|
||||||
const createdSessions: string[] = []
|
const createdSessions: string[] = []
|
||||||
|
const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
|
||||||
const sentShell: string[] = []
|
const sentShell: string[] = []
|
||||||
const syncedDirectories: string[] = []
|
const syncedDirectories: string[] = []
|
||||||
|
|
||||||
|
|
@ -69,6 +70,14 @@ beforeAll(async () => {
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
mock.module("@/context/permission", () => ({
|
||||||
|
usePermission: () => ({
|
||||||
|
enableAutoAccept(sessionID: string, directory: string) {
|
||||||
|
enabledAutoAccept.push({ sessionID, directory })
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
mock.module("@/context/prompt", () => ({
|
mock.module("@/context/prompt", () => ({
|
||||||
usePrompt: () => ({
|
usePrompt: () => ({
|
||||||
current: () => promptValue,
|
current: () => promptValue,
|
||||||
|
|
@ -145,6 +154,7 @@ beforeAll(async () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
createdClients.length = 0
|
createdClients.length = 0
|
||||||
createdSessions.length = 0
|
createdSessions.length = 0
|
||||||
|
enabledAutoAccept.length = 0
|
||||||
sentShell.length = 0
|
sentShell.length = 0
|
||||||
syncedDirectories.length = 0
|
syncedDirectories.length = 0
|
||||||
selected = "/repo/worktree-a"
|
selected = "/repo/worktree-a"
|
||||||
|
|
@ -156,6 +166,7 @@ describe("prompt submit worktree selection", () => {
|
||||||
info: () => undefined,
|
info: () => undefined,
|
||||||
imageAttachments: () => [],
|
imageAttachments: () => [],
|
||||||
commentCount: () => 0,
|
commentCount: () => 0,
|
||||||
|
autoAccept: () => false,
|
||||||
mode: () => "shell",
|
mode: () => "shell",
|
||||||
working: () => false,
|
working: () => false,
|
||||||
editor: () => undefined,
|
editor: () => undefined,
|
||||||
|
|
@ -181,4 +192,31 @@ describe("prompt submit worktree selection", () => {
|
||||||
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("applies auto-accept to newly created sessions", async () => {
|
||||||
|
const submit = createPromptSubmit({
|
||||||
|
info: () => undefined,
|
||||||
|
imageAttachments: () => [],
|
||||||
|
commentCount: () => 0,
|
||||||
|
autoAccept: () => true,
|
||||||
|
mode: () => "shell",
|
||||||
|
working: () => false,
|
||||||
|
editor: () => undefined,
|
||||||
|
queueScroll: () => undefined,
|
||||||
|
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||||
|
addToHistory: () => undefined,
|
||||||
|
resetHistoryNavigation: () => undefined,
|
||||||
|
setMode: () => undefined,
|
||||||
|
setPopover: () => undefined,
|
||||||
|
newSessionWorktree: () => selected,
|
||||||
|
onNewSessionWorktreeReset: () => undefined,
|
||||||
|
onSubmit: () => undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = { preventDefault: () => undefined } as unknown as Event
|
||||||
|
|
||||||
|
await submit.handleSubmit(event)
|
||||||
|
|
||||||
|
expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
|
import { usePermission } from "@/context/permission"
|
||||||
import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
|
import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
|
|
@ -27,6 +28,7 @@ type PromptSubmitInput = {
|
||||||
info: Accessor<{ id: string } | undefined>
|
info: Accessor<{ id: string } | undefined>
|
||||||
imageAttachments: Accessor<ImageAttachmentPart[]>
|
imageAttachments: Accessor<ImageAttachmentPart[]>
|
||||||
commentCount: Accessor<number>
|
commentCount: Accessor<number>
|
||||||
|
autoAccept: Accessor<boolean>
|
||||||
mode: Accessor<"normal" | "shell">
|
mode: Accessor<"normal" | "shell">
|
||||||
working: Accessor<boolean>
|
working: Accessor<boolean>
|
||||||
editor: () => HTMLDivElement | undefined
|
editor: () => HTMLDivElement | undefined
|
||||||
|
|
@ -56,6 +58,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const globalSync = useGlobalSync()
|
const globalSync = useGlobalSync()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
|
const permission = usePermission()
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
@ -140,6 +143,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
|
|
||||||
const projectDirectory = sdk.directory
|
const projectDirectory = sdk.directory
|
||||||
const isNewSession = !params.id
|
const isNewSession = !params.id
|
||||||
|
const shouldAutoAccept = isNewSession && input.autoAccept()
|
||||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||||
|
|
||||||
let sessionDirectory = projectDirectory
|
let sessionDirectory = projectDirectory
|
||||||
|
|
@ -197,6 +201,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
if (session) {
|
if (session) {
|
||||||
|
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
|
||||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||||
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -138,12 +138,12 @@ function useSessionShare(args: {
|
||||||
globalSDK: ReturnType<typeof useGlobalSDK>
|
globalSDK: ReturnType<typeof useGlobalSDK>
|
||||||
currentSession: () =>
|
currentSession: () =>
|
||||||
| {
|
| {
|
||||||
id: string
|
|
||||||
share?: {
|
share?: {
|
||||||
url?: string
|
url?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| undefined
|
| undefined
|
||||||
|
sessionID: () => string | undefined
|
||||||
projectDirectory: () => string
|
projectDirectory: () => string
|
||||||
platform: ReturnType<typeof usePlatform>
|
platform: ReturnType<typeof usePlatform>
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -167,11 +167,11 @@ function useSessionShare(args: {
|
||||||
})
|
})
|
||||||
|
|
||||||
const shareSession = () => {
|
const shareSession = () => {
|
||||||
const session = args.currentSession()
|
const sessionID = args.sessionID()
|
||||||
if (!session || state.share) return
|
if (!sessionID || state.share) return
|
||||||
setState("share", true)
|
setState("share", true)
|
||||||
args.globalSDK.client.session
|
args.globalSDK.client.session
|
||||||
.share({ sessionID: session.id, directory: args.projectDirectory() })
|
.share({ sessionID, directory: args.projectDirectory() })
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Failed to share session", error)
|
console.error("Failed to share session", error)
|
||||||
})
|
})
|
||||||
|
|
@ -181,11 +181,11 @@ function useSessionShare(args: {
|
||||||
}
|
}
|
||||||
|
|
||||||
const unshareSession = () => {
|
const unshareSession = () => {
|
||||||
const session = args.currentSession()
|
const sessionID = args.sessionID()
|
||||||
if (!session || state.unshare) return
|
if (!sessionID || state.unshare) return
|
||||||
setState("unshare", true)
|
setState("unshare", true)
|
||||||
args.globalSDK.client.session
|
args.globalSDK.client.session
|
||||||
.unshare({ sessionID: session.id, directory: args.projectDirectory() })
|
.unshare({ sessionID, directory: args.projectDirectory() })
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Failed to unshare session", error)
|
console.error("Failed to unshare session", error)
|
||||||
})
|
})
|
||||||
|
|
@ -243,9 +243,9 @@ export function SessionHeader() {
|
||||||
})
|
})
|
||||||
const hotkey = createMemo(() => command.keybind("file.open"))
|
const hotkey = createMemo(() => command.keybind("file.open"))
|
||||||
|
|
||||||
const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id))
|
const currentSession = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||||
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
||||||
const showShare = createMemo(() => shareEnabled() && !!currentSession())
|
const showShare = createMemo(() => shareEnabled() && !!params.id)
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
const view = createMemo(() => layout.view(sessionKey))
|
const view = createMemo(() => layout.view(sessionKey))
|
||||||
const os = createMemo(() => detectOS(platform))
|
const os = createMemo(() => detectOS(platform))
|
||||||
|
|
@ -306,11 +306,10 @@ export function SessionHeader() {
|
||||||
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
|
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
|
||||||
const opening = createMemo(() => openRequest.app !== undefined)
|
const opening = createMemo(() => openRequest.app !== undefined)
|
||||||
|
|
||||||
createEffect(() => {
|
const selectApp = (app: OpenApp) => {
|
||||||
const value = prefs.app
|
if (!options().some((item) => item.id === app)) return
|
||||||
if (options().some((o) => o.id === value)) return
|
setPrefs("app", app)
|
||||||
setPrefs("app", options()[0]?.id ?? "finder")
|
}
|
||||||
})
|
|
||||||
|
|
||||||
const openDir = (app: OpenApp) => {
|
const openDir = (app: OpenApp) => {
|
||||||
if (opening() || !canOpen() || !platform.openPath) return
|
if (opening() || !canOpen() || !platform.openPath) return
|
||||||
|
|
@ -347,6 +346,7 @@ export function SessionHeader() {
|
||||||
const share = useSessionShare({
|
const share = useSessionShare({
|
||||||
globalSDK,
|
globalSDK,
|
||||||
currentSession,
|
currentSession,
|
||||||
|
sessionID: () => params.id,
|
||||||
projectDirectory,
|
projectDirectory,
|
||||||
platform,
|
platform,
|
||||||
})
|
})
|
||||||
|
|
@ -458,7 +458,7 @@ export function SessionHeader() {
|
||||||
value={current().id}
|
value={current().id}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
if (!OPEN_APPS.includes(value as OpenApp)) return
|
if (!OPEN_APPS.includes(value as OpenApp)) return
|
||||||
setPrefs("app", value as OpenApp)
|
selectApp(value as OpenApp)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<For each={options()}>
|
<For each={options()}>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { Switch } from "@opencode-ai/ui/switch"
|
||||||
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 { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
|
||||||
import { type Component, For, Show } from "solid-js"
|
import { type Component, For, Show } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useModels } from "@/context/models"
|
import { useModels } from "@/context/models"
|
||||||
|
|
@ -98,7 +97,7 @@ export const SettingsModels: Component = () => {
|
||||||
{(group) => (
|
{(group) => (
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<div class="flex items-center gap-2 pb-2">
|
<div class="flex items-center gap-2 pb-2">
|
||||||
<ProviderIcon id={group.category as IconName} class="size-5 shrink-0 icon-strong-base" />
|
<ProviderIcon id={group.category} class="size-5 shrink-0 icon-strong-base" />
|
||||||
<span class="text-14-medium text-text-strong">{group.items[0].provider.name}</span>
|
<span class="text-14-medium text-text-strong">{group.items[0].provider.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { Tag } from "@opencode-ai/ui/tag"
|
import { Tag } from "@opencode-ai/ui/tag"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { iconNames, type IconName } from "@opencode-ai/ui/icons/provider"
|
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { createMemo, type Component, For, Show } from "solid-js"
|
import { createMemo, type Component, For, Show } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
@ -33,11 +32,6 @@ export const SettingsProviders: Component = () => {
|
||||||
const globalSync = useGlobalSync()
|
const globalSync = useGlobalSync()
|
||||||
const providers = useProviders()
|
const providers = useProviders()
|
||||||
|
|
||||||
const icon = (id: string): IconName => {
|
|
||||||
if (iconNames.includes(id as IconName)) return id as IconName
|
|
||||||
return "synthetic"
|
|
||||||
}
|
|
||||||
|
|
||||||
const connected = createMemo(() => {
|
const connected = createMemo(() => {
|
||||||
return providers
|
return providers
|
||||||
.connected()
|
.connected()
|
||||||
|
|
@ -154,7 +148,7 @@ export const SettingsProviders: Component = () => {
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<div class="group flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
|
<div class="group flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<ProviderIcon id={icon(item.id)} class="size-5 shrink-0 icon-strong-base" />
|
<ProviderIcon id={item.id} class="size-5 shrink-0 icon-strong-base" />
|
||||||
<span class="text-14-medium text-text-strong truncate">{item.name}</span>
|
<span class="text-14-medium text-text-strong truncate">{item.name}</span>
|
||||||
<Tag>{type(item)}</Tag>
|
<Tag>{type(item)}</Tag>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -185,7 +179,7 @@ export const SettingsProviders: Component = () => {
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
|
<div class="flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
|
||||||
<div class="flex flex-col min-w-0">
|
<div class="flex flex-col min-w-0">
|
||||||
<div class="flex items-center gap-x-3">
|
<div class="flex items-center gap-x-3">
|
||||||
<ProviderIcon id={icon(item.id)} class="size-5 shrink-0 icon-strong-base" />
|
<ProviderIcon id={item.id} class="size-5 shrink-0 icon-strong-base" />
|
||||||
<span class="text-14-medium text-text-strong">{item.name}</span>
|
<span class="text-14-medium text-text-strong">{item.name}</span>
|
||||||
<Show when={item.id === "opencode"}>
|
<Show when={item.id === "opencode"}>
|
||||||
<span class="text-14-regular text-text-weak">
|
<span class="text-14-regular text-text-weak">
|
||||||
|
|
@ -228,7 +222,7 @@ export const SettingsProviders: Component = () => {
|
||||||
>
|
>
|
||||||
<div class="flex flex-col min-w-0">
|
<div class="flex flex-col min-w-0">
|
||||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
<ProviderIcon id={icon("synthetic")} class="size-5 shrink-0 icon-strong-base" />
|
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||||
<span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
|
<span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
|
||||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -202,29 +202,26 @@ export function StatusPopover() {
|
||||||
triggerAs={Button}
|
triggerAs={Button}
|
||||||
triggerProps={{
|
triggerProps={{
|
||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
class:
|
class: "titlebar-icon w-6 h-6 p-0 box-border",
|
||||||
"rounded-md h-[24px] pr-3 pl-0.5 gap-2 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-base-active",
|
"aria-label": language.t("status.popover.trigger"),
|
||||||
style: { scale: 1 },
|
style: { scale: 1 },
|
||||||
}}
|
}}
|
||||||
trigger={
|
trigger={
|
||||||
<div class="flex items-center gap-0.5">
|
<div class="flex size-4 items-center justify-center">
|
||||||
<div class="size-4 flex items-center justify-center">
|
<div
|
||||||
<div
|
classList={{
|
||||||
classList={{
|
"size-1.5 rounded-full": true,
|
||||||
"size-1.5 rounded-full": true,
|
"bg-icon-success-base": overallHealthy(),
|
||||||
"bg-icon-success-base": overallHealthy(),
|
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
|
||||||
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
|
"bg-border-weak-base": server.healthy() === undefined,
|
||||||
"bg-border-weak-base": server.healthy() === undefined,
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span class="text-12-regular text-text-strong">{language.t("status.popover.trigger")}</span>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
|
class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
|
||||||
gutter={4}
|
gutter={4}
|
||||||
placement="bottom-end"
|
placement="bottom-end"
|
||||||
shift={-136}
|
shift={-168}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
|
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
|
import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
|
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
|
||||||
import { type ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
|
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
|
||||||
import { SerializeAddon } from "@/addons/serialize"
|
import { SerializeAddon } from "@/addons/serialize"
|
||||||
import { matchKeybind, parseKeybind } from "@/context/command"
|
import { matchKeybind, parseKeybind } from "@/context/command"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
@ -219,7 +219,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [terminalColors, setTerminalColors] = createSignal<TerminalColors>(getTerminalColors())
|
const terminalColors = createMemo(getTerminalColors)
|
||||||
|
|
||||||
const scheduleFit = () => {
|
const scheduleFit = () => {
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
|
|
@ -259,8 +259,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const colors = getTerminalColors()
|
const colors = terminalColors()
|
||||||
setTerminalColors(colors)
|
|
||||||
if (!term) return
|
if (!term) return
|
||||||
setOptionIfSupported(term, "theme", colors)
|
setOptionIfSupported(term, "theme", colors)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,7 @@ export function Titlebar() {
|
||||||
<header
|
<header
|
||||||
class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
|
class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
|
||||||
style={{ "min-height": minHeight() }}
|
style={{ "min-height": minHeight() }}
|
||||||
|
data-tauri-drag-region
|
||||||
onMouseDown={drag}
|
onMouseDown={drag}
|
||||||
onDblClick={maximize}
|
onDblClick={maximize}
|
||||||
>
|
>
|
||||||
|
|
@ -276,6 +277,7 @@ export function Titlebar() {
|
||||||
"flex items-center min-w-0 justify-end": true,
|
"flex items-center min-w-0 justify-end": true,
|
||||||
"pr-2": !windows(),
|
"pr-2": !windows(),
|
||||||
}}
|
}}
|
||||||
|
data-tauri-drag-region
|
||||||
onMouseDown={drag}
|
onMouseDown={drag}
|
||||||
>
|
>
|
||||||
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
|
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { getFilename } from "@opencode-ai/util/path"
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
createEffect,
|
|
||||||
getOwner,
|
getOwner,
|
||||||
Match,
|
Match,
|
||||||
onCleanup,
|
onCleanup,
|
||||||
|
|
@ -35,7 +34,6 @@ import { trimSessions } from "./global-sync/session-trim"
|
||||||
import type { ProjectMeta } from "./global-sync/types"
|
import type { ProjectMeta } from "./global-sync/types"
|
||||||
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
|
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
|
||||||
import { sanitizeProject } from "./global-sync/utils"
|
import { sanitizeProject } from "./global-sync/utils"
|
||||||
import { usePlatform } from "./platform"
|
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
|
|
@ -54,7 +52,6 @@ type GlobalStore = {
|
||||||
|
|
||||||
function createGlobalSync() {
|
function createGlobalSync() {
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
const platform = usePlatform()
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const owner = getOwner()
|
const owner = getOwner()
|
||||||
if (!owner) throw new Error("GlobalSync must be created within owner")
|
if (!owner) throw new Error("GlobalSync must be created within owner")
|
||||||
|
|
@ -64,7 +61,7 @@ function createGlobalSync() {
|
||||||
const sessionLoads = new Map<string, Promise<void>>()
|
const sessionLoads = new Map<string, Promise<void>>()
|
||||||
const sessionMeta = new Map<string, { limit: number }>()
|
const sessionMeta = new Map<string, { limit: number }>()
|
||||||
|
|
||||||
const [projectCache, setProjectCache, , projectCacheReady] = persisted(
|
const [projectCache, setProjectCache, projectInit] = persisted(
|
||||||
Persist.global("globalSync.project", ["globalSync.project.v1"]),
|
Persist.global("globalSync.project", ["globalSync.project.v1"]),
|
||||||
createStore({ value: [] as Project[] }),
|
createStore({ value: [] as Project[] }),
|
||||||
)
|
)
|
||||||
|
|
@ -80,6 +77,57 @@ function createGlobalSync() {
|
||||||
reload: undefined,
|
reload: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let active = true
|
||||||
|
let projectWritten = false
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
active = false
|
||||||
|
})
|
||||||
|
|
||||||
|
const cacheProjects = () => {
|
||||||
|
setProjectCache(
|
||||||
|
"value",
|
||||||
|
untrack(() => globalStore.project.map(sanitizeProject)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setProjects = (next: Project[] | ((draft: Project[]) => void)) => {
|
||||||
|
projectWritten = true
|
||||||
|
if (typeof next === "function") {
|
||||||
|
setGlobalStore("project", produce(next))
|
||||||
|
cacheProjects()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setGlobalStore("project", next)
|
||||||
|
cacheProjects()
|
||||||
|
}
|
||||||
|
|
||||||
|
const setBootStore = ((...input: unknown[]) => {
|
||||||
|
if (input[0] === "project" && Array.isArray(input[1])) {
|
||||||
|
setProjects(input[1] as Project[])
|
||||||
|
return input[1]
|
||||||
|
}
|
||||||
|
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
|
||||||
|
}) as typeof setGlobalStore
|
||||||
|
|
||||||
|
const set = ((...input: unknown[]) => {
|
||||||
|
if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) {
|
||||||
|
setProjects(input[1] as Project[] | ((draft: Project[]) => void))
|
||||||
|
return input[1]
|
||||||
|
}
|
||||||
|
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
|
||||||
|
}) as typeof setGlobalStore
|
||||||
|
|
||||||
|
if (projectInit instanceof Promise) {
|
||||||
|
void projectInit.then(() => {
|
||||||
|
if (!active) return
|
||||||
|
if (projectWritten) return
|
||||||
|
const cached = projectCache.value
|
||||||
|
if (cached.length === 0) return
|
||||||
|
setGlobalStore("project", cached)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
|
const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
if (!todos) {
|
if (!todos) {
|
||||||
|
|
@ -127,30 +175,6 @@ function createGlobalSync() {
|
||||||
return sdk
|
return sdk
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!projectCacheReady()) return
|
|
||||||
if (globalStore.project.length !== 0) return
|
|
||||||
const cached = projectCache.value
|
|
||||||
if (cached.length === 0) return
|
|
||||||
setGlobalStore("project", cached)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!projectCacheReady()) return
|
|
||||||
const projects = globalStore.project
|
|
||||||
if (projects.length === 0) {
|
|
||||||
const cachedLength = untrack(() => projectCache.value.length)
|
|
||||||
if (cachedLength !== 0) return
|
|
||||||
}
|
|
||||||
setProjectCache("value", projects.map(sanitizeProject))
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (globalStore.reload !== "complete") return
|
|
||||||
setGlobalStore("reload", undefined)
|
|
||||||
queue.refresh()
|
|
||||||
})
|
|
||||||
|
|
||||||
async function loadSessions(directory: string) {
|
async function loadSessions(directory: string) {
|
||||||
const pending = sessionLoads.get(directory)
|
const pending = sessionLoads.get(directory)
|
||||||
if (pending) return pending
|
if (pending) return pending
|
||||||
|
|
@ -259,13 +283,7 @@ function createGlobalSync() {
|
||||||
event,
|
event,
|
||||||
project: globalStore.project,
|
project: globalStore.project,
|
||||||
refresh: queue.refresh,
|
refresh: queue.refresh,
|
||||||
setGlobalProject(next) {
|
setGlobalProject: setProjects,
|
||||||
if (typeof next === "function") {
|
|
||||||
setGlobalStore("project", produce(next))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setGlobalStore("project", next)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
if (event.type === "server.connected" || event.type === "global.disposed") {
|
if (event.type === "server.connected" || event.type === "global.disposed") {
|
||||||
for (const directory of Object.keys(children.children)) {
|
for (const directory of Object.keys(children.children)) {
|
||||||
|
|
@ -316,7 +334,7 @@ function createGlobalSync() {
|
||||||
unknownError: language.t("error.chain.unknown"),
|
unknownError: language.t("error.chain.unknown"),
|
||||||
invalidConfigurationError: language.t("error.server.invalidConfiguration"),
|
invalidConfigurationError: language.t("error.server.invalidConfiguration"),
|
||||||
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
||||||
setGlobalStore,
|
setGlobalStore: setBootStore,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -340,7 +358,9 @@ function createGlobalSync() {
|
||||||
.update({ config })
|
.update({ config })
|
||||||
.then(bootstrap)
|
.then(bootstrap)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setGlobalStore("reload", "complete")
|
queue.refresh()
|
||||||
|
setGlobalStore("reload", undefined)
|
||||||
|
queue.refresh()
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
setGlobalStore("reload", undefined)
|
setGlobalStore("reload", undefined)
|
||||||
|
|
@ -350,7 +370,7 @@ function createGlobalSync() {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: globalStore,
|
data: globalStore,
|
||||||
set: setGlobalStore,
|
set,
|
||||||
get ready() {
|
get ready() {
|
||||||
return globalStore.ready
|
return globalStore.ready
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { createRoot, createEffect, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js"
|
import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
|
||||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
|
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
@ -131,8 +131,7 @@ export function createChildStoreManager(input: {
|
||||||
)
|
)
|
||||||
if (!vcs) throw new Error("Failed to create persisted cache")
|
if (!vcs) throw new Error("Failed to create persisted cache")
|
||||||
const vcsStore = vcs[0]
|
const vcsStore = vcs[0]
|
||||||
const vcsReady = vcs[3]
|
vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcs[3] })
|
||||||
vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcsReady })
|
|
||||||
|
|
||||||
const meta = runWithOwner(input.owner, () =>
|
const meta = runWithOwner(input.owner, () =>
|
||||||
persisted(
|
persisted(
|
||||||
|
|
@ -154,10 +153,12 @@ export function createChildStoreManager(input: {
|
||||||
|
|
||||||
const init = () =>
|
const init = () =>
|
||||||
createRoot((dispose) => {
|
createRoot((dispose) => {
|
||||||
|
const initialMeta = meta[0].value
|
||||||
|
const initialIcon = icon[0].value
|
||||||
const child = createStore<State>({
|
const child = createStore<State>({
|
||||||
project: "",
|
project: "",
|
||||||
projectMeta: meta[0].value,
|
projectMeta: initialMeta,
|
||||||
icon: icon[0].value,
|
icon: initialIcon,
|
||||||
provider: { all: [], connected: [], default: {} },
|
provider: { all: [], connected: [], default: {} },
|
||||||
config: {},
|
config: {},
|
||||||
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
||||||
|
|
@ -181,16 +182,27 @@ export function createChildStoreManager(input: {
|
||||||
children[directory] = child
|
children[directory] = child
|
||||||
disposers.set(directory, dispose)
|
disposers.set(directory, dispose)
|
||||||
|
|
||||||
createEffect(() => {
|
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
||||||
if (!vcsReady()) return
|
if (!(init instanceof Promise)) return
|
||||||
|
void init.then(() => {
|
||||||
|
if (children[directory] !== child) return
|
||||||
|
run()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onPersistedInit(vcs[2], () => {
|
||||||
const cached = vcsStore.value
|
const cached = vcsStore.value
|
||||||
if (!cached?.branch) return
|
if (!cached?.branch) return
|
||||||
child[1]("vcs", (value) => value ?? cached)
|
child[1]("vcs", (value) => value ?? cached)
|
||||||
})
|
})
|
||||||
createEffect(() => {
|
|
||||||
|
onPersistedInit(meta[2], () => {
|
||||||
|
if (child[0].projectMeta !== initialMeta) return
|
||||||
child[1]("projectMeta", meta[0].value)
|
child[1]("projectMeta", meta[0].value)
|
||||||
})
|
})
|
||||||
createEffect(() => {
|
|
||||||
|
onPersistedInit(icon[2], () => {
|
||||||
|
if (child[0].icon !== initialIcon) return
|
||||||
child[1]("icon", icon[0].value)
|
child[1]("icon", icon[0].value)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,10 @@ import { useServer } from "./server"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
import { Project } from "@opencode-ai/sdk/v2"
|
import { Project } from "@opencode-ai/sdk/v2"
|
||||||
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
||||||
|
import { decode64 } from "@/utils/base64"
|
||||||
import { same } from "@/utils/same"
|
import { same } from "@/utils/same"
|
||||||
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
|
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
|
||||||
|
import { createPathHelpers } from "./file/path"
|
||||||
|
|
||||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||||
const DEFAULT_PANEL_WIDTH = 344
|
const DEFAULT_PANEL_WIDTH = 344
|
||||||
|
|
@ -96,6 +98,38 @@ function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string):
|
||||||
return { all, active: tab }
|
return { all, active: tab }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sessionPath = (key: string) => {
|
||||||
|
const dir = key.split("/")[0]
|
||||||
|
if (!dir) return
|
||||||
|
const root = decode64(dir)
|
||||||
|
if (!root) return
|
||||||
|
return createPathHelpers(() => root)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeSessionTab = (path: ReturnType<typeof createPathHelpers> | undefined, tab: string) => {
|
||||||
|
if (!tab.startsWith("file://")) return tab
|
||||||
|
if (!path) return tab
|
||||||
|
return path.tab(tab)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeSessionTabList = (path: ReturnType<typeof createPathHelpers> | undefined, all: string[]) => {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
return all.flatMap((tab) => {
|
||||||
|
const value = normalizeSessionTab(path, tab)
|
||||||
|
if (seen.has(value)) return []
|
||||||
|
seen.add(value)
|
||||||
|
return [value]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
|
||||||
|
const path = sessionPath(key)
|
||||||
|
return {
|
||||||
|
all: normalizeSessionTabList(path, tabs.all),
|
||||||
|
active: tabs.active ? normalizeSessionTab(path, tabs.active) : tabs.active,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
|
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
|
||||||
name: "Layout",
|
name: "Layout",
|
||||||
init: () => {
|
init: () => {
|
||||||
|
|
@ -147,12 +181,46 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
if (migratedSidebar === sidebar && migratedReview === review && migratedFileTree === fileTree) return value
|
const sessionTabs = value.sessionTabs
|
||||||
|
const migratedSessionTabs = (() => {
|
||||||
|
if (!isRecord(sessionTabs)) return sessionTabs
|
||||||
|
|
||||||
|
let changed = false
|
||||||
|
const next = Object.fromEntries(
|
||||||
|
Object.entries(sessionTabs).map(([key, tabs]) => {
|
||||||
|
if (!isRecord(tabs) || !Array.isArray(tabs.all)) return [key, tabs]
|
||||||
|
|
||||||
|
const current = {
|
||||||
|
all: tabs.all.filter((tab): tab is string => typeof tab === "string"),
|
||||||
|
active: typeof tabs.active === "string" ? tabs.active : undefined,
|
||||||
|
}
|
||||||
|
const normalized = normalizeStoredSessionTabs(key, current)
|
||||||
|
if (current.all.length !== tabs.all.length) changed = true
|
||||||
|
if (!same(current.all, normalized.all) || current.active !== normalized.active) changed = true
|
||||||
|
if (tabs.active !== undefined && typeof tabs.active !== "string") changed = true
|
||||||
|
return [key, normalized]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!changed) return sessionTabs
|
||||||
|
return next
|
||||||
|
})()
|
||||||
|
|
||||||
|
if (
|
||||||
|
migratedSidebar === sidebar &&
|
||||||
|
migratedReview === review &&
|
||||||
|
migratedFileTree === fileTree &&
|
||||||
|
migratedSessionTabs === sessionTabs
|
||||||
|
) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...value,
|
...value,
|
||||||
sidebar: migratedSidebar,
|
sidebar: migratedSidebar,
|
||||||
review: migratedReview,
|
review: migratedReview,
|
||||||
fileTree: migratedFileTree,
|
fileTree: migratedFileTree,
|
||||||
|
sessionTabs: migratedSessionTabs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -745,22 +813,26 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||||
},
|
},
|
||||||
tabs(sessionKey: string | Accessor<string>) {
|
tabs(sessionKey: string | Accessor<string>) {
|
||||||
const key = createSessionKeyReader(sessionKey, ensureKey)
|
const key = createSessionKeyReader(sessionKey, ensureKey)
|
||||||
|
const path = createMemo(() => sessionPath(key()))
|
||||||
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
|
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
|
||||||
|
const normalize = (tab: string) => normalizeSessionTab(path(), tab)
|
||||||
|
const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all)
|
||||||
return {
|
return {
|
||||||
tabs,
|
tabs,
|
||||||
active: createMemo(() => tabs().active),
|
active: createMemo(() => tabs().active),
|
||||||
all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
|
all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
|
||||||
setActive(tab: string | undefined) {
|
setActive(tab: string | undefined) {
|
||||||
const session = key()
|
const session = key()
|
||||||
|
const next = tab ? normalize(tab) : tab
|
||||||
if (!store.sessionTabs[session]) {
|
if (!store.sessionTabs[session]) {
|
||||||
setStore("sessionTabs", session, { all: [], active: tab })
|
setStore("sessionTabs", session, { all: [], active: next })
|
||||||
} else {
|
} else {
|
||||||
setStore("sessionTabs", session, "active", tab)
|
setStore("sessionTabs", session, "active", next)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setAll(all: string[]) {
|
setAll(all: string[]) {
|
||||||
const session = key()
|
const session = key()
|
||||||
const next = all.filter((tab) => tab !== "review")
|
const next = normalizeAll(all).filter((tab) => tab !== "review")
|
||||||
if (!store.sessionTabs[session]) {
|
if (!store.sessionTabs[session]) {
|
||||||
setStore("sessionTabs", session, { all: next, active: undefined })
|
setStore("sessionTabs", session, { all: next, active: undefined })
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -769,7 +841,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||||
},
|
},
|
||||||
async open(tab: string) {
|
async open(tab: string) {
|
||||||
const session = key()
|
const session = key()
|
||||||
const next = nextSessionTabsForOpen(store.sessionTabs[session], tab)
|
const next = nextSessionTabsForOpen(store.sessionTabs[session], normalize(tab))
|
||||||
setStore("sessionTabs", session, next)
|
setStore("sessionTabs", session, next)
|
||||||
},
|
},
|
||||||
close(tab: string) {
|
close(tab: string) {
|
||||||
|
|
|
||||||
|
|
@ -31,13 +31,13 @@ describe("autoRespondsPermission", () => {
|
||||||
expect(autoRespondsPermission({ root: true }, sessions, permission("child"), "/tmp/project")).toBe(true)
|
expect(autoRespondsPermission({ root: true }, sessions, permission("child"), "/tmp/project")).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("defaults to auto-accept when no lineage override exists", () => {
|
test("defaults to requiring approval when no lineage override exists", () => {
|
||||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" }), session({ id: "other" })]
|
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" }), session({ id: "other" })]
|
||||||
const autoAccept = {
|
const autoAccept = {
|
||||||
other: true,
|
other: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), "/tmp/project")).toBe(true)
|
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), "/tmp/project")).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("inherits a parent session's false override", () => {
|
test("inherits a parent session's false override", () => {
|
||||||
|
|
|
||||||
|
|
@ -37,5 +37,5 @@ export function autoRespondsPermission(
|
||||||
const value = sessionLineage(session, permission.sessionID)
|
const value = sessionLineage(session, permission.sessionID)
|
||||||
.map((id) => accepted(autoAccept, id, directory))
|
.map((id) => accepted(autoAccept, id, directory))
|
||||||
.find((item): item is boolean => item !== undefined)
|
.find((item): item is boolean => item !== undefined)
|
||||||
return value ?? true
|
return value ?? false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,12 +43,11 @@ type OptimisticRemoveInput = {
|
||||||
|
|
||||||
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
|
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
|
||||||
const messages = draft.message[input.sessionID]
|
const messages = draft.message[input.sessionID]
|
||||||
if (!messages) {
|
|
||||||
draft.message[input.sessionID] = [input.message]
|
|
||||||
}
|
|
||||||
if (messages) {
|
if (messages) {
|
||||||
const result = Binary.search(messages, input.message.id, (m) => m.id)
|
const result = Binary.search(messages, input.message.id, (m) => m.id)
|
||||||
messages.splice(result.index, 0, input.message)
|
messages.splice(result.index, 0, input.message)
|
||||||
|
} else {
|
||||||
|
draft.message[input.sessionID] = [input.message]
|
||||||
}
|
}
|
||||||
draft.part[input.message.id] = sortParts(input.parts)
|
draft.part[input.message.id] = sortParts(input.parts)
|
||||||
}
|
}
|
||||||
|
|
@ -105,7 +104,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
return globalSync.child(directory)
|
return globalSync.child(directory)
|
||||||
}
|
}
|
||||||
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
||||||
const messagePageSize = 400
|
const messagePageSize = 200
|
||||||
const inflight = new Map<string, Promise<void>>()
|
const inflight = new Map<string, Promise<void>>()
|
||||||
const inflightDiff = new Map<string, Promise<void>>()
|
const inflightDiff = new Map<string, Promise<void>>()
|
||||||
const inflightTodo = new Map<string, Promise<void>>()
|
const inflightTodo = new Map<string, Promise<void>>()
|
||||||
|
|
@ -122,20 +121,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const limitFor = (count: number) => {
|
|
||||||
if (count <= messagePageSize) return messagePageSize
|
|
||||||
return Math.ceil(count / messagePageSize) * messagePageSize
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => {
|
const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => {
|
||||||
const messages = await retry(() =>
|
const messages = await retry(() =>
|
||||||
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
|
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
|
||||||
)
|
)
|
||||||
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
||||||
const session = items
|
const session = items.map((x) => x.info).sort((a, b) => cmp(a.id, b.id))
|
||||||
.map((x) => x.info)
|
|
||||||
.filter((m) => !!m?.id)
|
|
||||||
.sort((a, b) => cmp(a.id, b.id))
|
|
||||||
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
|
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
|
||||||
return {
|
return {
|
||||||
session,
|
session,
|
||||||
|
|
@ -159,8 +150,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
.then((next) => {
|
.then((next) => {
|
||||||
batch(() => {
|
batch(() => {
|
||||||
input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
|
input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
|
||||||
for (const message of next.part) {
|
for (const p of next.part) {
|
||||||
input.setStore("part", message.id, reconcile(message.part, { key: "id" }))
|
input.setStore("part", p.id, p.part)
|
||||||
}
|
}
|
||||||
setMeta("limit", key, input.limit)
|
setMeta("limit", key, input.limit)
|
||||||
setMeta("complete", key, next.complete)
|
setMeta("complete", key, next.complete)
|
||||||
|
|
@ -229,17 +220,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
const client = sdk.client
|
const client = sdk.client
|
||||||
const [store, setStore] = globalSync.child(directory)
|
const [store, setStore] = globalSync.child(directory)
|
||||||
const key = keyFor(directory, sessionID)
|
const key = keyFor(directory, sessionID)
|
||||||
const hasSession = (() => {
|
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
|
||||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
|
||||||
return match.found
|
|
||||||
})()
|
|
||||||
|
|
||||||
const hasMessages = store.message[sessionID] !== undefined
|
const limit = meta.limit[key] ?? messagePageSize
|
||||||
const hydrated = meta.limit[key] !== undefined
|
|
||||||
if (hasSession && hasMessages && hydrated) return
|
|
||||||
|
|
||||||
const count = store.message[sessionID]?.length ?? 0
|
|
||||||
const limit = hydrated ? (meta.limit[key] ?? messagePageSize) : limitFor(count)
|
|
||||||
|
|
||||||
const sessionReq = hasSession
|
const sessionReq = hasSession
|
||||||
? Promise.resolve()
|
? Promise.resolve()
|
||||||
|
|
@ -259,16 +242,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const messagesReq =
|
const messagesReq = loadMessages({
|
||||||
hasMessages && hydrated
|
directory,
|
||||||
? Promise.resolve()
|
client,
|
||||||
: loadMessages({
|
setStore,
|
||||||
directory,
|
sessionID,
|
||||||
client,
|
limit,
|
||||||
setStore,
|
})
|
||||||
sessionID,
|
|
||||||
limit,
|
|
||||||
})
|
|
||||||
|
|
||||||
return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {}))
|
return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {}))
|
||||||
},
|
},
|
||||||
|
|
@ -290,14 +270,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
const client = sdk.client
|
const client = sdk.client
|
||||||
const [store, setStore] = globalSync.child(directory)
|
const [store, setStore] = globalSync.child(directory)
|
||||||
const existing = store.todo[sessionID]
|
const existing = store.todo[sessionID]
|
||||||
|
const cached = globalSync.data.session_todo[sessionID]
|
||||||
if (existing !== undefined) {
|
if (existing !== undefined) {
|
||||||
if (globalSync.data.session_todo[sessionID] === undefined) {
|
if (cached === undefined) {
|
||||||
globalSync.todo.set(sessionID, existing)
|
globalSync.todo.set(sessionID, existing)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const cached = globalSync.data.session_todo[sessionID]
|
|
||||||
if (cached !== undefined) {
|
if (cached !== undefined) {
|
||||||
setStore("todo", sessionID, reconcile(cached, { key: "id" }))
|
setStore("todo", sessionID, reconcile(cached, { key: "id" }))
|
||||||
}
|
}
|
||||||
|
|
@ -324,11 +304,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
const key = keyFor(sdk.directory, sessionID)
|
const key = keyFor(sdk.directory, sessionID)
|
||||||
return meta.loading[key] ?? false
|
return meta.loading[key] ?? false
|
||||||
},
|
},
|
||||||
async loadMore(sessionID: string, count = messagePageSize) {
|
async loadMore(sessionID: string, count?: number) {
|
||||||
const directory = sdk.directory
|
const directory = sdk.directory
|
||||||
const client = sdk.client
|
const client = sdk.client
|
||||||
const [, setStore] = globalSync.child(directory)
|
const [, setStore] = globalSync.child(directory)
|
||||||
const key = keyFor(directory, sessionID)
|
const key = keyFor(directory, sessionID)
|
||||||
|
const step = count ?? messagePageSize
|
||||||
if (meta.loading[key]) return
|
if (meta.loading[key]) return
|
||||||
if (meta.complete[key]) return
|
if (meta.complete[key]) return
|
||||||
|
|
||||||
|
|
@ -338,7 +319,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
client,
|
client,
|
||||||
setStore,
|
setStore,
|
||||||
sessionID,
|
sessionID,
|
||||||
limit: currentLimit + count,
|
limit: currentLimit + step,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ import { Binary } from "@opencode-ai/util/binary"
|
||||||
import { retry } from "@opencode-ai/util/retry"
|
import { retry } from "@opencode-ai/util/retry"
|
||||||
import { playSound, soundSrc } from "@/utils/sound"
|
import { playSound, soundSrc } from "@/utils/sound"
|
||||||
import { createAim } from "@/utils/aim"
|
import { createAim } from "@/utils/aim"
|
||||||
|
import { setNavigate } from "@/utils/notification-click"
|
||||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||||
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
|
@ -59,11 +60,11 @@ import { useLanguage, type Locale } from "@/context/language"
|
||||||
import {
|
import {
|
||||||
childMapByParent,
|
childMapByParent,
|
||||||
displayName,
|
displayName,
|
||||||
|
effectiveWorkspaceOrder,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
getDraggableId,
|
getDraggableId,
|
||||||
latestRootSession,
|
latestRootSession,
|
||||||
sortedRootSessions,
|
sortedRootSessions,
|
||||||
syncWorkspaceOrder,
|
|
||||||
workspaceKey,
|
workspaceKey,
|
||||||
} from "./layout/helpers"
|
} from "./layout/helpers"
|
||||||
import { collectOpenProjectDeepLinks, deepLinkEvent, drainPendingDeepLinks } from "./layout/deep-links"
|
import { collectOpenProjectDeepLinks, deepLinkEvent, drainPendingDeepLinks } from "./layout/deep-links"
|
||||||
|
|
@ -107,6 +108,7 @@ export default function Layout(props: ParentProps) {
|
||||||
const notification = useNotification()
|
const notification = useNotification()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
setNavigate(navigate)
|
||||||
const providers = useProviders()
|
const providers = useProviders()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
|
|
@ -481,21 +483,6 @@ export default function Layout(props: ParentProps) {
|
||||||
return projects.find((p) => p.worktree === root)
|
return projects.find((p) => p.worktree === root)
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => ({ ready: pageReady(), project: currentProject() }),
|
|
||||||
(value) => {
|
|
||||||
if (!value.ready) return
|
|
||||||
const project = value.project
|
|
||||||
if (!project) return
|
|
||||||
const last = server.projects.last()
|
|
||||||
if (last === project.worktree) return
|
|
||||||
server.projects.touch(project.worktree)
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => ({ ready: pageReady(), layoutReady: layoutReady(), dir: params.dir, list: layout.projects.list() }),
|
() => ({ ready: pageReady(), layoutReady: layoutReady(), dir: params.dir, list: layout.projects.list() }),
|
||||||
|
|
@ -554,29 +541,17 @@ export default function Layout(props: ParentProps) {
|
||||||
return layout.sidebar.workspaces(project.worktree)()
|
return layout.sidebar.workspaces(project.worktree)()
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
const visibleSessionDirs = createMemo(() => {
|
||||||
if (!pageReady()) return
|
|
||||||
if (!layoutReady()) return
|
|
||||||
const project = currentProject()
|
const project = currentProject()
|
||||||
if (!project) return
|
if (!project) return [] as string[]
|
||||||
|
if (!workspaceSetting()) return [project.worktree]
|
||||||
|
|
||||||
const local = project.worktree
|
const activeDir = currentDir()
|
||||||
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
|
return workspaceIds(project).filter((directory) => {
|
||||||
const existing = store.workspaceOrder[project.worktree]
|
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
|
||||||
const merged = syncWorkspaceOrder(local, dirs, existing)
|
const active = directory === activeDir
|
||||||
if (!existing) {
|
return expanded || active
|
||||||
setStore("workspaceOrder", project.worktree, merged)
|
})
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (merged.length !== existing.length) {
|
|
||||||
setStore("workspaceOrder", project.worktree, merged)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (merged.some((d, i) => d !== existing[i])) {
|
|
||||||
setStore("workspaceOrder", project.worktree, merged)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|
@ -593,25 +568,17 @@ export default function Layout(props: ParentProps) {
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentSessions = createMemo(() => {
|
const currentSessions = createMemo(() => {
|
||||||
const project = currentProject()
|
|
||||||
if (!project) return [] as Session[]
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (workspaceSetting()) {
|
const dirs = visibleSessionDirs()
|
||||||
const dirs = workspaceIds(project)
|
if (dirs.length === 0) return [] as Session[]
|
||||||
const activeDir = currentDir()
|
|
||||||
const result: Session[] = []
|
const result: Session[] = []
|
||||||
for (const dir of dirs) {
|
for (const dir of dirs) {
|
||||||
const expanded = store.workspaceExpanded[dir] ?? dir === project.worktree
|
const [dirStore] = globalSync.child(dir, { bootstrap: true })
|
||||||
const active = dir === activeDir
|
const dirSessions = sortedRootSessions(dirStore, now)
|
||||||
if (!expanded && !active) continue
|
result.push(...dirSessions)
|
||||||
const [dirStore] = globalSync.child(dir, { bootstrap: true })
|
|
||||||
const dirSessions = sortedRootSessions(dirStore, now)
|
|
||||||
result.push(...dirSessions)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
const [projectStore] = globalSync.child(project.worktree)
|
return result
|
||||||
return sortedRootSessions(projectStore, now)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
type PrefetchQueue = {
|
type PrefetchQueue = {
|
||||||
|
|
@ -826,7 +793,6 @@ export default function Layout(props: ParentProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
navigateToSession(session)
|
navigateToSession(session)
|
||||||
queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateSessionByUnseen(offset: number) {
|
function navigateSessionByUnseen(offset: number) {
|
||||||
|
|
@ -861,7 +827,6 @@ export default function Layout(props: ParentProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
navigateToSession(session)
|
navigateToSession(session)
|
||||||
queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1094,34 +1059,90 @@ export default function Layout(props: ParentProps) {
|
||||||
return meta?.worktree ?? directory
|
return meta?.worktree ?? directory
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function activeProjectRoot(directory: string) {
|
||||||
|
return currentProject()?.worktree ?? projectRoot(directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
function touchProjectRoute() {
|
||||||
|
const root = currentProject()?.worktree
|
||||||
|
if (!root) return
|
||||||
|
if (server.projects.last() !== root) server.projects.touch(root)
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
|
||||||
|
setStore("lastProjectSession", root, { directory, id, at: Date.now() })
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLastProjectSession(root: string) {
|
||||||
|
if (!store.lastProjectSession[root]) return
|
||||||
|
setStore(
|
||||||
|
"lastProjectSession",
|
||||||
|
produce((draft) => {
|
||||||
|
delete draft[root]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
|
||||||
|
rememberSessionRoute(directory, id, root)
|
||||||
|
notification.session.markViewed(id)
|
||||||
|
const expanded = untrack(() => store.workspaceExpanded[directory])
|
||||||
|
if (expanded === false) {
|
||||||
|
setStore("workspaceExpanded", directory, true)
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => scrollToSession(id, `${directory}:${id}`))
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
async function navigateToProject(directory: string | undefined) {
|
async function navigateToProject(directory: string | undefined) {
|
||||||
if (!directory) return
|
if (!directory) return
|
||||||
const root = projectRoot(directory)
|
const root = projectRoot(directory)
|
||||||
server.projects.touch(root)
|
server.projects.touch(root)
|
||||||
const project = layout.projects.list().find((item) => item.worktree === root)
|
const project = layout.projects.list().find((item) => item.worktree === root)
|
||||||
const dirs = Array.from(new Set([root, ...(store.workspaceOrder[root] ?? []), ...(project?.sandboxes ?? [])]))
|
let dirs = project
|
||||||
|
? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
|
||||||
|
: [root]
|
||||||
|
const canOpen = (value: string | undefined) => {
|
||||||
|
if (!value) return false
|
||||||
|
return dirs.some((item) => workspaceKey(item) === workspaceKey(value))
|
||||||
|
}
|
||||||
|
const refreshDirs = async (target?: string) => {
|
||||||
|
if (!target || target === root || canOpen(target)) return canOpen(target)
|
||||||
|
const listed = await globalSDK.client.worktree
|
||||||
|
.list({ directory: root })
|
||||||
|
.then((x) => x.data ?? [])
|
||||||
|
.catch(() => [] as string[])
|
||||||
|
dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
|
||||||
|
return canOpen(target)
|
||||||
|
}
|
||||||
const openSession = async (target: { directory: string; id: string }) => {
|
const openSession = async (target: { directory: string; id: string }) => {
|
||||||
|
if (!canOpen(target.directory)) return false
|
||||||
const resolved = await globalSDK.client.session
|
const resolved = await globalSDK.client.session
|
||||||
.get({ sessionID: target.id })
|
.get({ sessionID: target.id })
|
||||||
.then((x) => x.data)
|
.then((x) => x.data)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
const next = resolved?.directory ? resolved : target
|
if (!resolved?.directory) return false
|
||||||
setStore("lastProjectSession", root, { directory: next.directory, id: next.id, at: Date.now() })
|
if (!canOpen(resolved.directory)) return false
|
||||||
navigateWithSidebarReset(`/${base64Encode(next.directory)}/session/${next.id}`)
|
setStore("lastProjectSession", root, { directory: resolved.directory, id: resolved.id, at: Date.now() })
|
||||||
|
navigateWithSidebarReset(`/${base64Encode(resolved.directory)}/session/${resolved.id}`)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectSession = store.lastProjectSession[root]
|
const projectSession = store.lastProjectSession[root]
|
||||||
if (projectSession?.id) {
|
if (projectSession?.id) {
|
||||||
await openSession(projectSession)
|
await refreshDirs(projectSession.directory)
|
||||||
return
|
const opened = await openSession(projectSession)
|
||||||
|
if (opened) return
|
||||||
|
clearLastProjectSession(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
const latest = latestRootSession(
|
const latest = latestRootSession(
|
||||||
dirs.map((item) => globalSync.child(item, { bootstrap: false })[0]),
|
dirs.map((item) => globalSync.child(item, { bootstrap: false })[0]),
|
||||||
Date.now(),
|
Date.now(),
|
||||||
)
|
)
|
||||||
if (latest) {
|
if (latest && (await openSession(latest))) {
|
||||||
await openSession(latest)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1137,8 +1158,7 @@ export default function Layout(props: ParentProps) {
|
||||||
),
|
),
|
||||||
Date.now(),
|
Date.now(),
|
||||||
)
|
)
|
||||||
if (fetched) {
|
if (fetched && (await openSession(fetched))) {
|
||||||
await openSession(fetched)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1195,11 +1215,28 @@ export default function Layout(props: ParentProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeProject(directory: string) {
|
function closeProject(directory: string) {
|
||||||
const index = layout.projects.list().findIndex((x) => x.worktree === directory)
|
const list = layout.projects.list()
|
||||||
const next = layout.projects.list()[index + 1]
|
const index = list.findIndex((x) => x.worktree === directory)
|
||||||
|
const active = currentProject()?.worktree === directory
|
||||||
|
if (index === -1) return
|
||||||
|
const next = list[index + 1]
|
||||||
|
|
||||||
|
if (!active) {
|
||||||
|
layout.projects.close(directory)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!next) {
|
||||||
|
layout.projects.close(directory)
|
||||||
|
navigate("/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
navigateWithSidebarReset(`/${base64Encode(next.worktree)}/session`)
|
||||||
layout.projects.close(directory)
|
layout.projects.close(directory)
|
||||||
if (next) navigateToProject(next.worktree)
|
queueMicrotask(() => {
|
||||||
else navigate("/")
|
void navigateToProject(next.worktree)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleProjectWorkspaces(project: LocalProject) {
|
function toggleProjectWorkspaces(project: LocalProject) {
|
||||||
|
|
@ -1240,9 +1277,17 @@ export default function Layout(props: ParentProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteWorkspace = async (root: string, directory: string) => {
|
const deleteWorkspace = async (root: string, directory: string, leaveDeletedWorkspace = false) => {
|
||||||
if (directory === root) return
|
if (directory === root) return
|
||||||
|
|
||||||
|
const current = currentDir()
|
||||||
|
const currentKey = workspaceKey(current)
|
||||||
|
const deletedKey = workspaceKey(directory)
|
||||||
|
const shouldLeave = leaveDeletedWorkspace || (!!params.dir && currentKey === deletedKey)
|
||||||
|
if (!leaveDeletedWorkspace && shouldLeave) {
|
||||||
|
navigateWithSidebarReset(`/${base64Encode(root)}/session`)
|
||||||
|
}
|
||||||
|
|
||||||
setBusy(directory, true)
|
setBusy(directory, true)
|
||||||
|
|
||||||
const result = await globalSDK.client.worktree
|
const result = await globalSDK.client.worktree
|
||||||
|
|
@ -1260,6 +1305,10 @@ export default function Layout(props: ParentProps) {
|
||||||
|
|
||||||
if (!result) return
|
if (!result) return
|
||||||
|
|
||||||
|
if (workspaceKey(store.lastProjectSession[root]?.directory ?? "") === workspaceKey(directory)) {
|
||||||
|
clearLastProjectSession(root)
|
||||||
|
}
|
||||||
|
|
||||||
globalSync.set(
|
globalSync.set(
|
||||||
"project",
|
"project",
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
|
|
@ -1273,8 +1322,18 @@ export default function Layout(props: ParentProps) {
|
||||||
layout.projects.close(directory)
|
layout.projects.close(directory)
|
||||||
layout.projects.open(root)
|
layout.projects.open(root)
|
||||||
|
|
||||||
if (params.dir && currentDir() === directory) {
|
if (shouldLeave) return
|
||||||
navigateToProject(root)
|
|
||||||
|
const nextCurrent = currentDir()
|
||||||
|
const nextKey = workspaceKey(nextCurrent)
|
||||||
|
const project = layout.projects.list().find((item) => item.worktree === root)
|
||||||
|
const dirs = project
|
||||||
|
? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
|
||||||
|
: [root]
|
||||||
|
const valid = dirs.some((item) => workspaceKey(item) === nextKey)
|
||||||
|
|
||||||
|
if (params.dir && projectRoot(nextCurrent) === root && !valid) {
|
||||||
|
navigateWithSidebarReset(`/${base64Encode(root)}/session`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1377,8 +1436,12 @@ export default function Layout(props: ParentProps) {
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
|
const leaveDeletedWorkspace = !!params.dir && workspaceKey(currentDir()) === workspaceKey(props.directory)
|
||||||
|
if (leaveDeletedWorkspace) {
|
||||||
|
navigateWithSidebarReset(`/${base64Encode(props.root)}/session`)
|
||||||
|
}
|
||||||
dialog.close()
|
dialog.close()
|
||||||
void deleteWorkspace(props.root, props.directory)
|
void deleteWorkspace(props.root, props.directory, leaveDeletedWorkspace)
|
||||||
}
|
}
|
||||||
|
|
||||||
const description = () => {
|
const description = () => {
|
||||||
|
|
@ -1486,26 +1549,42 @@ export default function Layout(props: ParentProps) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeRoute = {
|
||||||
|
session: "",
|
||||||
|
sessionProject: "",
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => ({ ready: pageReady(), dir: params.dir, id: params.id }),
|
() => [pageReady(), params.dir, params.id, currentProject()?.worktree] as const,
|
||||||
(value) => {
|
([ready, dir, id]) => {
|
||||||
if (!value.ready) return
|
if (!ready || !dir) {
|
||||||
const dir = value.dir
|
activeRoute.session = ""
|
||||||
const id = value.id
|
activeRoute.sessionProject = ""
|
||||||
if (!dir || !id) return
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const directory = decode64(dir)
|
const directory = decode64(dir)
|
||||||
if (!directory) return
|
if (!directory) return
|
||||||
const at = Date.now()
|
|
||||||
setStore("lastProjectSession", projectRoot(directory), { directory, id, at })
|
const root = touchProjectRoute() ?? activeProjectRoot(directory)
|
||||||
notification.session.markViewed(id)
|
|
||||||
const expanded = untrack(() => store.workspaceExpanded[directory])
|
if (!id) {
|
||||||
if (expanded === false) {
|
activeRoute.session = ""
|
||||||
setStore("workspaceExpanded", directory, true)
|
activeRoute.sessionProject = ""
|
||||||
|
return
|
||||||
}
|
}
|
||||||
requestAnimationFrame(() => scrollToSession(id, `${directory}:${id}`))
|
|
||||||
|
const session = `${dir}/${id}`
|
||||||
|
if (session !== activeRoute.session) {
|
||||||
|
activeRoute.session = session
|
||||||
|
activeRoute.sessionProject = syncSessionRoute(directory, id, root)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root === activeRoute.sessionProject) return
|
||||||
|
activeRoute.sessionProject = rememberSessionRoute(directory, id, root)
|
||||||
},
|
},
|
||||||
{ defer: true },
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1516,40 +1595,29 @@ export default function Layout(props: ParentProps) {
|
||||||
|
|
||||||
const loadedSessionDirs = new Set<string>()
|
const loadedSessionDirs = new Set<string>()
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(
|
||||||
const project = currentProject()
|
on(
|
||||||
const workspaces = workspaceSetting()
|
visibleSessionDirs,
|
||||||
const next = new Set<string>()
|
(dirs) => {
|
||||||
if (!project) {
|
if (dirs.length === 0) {
|
||||||
loadedSessionDirs.clear()
|
loadedSessionDirs.clear()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (workspaces) {
|
const next = new Set(dirs)
|
||||||
const activeDir = currentDir()
|
for (const directory of next) {
|
||||||
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
|
if (loadedSessionDirs.has(directory)) continue
|
||||||
for (const directory of dirs) {
|
globalSync.project.loadSessions(directory)
|
||||||
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
|
}
|
||||||
const active = directory === activeDir
|
|
||||||
if (!expanded && !active) continue
|
|
||||||
next.add(directory)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!workspaces) {
|
loadedSessionDirs.clear()
|
||||||
next.add(project.worktree)
|
for (const directory of next) {
|
||||||
}
|
loadedSessionDirs.add(directory)
|
||||||
|
}
|
||||||
for (const directory of next) {
|
},
|
||||||
if (loadedSessionDirs.has(directory)) continue
|
{ defer: true },
|
||||||
globalSync.project.loadSessions(directory)
|
),
|
||||||
}
|
)
|
||||||
|
|
||||||
loadedSessionDirs.clear()
|
|
||||||
for (const directory of next) {
|
|
||||||
loadedSessionDirs.add(directory)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function handleDragStart(event: unknown) {
|
function handleDragStart(event: unknown) {
|
||||||
const id = getDraggableId(event)
|
const id = getDraggableId(event)
|
||||||
|
|
@ -1583,14 +1651,11 @@ export default function Layout(props: ParentProps) {
|
||||||
const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined
|
const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined
|
||||||
const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
|
const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
|
||||||
|
|
||||||
const existing = store.workspaceOrder[project.worktree]
|
const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree])
|
||||||
if (!existing) return extra ? [...dirs, extra] : dirs
|
if (pending && extra) return [local, extra, ...ordered.filter((item) => item !== local)]
|
||||||
|
if (!extra) return ordered
|
||||||
const merged = syncWorkspaceOrder(local, dirs, existing)
|
if (pending) return ordered
|
||||||
if (pending && extra) return [local, extra, ...merged.filter((directory) => directory !== local)]
|
return [...ordered, extra]
|
||||||
if (!extra) return merged
|
|
||||||
if (pending) return merged
|
|
||||||
return [...merged, extra]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sidebarProject = createMemo(() => {
|
const sidebarProject = createMemo(() => {
|
||||||
|
|
@ -1623,7 +1688,11 @@ export default function Layout(props: ParentProps) {
|
||||||
const [item] = result.splice(fromIndex, 1)
|
const [item] = result.splice(fromIndex, 1)
|
||||||
if (!item) return
|
if (!item) return
|
||||||
result.splice(toIndex, 0, item)
|
result.splice(toIndex, 0, item)
|
||||||
setStore("workspaceOrder", project.worktree, result)
|
setStore(
|
||||||
|
"workspaceOrder",
|
||||||
|
project.worktree,
|
||||||
|
result.filter((directory) => workspaceKey(directory) !== workspaceKey(project.worktree)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleWorkspaceDragEnd() {
|
function handleWorkspaceDragEnd() {
|
||||||
|
|
@ -1661,10 +1730,9 @@ export default function Layout(props: ParentProps) {
|
||||||
const existing = prev ?? []
|
const existing = prev ?? []
|
||||||
const next = existing.filter((item) => {
|
const next = existing.filter((item) => {
|
||||||
const id = workspaceKey(item)
|
const id = workspaceKey(item)
|
||||||
if (id === root) return false
|
return id !== root && id !== key
|
||||||
return id !== key
|
|
||||||
})
|
})
|
||||||
return [local, created.directory, ...next]
|
return [created.directory, ...next]
|
||||||
})
|
})
|
||||||
|
|
||||||
globalSync.child(created.directory)
|
globalSync.child(created.directory)
|
||||||
|
|
@ -2015,7 +2083,11 @@ export default function Layout(props: ParentProps) {
|
||||||
onOpenSettings={openSettings}
|
onOpenSettings={openSettings}
|
||||||
helpLabel={() => language.t("sidebar.help")}
|
helpLabel={() => language.t("sidebar.help")}
|
||||||
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||||
renderPanel={() => <SidebarPanel project={currentProject()} />}
|
renderPanel={() => (
|
||||||
|
<Show when={currentProject()} keyed>
|
||||||
|
{(project) => <SidebarPanel project={project} />}
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Show when={!layout.sidebar.opened() ? hoverProjectData()?.worktree : undefined} keyed>
|
<Show when={!layout.sidebar.opened() ? hoverProjectData()?.worktree : undefined} keyed>
|
||||||
|
|
|
||||||
|
|
@ -74,9 +74,29 @@ export const errorMessage = (err: unknown, fallback: string) => {
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
export const syncWorkspaceOrder = (local: string, dirs: string[], existing?: string[]) => {
|
export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted?: string[]) => {
|
||||||
if (!existing) return dirs
|
const root = workspaceKey(local)
|
||||||
const keep = existing.filter((d) => d !== local && dirs.includes(d))
|
const live = new Map<string, string>()
|
||||||
const missing = dirs.filter((d) => d !== local && !existing.includes(d))
|
|
||||||
return [local, ...missing, ...keep]
|
for (const dir of dirs) {
|
||||||
|
const key = workspaceKey(dir)
|
||||||
|
if (key === root) continue
|
||||||
|
if (!live.has(key)) live.set(key, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!persisted?.length) return [local, ...live.values()]
|
||||||
|
|
||||||
|
const result = [local]
|
||||||
|
for (const dir of persisted) {
|
||||||
|
const key = workspaceKey(dir)
|
||||||
|
if (key === root) continue
|
||||||
|
const match = live.get(key)
|
||||||
|
if (!match) continue
|
||||||
|
result.push(match)
|
||||||
|
live.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...result, ...live.values()]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const syncWorkspaceOrder = effectiveWorkspaceOrder
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,16 @@
|
||||||
import { onCleanup, Show, Match, Switch, createMemo, createEffect, on, onMount } from "solid-js"
|
import {
|
||||||
|
onCleanup,
|
||||||
|
Show,
|
||||||
|
Match,
|
||||||
|
Switch,
|
||||||
|
createMemo,
|
||||||
|
createEffect,
|
||||||
|
createComputed,
|
||||||
|
on,
|
||||||
|
onMount,
|
||||||
|
untrack,
|
||||||
|
createSignal,
|
||||||
|
} from "solid-js"
|
||||||
import { createMediaQuery } from "@solid-primitives/media"
|
import { createMediaQuery } from "@solid-primitives/media"
|
||||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
|
|
@ -32,6 +44,215 @@ import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
|
||||||
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||||
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
||||||
|
|
||||||
|
const emptyUserMessages: UserMessage[] = []
|
||||||
|
|
||||||
|
type SessionHistoryWindowInput = {
|
||||||
|
sessionID: () => string | undefined
|
||||||
|
messagesReady: () => boolean
|
||||||
|
visibleUserMessages: () => UserMessage[]
|
||||||
|
historyMore: () => boolean
|
||||||
|
historyLoading: () => boolean
|
||||||
|
loadMore: (sessionID: string) => Promise<void>
|
||||||
|
userScrolled: () => boolean
|
||||||
|
scroller: () => HTMLDivElement | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maintains the rendered history window for a session timeline.
|
||||||
|
*
|
||||||
|
* It keeps initial paint bounded to recent turns, reveals cached turns in
|
||||||
|
* small batches while scrolling upward, and prefetches older history near top.
|
||||||
|
*/
|
||||||
|
function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
|
||||||
|
const turnInit = 10
|
||||||
|
const turnBatch = 8
|
||||||
|
const turnScrollThreshold = 200
|
||||||
|
const turnPrefetchBuffer = 16
|
||||||
|
const prefetchCooldownMs = 400
|
||||||
|
const prefetchNoGrowthLimit = 2
|
||||||
|
|
||||||
|
const [state, setState] = createStore({
|
||||||
|
turnID: undefined as string | undefined,
|
||||||
|
turnStart: 0,
|
||||||
|
prefetchUntil: 0,
|
||||||
|
prefetchNoGrowth: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0)
|
||||||
|
|
||||||
|
const turnStart = createMemo(() => {
|
||||||
|
const id = input.sessionID()
|
||||||
|
const len = input.visibleUserMessages().length
|
||||||
|
if (!id || len <= 0) return 0
|
||||||
|
if (state.turnID !== id) return initialTurnStart(len)
|
||||||
|
if (state.turnStart <= 0) return 0
|
||||||
|
if (state.turnStart >= len) return initialTurnStart(len)
|
||||||
|
return state.turnStart
|
||||||
|
})
|
||||||
|
|
||||||
|
const setTurnStart = (start: number) => {
|
||||||
|
const id = input.sessionID()
|
||||||
|
const next = start > 0 ? start : 0
|
||||||
|
if (!id) {
|
||||||
|
setState({ turnID: undefined, turnStart: next })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setState({ turnID: id, turnStart: next })
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderedUserMessages = createMemo(
|
||||||
|
() => {
|
||||||
|
const msgs = input.visibleUserMessages()
|
||||||
|
const start = turnStart()
|
||||||
|
if (start <= 0) return msgs
|
||||||
|
return msgs.slice(start)
|
||||||
|
},
|
||||||
|
emptyUserMessages,
|
||||||
|
{
|
||||||
|
equals: same,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const preserveScroll = (fn: () => void) => {
|
||||||
|
const el = input.scroller()
|
||||||
|
if (!el) {
|
||||||
|
fn()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const beforeTop = el.scrollTop
|
||||||
|
const beforeHeight = el.scrollHeight
|
||||||
|
fn()
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const delta = el.scrollHeight - beforeHeight
|
||||||
|
if (!delta) return
|
||||||
|
el.scrollTop = beforeTop + delta
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const backfillTurns = () => {
|
||||||
|
const start = turnStart()
|
||||||
|
if (start <= 0) return
|
||||||
|
|
||||||
|
const next = start - turnBatch
|
||||||
|
const nextStart = next > 0 ? next : 0
|
||||||
|
|
||||||
|
preserveScroll(() => setTurnStart(nextStart))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Button path: reveal all cached turns, fetch older history, reveal one batch. */
|
||||||
|
const loadAndReveal = async () => {
|
||||||
|
const id = input.sessionID()
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
const start = turnStart()
|
||||||
|
const beforeVisible = input.visibleUserMessages().length
|
||||||
|
|
||||||
|
if (start > 0) setTurnStart(0)
|
||||||
|
|
||||||
|
if (!input.historyMore() || input.historyLoading()) return
|
||||||
|
|
||||||
|
await input.loadMore(id)
|
||||||
|
if (input.sessionID() !== id) return
|
||||||
|
|
||||||
|
const afterVisible = input.visibleUserMessages().length
|
||||||
|
const growth = afterVisible - beforeVisible
|
||||||
|
if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0)
|
||||||
|
if (growth <= 0) return
|
||||||
|
if (turnStart() !== 0) return
|
||||||
|
|
||||||
|
const target = Math.min(afterVisible, Math.max(beforeVisible, renderedUserMessages().length) + turnBatch)
|
||||||
|
const nextStart = Math.max(0, afterVisible - target)
|
||||||
|
preserveScroll(() => setTurnStart(nextStart))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scroll/prefetch path: fetch older history from server. */
|
||||||
|
const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => {
|
||||||
|
const id = input.sessionID()
|
||||||
|
if (!id) return
|
||||||
|
if (!input.historyMore() || input.historyLoading()) return
|
||||||
|
|
||||||
|
if (opts?.prefetch) {
|
||||||
|
const now = Date.now()
|
||||||
|
if (state.prefetchUntil > now) return
|
||||||
|
if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return
|
||||||
|
setState("prefetchUntil", now + prefetchCooldownMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = turnStart()
|
||||||
|
const beforeVisible = input.visibleUserMessages().length
|
||||||
|
const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
|
||||||
|
|
||||||
|
await input.loadMore(id)
|
||||||
|
if (input.sessionID() !== id) return
|
||||||
|
|
||||||
|
const afterVisible = input.visibleUserMessages().length
|
||||||
|
const growth = afterVisible - beforeVisible
|
||||||
|
|
||||||
|
if (opts?.prefetch) {
|
||||||
|
setState("prefetchNoGrowth", growth > 0 ? 0 : state.prefetchNoGrowth + 1)
|
||||||
|
} else if (growth > 0 && state.prefetchNoGrowth) {
|
||||||
|
setState("prefetchNoGrowth", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (growth <= 0) return
|
||||||
|
if (turnStart() !== start) return
|
||||||
|
|
||||||
|
const reveal = !opts?.prefetch
|
||||||
|
const currentRendered = renderedUserMessages().length
|
||||||
|
const base = Math.max(beforeRendered, currentRendered)
|
||||||
|
const target = reveal ? Math.min(afterVisible, base + turnBatch) : base
|
||||||
|
const nextStart = Math.max(0, afterVisible - target)
|
||||||
|
preserveScroll(() => setTurnStart(nextStart))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onScrollerScroll = () => {
|
||||||
|
if (!input.userScrolled()) return
|
||||||
|
const el = input.scroller()
|
||||||
|
if (!el) return
|
||||||
|
if (el.scrollTop >= turnScrollThreshold) return
|
||||||
|
|
||||||
|
const start = turnStart()
|
||||||
|
if (start > 0) {
|
||||||
|
if (start <= turnPrefetchBuffer) {
|
||||||
|
void fetchOlderMessages({ prefetch: true })
|
||||||
|
}
|
||||||
|
backfillTurns()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void fetchOlderMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
input.sessionID,
|
||||||
|
() => {
|
||||||
|
setState({ prefetchUntil: 0, prefetchNoGrowth: 0 })
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => [input.sessionID(), input.messagesReady()] as const,
|
||||||
|
([id, ready]) => {
|
||||||
|
if (!id || !ready) return
|
||||||
|
setTurnStart(initialTurnStart(input.visibleUserMessages().length))
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
turnStart,
|
||||||
|
setTurnStart,
|
||||||
|
renderedUserMessages,
|
||||||
|
loadAndReveal,
|
||||||
|
onScrollerScroll,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
|
|
@ -138,24 +359,6 @@ export default function Page() {
|
||||||
if (path) file.load(path)
|
if (path) file.load(path)
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const current = tabs().all()
|
|
||||||
if (current.length === 0) return
|
|
||||||
|
|
||||||
const next = normalizeTabs(current)
|
|
||||||
if (same(current, next)) return
|
|
||||||
|
|
||||||
tabs().setAll(next)
|
|
||||||
|
|
||||||
const active = tabs().active()
|
|
||||||
if (!active) return
|
|
||||||
if (!active.startsWith("file://")) return
|
|
||||||
|
|
||||||
const normalized = normalizeTab(active)
|
|
||||||
if (active === normalized) return
|
|
||||||
tabs().setActive(normalized)
|
|
||||||
})
|
|
||||||
|
|
||||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||||
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
||||||
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
||||||
|
|
@ -178,7 +381,6 @@ export default function Page() {
|
||||||
return sync.session.history.loading(id)
|
return sync.session.history.loading(id)
|
||||||
})
|
})
|
||||||
|
|
||||||
const emptyUserMessages: UserMessage[] = []
|
|
||||||
const userMessages = createMemo(
|
const userMessages = createMemo(
|
||||||
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
||||||
emptyUserMessages,
|
emptyUserMessages,
|
||||||
|
|
@ -211,29 +413,26 @@ export default function Page() {
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
messageId: undefined as string | undefined,
|
messageId: undefined as string | undefined,
|
||||||
turnStart: 0,
|
|
||||||
mobileTab: "session" as "session" | "changes",
|
mobileTab: "session" as "session" | "changes",
|
||||||
changes: "session" as "session" | "turn",
|
changes: "session" as "session" | "turn",
|
||||||
newSessionWorktree: "main",
|
newSessionWorktree: "main",
|
||||||
|
deferRender: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createComputed((prev) => {
|
||||||
|
const key = sessionKey()
|
||||||
|
if (key !== prev) {
|
||||||
|
setStore("deferRender", true)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setTimeout(() => setStore("deferRender", false), 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}, sessionKey())
|
||||||
|
|
||||||
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
|
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
|
||||||
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
|
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
|
||||||
|
|
||||||
const renderedUserMessages = createMemo(
|
|
||||||
() => {
|
|
||||||
const msgs = visibleUserMessages()
|
|
||||||
const start = store.turnStart
|
|
||||||
if (start <= 0) return msgs
|
|
||||||
if (start >= msgs.length) return emptyUserMessages
|
|
||||||
return msgs.slice(start)
|
|
||||||
},
|
|
||||||
emptyUserMessages,
|
|
||||||
{
|
|
||||||
equals: same,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const newSessionWorktree = createMemo(() => {
|
const newSessionWorktree = createMemo(() => {
|
||||||
if (store.newSessionWorktree === "create") return "create"
|
if (store.newSessionWorktree === "create") return "create"
|
||||||
const project = sync.project
|
const project = sync.project
|
||||||
|
|
@ -302,13 +501,15 @@ export default function Page() {
|
||||||
|
|
||||||
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
|
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(
|
||||||
sdk.directory
|
on([() => sdk.directory, () => params.id] as const, ([, id]) => {
|
||||||
const id = params.id
|
if (!id) return
|
||||||
if (!id) return
|
untrack(() => {
|
||||||
void sync.session.sync(id)
|
void sync.session.sync(id)
|
||||||
void sync.session.todo(id)
|
void sync.session.todo(id)
|
||||||
})
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
|
|
@ -535,35 +736,12 @@ export default function Page() {
|
||||||
loadingClass: string
|
loadingClass: string
|
||||||
emptyClass: string
|
emptyClass: string
|
||||||
}) => (
|
}) => (
|
||||||
<Switch>
|
<Show when={!store.deferRender}>
|
||||||
<Match when={store.changes === "turn" && !!params.id}>
|
<Switch>
|
||||||
<SessionReviewTab
|
<Match when={store.changes === "turn" && !!params.id}>
|
||||||
title={changesTitle()}
|
|
||||||
empty={emptyTurn()}
|
|
||||||
diffs={reviewDiffs}
|
|
||||||
view={view}
|
|
||||||
diffStyle={input.diffStyle}
|
|
||||||
onDiffStyleChange={input.onDiffStyleChange}
|
|
||||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
|
||||||
focusedFile={tree.activeDiff}
|
|
||||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
|
||||||
onLineCommentUpdate={updateCommentInContext}
|
|
||||||
onLineCommentDelete={removeCommentFromContext}
|
|
||||||
lineCommentActions={reviewCommentActions()}
|
|
||||||
comments={comments.all()}
|
|
||||||
focusedComment={comments.focus()}
|
|
||||||
onFocusedCommentChange={comments.setFocus}
|
|
||||||
onViewFile={openReviewFile}
|
|
||||||
classes={input.classes}
|
|
||||||
/>
|
|
||||||
</Match>
|
|
||||||
<Match when={hasReview()}>
|
|
||||||
<Show
|
|
||||||
when={diffsReady()}
|
|
||||||
fallback={<div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>}
|
|
||||||
>
|
|
||||||
<SessionReviewTab
|
<SessionReviewTab
|
||||||
title={changesTitle()}
|
title={changesTitle()}
|
||||||
|
empty={emptyTurn()}
|
||||||
diffs={reviewDiffs}
|
diffs={reviewDiffs}
|
||||||
view={view}
|
view={view}
|
||||||
diffStyle={input.diffStyle}
|
diffStyle={input.diffStyle}
|
||||||
|
|
@ -580,39 +758,64 @@ export default function Page() {
|
||||||
onViewFile={openReviewFile}
|
onViewFile={openReviewFile}
|
||||||
classes={input.classes}
|
classes={input.classes}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Match>
|
||||||
</Match>
|
<Match when={hasReview()}>
|
||||||
<Match when={true}>
|
<Show
|
||||||
<SessionReviewTab
|
when={diffsReady()}
|
||||||
title={changesTitle()}
|
fallback={<div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>}
|
||||||
empty={
|
>
|
||||||
store.changes === "turn" ? (
|
<SessionReviewTab
|
||||||
emptyTurn()
|
title={changesTitle()}
|
||||||
) : (
|
diffs={reviewDiffs}
|
||||||
<div class={input.emptyClass}>
|
view={view}
|
||||||
<Mark class="w-14 opacity-10" />
|
diffStyle={input.diffStyle}
|
||||||
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
|
onDiffStyleChange={input.onDiffStyleChange}
|
||||||
</div>
|
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||||
)
|
focusedFile={tree.activeDiff}
|
||||||
}
|
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||||
diffs={reviewDiffs}
|
onLineCommentUpdate={updateCommentInContext}
|
||||||
view={view}
|
onLineCommentDelete={removeCommentFromContext}
|
||||||
diffStyle={input.diffStyle}
|
lineCommentActions={reviewCommentActions()}
|
||||||
onDiffStyleChange={input.onDiffStyleChange}
|
comments={comments.all()}
|
||||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
focusedComment={comments.focus()}
|
||||||
focusedFile={tree.activeDiff}
|
onFocusedCommentChange={comments.setFocus}
|
||||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
onViewFile={openReviewFile}
|
||||||
onLineCommentUpdate={updateCommentInContext}
|
classes={input.classes}
|
||||||
onLineCommentDelete={removeCommentFromContext}
|
/>
|
||||||
lineCommentActions={reviewCommentActions()}
|
</Show>
|
||||||
comments={comments.all()}
|
</Match>
|
||||||
focusedComment={comments.focus()}
|
<Match when={true}>
|
||||||
onFocusedCommentChange={comments.setFocus}
|
<SessionReviewTab
|
||||||
onViewFile={openReviewFile}
|
title={changesTitle()}
|
||||||
classes={input.classes}
|
empty={
|
||||||
/>
|
store.changes === "turn" ? (
|
||||||
</Match>
|
emptyTurn()
|
||||||
</Switch>
|
) : (
|
||||||
|
<div class={input.emptyClass}>
|
||||||
|
<Mark class="w-14 opacity-10" />
|
||||||
|
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
diffs={reviewDiffs}
|
||||||
|
view={view}
|
||||||
|
diffStyle={input.diffStyle}
|
||||||
|
onDiffStyleChange={input.onDiffStyleChange}
|
||||||
|
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||||
|
focusedFile={tree.activeDiff}
|
||||||
|
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||||
|
onLineCommentUpdate={updateCommentInContext}
|
||||||
|
onLineCommentDelete={removeCommentFromContext}
|
||||||
|
lineCommentActions={reviewCommentActions()}
|
||||||
|
comments={comments.all()}
|
||||||
|
focusedComment={comments.focus()}
|
||||||
|
onFocusedCommentChange={comments.setFocus}
|
||||||
|
onViewFile={openReviewFile}
|
||||||
|
classes={input.classes}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</Show>
|
||||||
)
|
)
|
||||||
|
|
||||||
const reviewPanel = () => (
|
const reviewPanel = () => (
|
||||||
|
|
@ -894,88 +1097,16 @@ export default function Page() {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const turnInit = 20
|
const historyWindow = createSessionHistoryWindow({
|
||||||
const turnBatch = 20
|
sessionID: () => params.id,
|
||||||
let turnHandle: number | undefined
|
messagesReady,
|
||||||
let turnIdle = false
|
visibleUserMessages,
|
||||||
|
historyMore,
|
||||||
function cancelTurnBackfill() {
|
historyLoading,
|
||||||
const handle = turnHandle
|
loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
|
||||||
if (handle === undefined) return
|
userScrolled: autoScroll.userScrolled,
|
||||||
turnHandle = undefined
|
scroller: () => scroller,
|
||||||
|
})
|
||||||
if (turnIdle && window.cancelIdleCallback) {
|
|
||||||
window.cancelIdleCallback(handle)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTimeout(handle)
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleTurnBackfill() {
|
|
||||||
if (turnHandle !== undefined) return
|
|
||||||
if (store.turnStart <= 0) return
|
|
||||||
|
|
||||||
if (window.requestIdleCallback) {
|
|
||||||
turnIdle = true
|
|
||||||
turnHandle = window.requestIdleCallback(() => {
|
|
||||||
turnHandle = undefined
|
|
||||||
backfillTurns()
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
turnIdle = false
|
|
||||||
turnHandle = window.setTimeout(() => {
|
|
||||||
turnHandle = undefined
|
|
||||||
backfillTurns()
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
function backfillTurns() {
|
|
||||||
const start = store.turnStart
|
|
||||||
if (start <= 0) return
|
|
||||||
|
|
||||||
const next = start - turnBatch
|
|
||||||
const nextStart = next > 0 ? next : 0
|
|
||||||
|
|
||||||
const el = scroller
|
|
||||||
if (!el) {
|
|
||||||
setStore("turnStart", nextStart)
|
|
||||||
scheduleTurnBackfill()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const beforeTop = el.scrollTop
|
|
||||||
const beforeHeight = el.scrollHeight
|
|
||||||
|
|
||||||
setStore("turnStart", nextStart)
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const delta = el.scrollHeight - beforeHeight
|
|
||||||
if (!delta) return
|
|
||||||
el.scrollTop = beforeTop + delta
|
|
||||||
})
|
|
||||||
|
|
||||||
scheduleTurnBackfill()
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => [params.id, messagesReady()] as const,
|
|
||||||
([id, ready]) => {
|
|
||||||
cancelTurnBackfill()
|
|
||||||
setStore("turnStart", 0)
|
|
||||||
if (!id || !ready) return
|
|
||||||
|
|
||||||
const len = visibleUserMessages().length
|
|
||||||
const start = len > turnInit ? len - turnInit : 0
|
|
||||||
setStore("turnStart", start)
|
|
||||||
scheduleTurnBackfill()
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
createResizeObserver(
|
createResizeObserver(
|
||||||
() => promptDock,
|
() => promptDock,
|
||||||
|
|
@ -986,7 +1117,9 @@ export default function Page() {
|
||||||
|
|
||||||
const el = scroller
|
const el = scroller
|
||||||
const delta = next - dockHeight
|
const delta = next - dockHeight
|
||||||
const stick = el ? el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta) : false
|
const stick = el
|
||||||
|
? !autoScroll.userScrolled() || el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta)
|
||||||
|
: false
|
||||||
|
|
||||||
dockHeight = next
|
dockHeight = next
|
||||||
|
|
||||||
|
|
@ -1002,13 +1135,12 @@ export default function Page() {
|
||||||
sessionID: () => params.id,
|
sessionID: () => params.id,
|
||||||
messagesReady,
|
messagesReady,
|
||||||
visibleUserMessages,
|
visibleUserMessages,
|
||||||
turnStart: () => store.turnStart,
|
turnStart: historyWindow.turnStart,
|
||||||
currentMessageId: () => store.messageId,
|
currentMessageId: () => store.messageId,
|
||||||
pendingMessage: () => ui.pendingMessage,
|
pendingMessage: () => ui.pendingMessage,
|
||||||
setPendingMessage: (value) => setUi("pendingMessage", value),
|
setPendingMessage: (value) => setUi("pendingMessage", value),
|
||||||
setActiveMessage,
|
setActiveMessage,
|
||||||
setTurnStart: (value) => setStore("turnStart", value),
|
setTurnStart: historyWindow.setTurnStart,
|
||||||
scheduleTurnBackfill,
|
|
||||||
autoScroll,
|
autoScroll,
|
||||||
scroller: () => scroller,
|
scroller: () => scroller,
|
||||||
anchor,
|
anchor,
|
||||||
|
|
@ -1021,7 +1153,6 @@ export default function Page() {
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
cancelTurnBackfill()
|
|
||||||
document.removeEventListener("keydown", handleKeyDown)
|
document.removeEventListener("keydown", handleKeyDown)
|
||||||
scrollSpy.destroy()
|
scrollSpy.destroy()
|
||||||
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
|
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
|
||||||
|
|
@ -1076,6 +1207,7 @@ export default function Page() {
|
||||||
hasScrollGesture={hasScrollGesture}
|
hasScrollGesture={hasScrollGesture}
|
||||||
isDesktop={isDesktop()}
|
isDesktop={isDesktop()}
|
||||||
onScrollSpyScroll={scrollSpy.onScroll}
|
onScrollSpyScroll={scrollSpy.onScroll}
|
||||||
|
onTurnBackfillScroll={historyWindow.onScrollerScroll}
|
||||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||||
centered={centered()}
|
centered={centered()}
|
||||||
setContentRef={(el) => {
|
setContentRef={(el) => {
|
||||||
|
|
@ -1085,21 +1217,16 @@ export default function Page() {
|
||||||
const root = scroller
|
const root = scroller
|
||||||
if (root) scheduleScrollState(root)
|
if (root) scheduleScrollState(root)
|
||||||
}}
|
}}
|
||||||
turnStart={store.turnStart}
|
turnStart={historyWindow.turnStart()}
|
||||||
onRenderEarlier={() => setStore("turnStart", 0)}
|
|
||||||
historyMore={historyMore()}
|
historyMore={historyMore()}
|
||||||
historyLoading={historyLoading()}
|
historyLoading={historyLoading()}
|
||||||
onLoadEarlier={() => {
|
onLoadEarlier={() => {
|
||||||
const id = params.id
|
void historyWindow.loadAndReveal()
|
||||||
if (!id) return
|
|
||||||
setStore("turnStart", 0)
|
|
||||||
sync.session.history.loadMore(id)
|
|
||||||
}}
|
}}
|
||||||
renderedUserMessages={renderedUserMessages()}
|
renderedUserMessages={historyWindow.renderedUserMessages()}
|
||||||
anchor={anchor}
|
anchor={anchor}
|
||||||
onRegisterMessage={scrollSpy.register}
|
onRegisterMessage={scrollSpy.register}
|
||||||
onUnregisterMessage={scrollSpy.unregister}
|
onUnregisterMessage={scrollSpy.unregister}
|
||||||
lastUserMessageID={lastUserMessage()?.id}
|
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</Match>
|
</Match>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Show, createEffect, createMemo } from "solid-js"
|
import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
|
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||||
import { PromptInput } from "@/components/prompt-input"
|
import { PromptInput } from "@/components/prompt-input"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePrompt } from "@/context/prompt"
|
import { usePrompt } from "@/context/prompt"
|
||||||
|
|
@ -18,6 +19,23 @@ export function SessionComposerRegion(props: {
|
||||||
onSubmit: () => void
|
onSubmit: () => void
|
||||||
onResponseSubmit: () => void
|
onResponseSubmit: () => void
|
||||||
setPromptDockRef: (el: HTMLDivElement) => void
|
setPromptDockRef: (el: HTMLDivElement) => void
|
||||||
|
visualDuration?: number
|
||||||
|
bounce?: number
|
||||||
|
dockOpenVisualDuration?: number
|
||||||
|
dockOpenBounce?: number
|
||||||
|
dockCloseVisualDuration?: number
|
||||||
|
dockCloseBounce?: number
|
||||||
|
drawerExpandVisualDuration?: number
|
||||||
|
drawerExpandBounce?: number
|
||||||
|
drawerCollapseVisualDuration?: number
|
||||||
|
drawerCollapseBounce?: number
|
||||||
|
subtitleDuration?: number
|
||||||
|
subtitleTravel?: number
|
||||||
|
subtitleEdge?: number
|
||||||
|
countDuration?: number
|
||||||
|
countMask?: number
|
||||||
|
countMaskHeight?: number
|
||||||
|
countWidthDuration?: number
|
||||||
}) {
|
}) {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
|
|
@ -43,6 +61,37 @@ export function SessionComposerRegion(props: {
|
||||||
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
|
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const open = createMemo(() => props.state.dock() && !props.state.closing())
|
||||||
|
const config = createMemo(() =>
|
||||||
|
open()
|
||||||
|
? {
|
||||||
|
visualDuration: props.dockOpenVisualDuration ?? props.visualDuration ?? 0.3,
|
||||||
|
bounce: props.dockOpenBounce ?? props.bounce ?? 0,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
visualDuration: props.dockCloseVisualDuration ?? props.visualDuration ?? 0.3,
|
||||||
|
bounce: props.dockCloseBounce ?? props.bounce ?? 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const progress = useSpring(() => (open() ? 1 : 0), config)
|
||||||
|
const value = createMemo(() => Math.max(0, Math.min(1, progress())))
|
||||||
|
const [height, setHeight] = createSignal(320)
|
||||||
|
const dock = createMemo(() => props.state.dock() || value() > 0.001)
|
||||||
|
const full = createMemo(() => Math.max(78, height()))
|
||||||
|
const [contentRef, setContentRef] = createSignal<HTMLDivElement>()
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const el = contentRef()
|
||||||
|
if (!el) return
|
||||||
|
const update = () => {
|
||||||
|
setHeight(el.getBoundingClientRect().height)
|
||||||
|
}
|
||||||
|
update()
|
||||||
|
const observer = new ResizeObserver(update)
|
||||||
|
observer.observe(el)
|
||||||
|
onCleanup(() => observer.disconnect())
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={props.setPromptDockRef}
|
ref={props.setPromptDockRef}
|
||||||
|
|
@ -87,30 +136,46 @@ export function SessionComposerRegion(props: {
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Show when={props.state.dock()}>
|
<Show when={dock()}>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"transition-[max-height,opacity,transform] duration-[400ms] ease-out overflow-hidden": true,
|
"overflow-hidden": true,
|
||||||
"max-h-[320px]": !props.state.closing(),
|
"pointer-events-none": value() < 0.98,
|
||||||
"max-h-0 pointer-events-none": props.state.closing(),
|
}}
|
||||||
"opacity-0 translate-y-9": props.state.closing() || props.state.opening(),
|
style={{
|
||||||
"opacity-100 translate-y-0": !props.state.closing() && !props.state.opening(),
|
"max-height": `${full() * value()}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SessionTodoDock
|
<div ref={setContentRef}>
|
||||||
todos={props.state.todos()}
|
<SessionTodoDock
|
||||||
title={language.t("session.todo.title")}
|
todos={props.state.todos()}
|
||||||
collapseLabel={language.t("session.todo.collapse")}
|
title={language.t("session.todo.title")}
|
||||||
expandLabel={language.t("session.todo.expand")}
|
collapseLabel={language.t("session.todo.collapse")}
|
||||||
/>
|
expandLabel={language.t("session.todo.expand")}
|
||||||
|
dockProgress={value()}
|
||||||
|
visualDuration={props.visualDuration}
|
||||||
|
bounce={props.bounce}
|
||||||
|
expandVisualDuration={props.drawerExpandVisualDuration}
|
||||||
|
expandBounce={props.drawerExpandBounce}
|
||||||
|
collapseVisualDuration={props.drawerCollapseVisualDuration}
|
||||||
|
collapseBounce={props.drawerCollapseBounce}
|
||||||
|
subtitleDuration={props.subtitleDuration}
|
||||||
|
subtitleTravel={props.subtitleTravel}
|
||||||
|
subtitleEdge={props.subtitleEdge}
|
||||||
|
countDuration={props.countDuration}
|
||||||
|
countMask={props.countMask}
|
||||||
|
countMaskHeight={props.countMaskHeight}
|
||||||
|
countWidthDuration={props.countWidthDuration}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"relative z-10": true,
|
"relative z-10": true,
|
||||||
"transition-[margin] duration-[400ms] ease-out": true,
|
}}
|
||||||
"-mt-9": props.state.dock() && !props.state.closing(),
|
style={{
|
||||||
"mt-0": !props.state.dock() || props.state.closing(),
|
"margin-top": `${-36 * value()}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PromptInput
|
<PromptInput
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ export function createSessionComposerBlocked() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSessionComposerState() {
|
export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
|
@ -96,12 +96,19 @@ export function createSessionComposerState() {
|
||||||
let timer: number | undefined
|
let timer: number | undefined
|
||||||
let raf: number | undefined
|
let raf: number | undefined
|
||||||
|
|
||||||
|
const closeMs = () => {
|
||||||
|
const value = options?.closeMs
|
||||||
|
if (typeof value === "function") return Math.max(0, value())
|
||||||
|
if (typeof value === "number") return Math.max(0, value)
|
||||||
|
return 400
|
||||||
|
}
|
||||||
|
|
||||||
const scheduleClose = () => {
|
const scheduleClose = () => {
|
||||||
if (timer) window.clearTimeout(timer)
|
if (timer) window.clearTimeout(timer)
|
||||||
timer = window.setTimeout(() => {
|
timer = window.setTimeout(() => {
|
||||||
setStore({ dock: false, closing: false })
|
setStore({ dock: false, closing: false })
|
||||||
timer = undefined
|
timer = undefined
|
||||||
}, 400)
|
}, closeMs())
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
import type { Todo } from "@opencode-ai/sdk/v2"
|
import type { Todo } from "@opencode-ai/sdk/v2"
|
||||||
|
import { AnimatedNumber } from "@opencode-ai/ui/animated-number"
|
||||||
import { Checkbox } from "@opencode-ai/ui/checkbox"
|
import { Checkbox } from "@opencode-ai/ui/checkbox"
|
||||||
import { DockTray } from "@opencode-ai/ui/dock-surface"
|
import { DockTray } from "@opencode-ai/ui/dock-surface"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||||
|
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||||
|
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
|
||||||
|
import { Index, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
|
||||||
function dot(status: Todo["status"]) {
|
function dot(status: Todo["status"]) {
|
||||||
|
|
@ -30,19 +34,35 @@ function dot(status: Todo["status"]) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseLabel: string; expandLabel: string }) {
|
export function SessionTodoDock(props: {
|
||||||
|
todos: Todo[]
|
||||||
|
title: string
|
||||||
|
collapseLabel: string
|
||||||
|
expandLabel: string
|
||||||
|
dockProgress?: number
|
||||||
|
visualDuration?: number
|
||||||
|
bounce?: number
|
||||||
|
expandVisualDuration?: number
|
||||||
|
expandBounce?: number
|
||||||
|
collapseVisualDuration?: number
|
||||||
|
collapseBounce?: number
|
||||||
|
subtitleDuration?: number
|
||||||
|
subtitleTravel?: number
|
||||||
|
subtitleEdge?: number
|
||||||
|
countDuration?: number
|
||||||
|
countMask?: number
|
||||||
|
countMaskHeight?: number
|
||||||
|
countWidthDuration?: number
|
||||||
|
}) {
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
collapsed: false,
|
collapsed: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const toggle = () => setStore("collapsed", (value) => !value)
|
const toggle = () => setStore("collapsed", (value) => !value)
|
||||||
|
|
||||||
const summary = createMemo(() => {
|
const total = createMemo(() => props.todos.length)
|
||||||
const total = props.todos.length
|
const done = createMemo(() => props.todos.filter((todo) => todo.status === "completed").length)
|
||||||
if (total === 0) return ""
|
const label = createMemo(() => `${done()} of ${total()} ${props.title.toLowerCase()} completed`)
|
||||||
const completed = props.todos.filter((todo) => todo.status === "completed").length
|
|
||||||
return `${completed} of ${total} ${props.title.toLowerCase()} completed`
|
|
||||||
})
|
|
||||||
|
|
||||||
const active = createMemo(
|
const active = createMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -53,56 +73,134 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
|
||||||
)
|
)
|
||||||
|
|
||||||
const preview = createMemo(() => active()?.content ?? "")
|
const preview = createMemo(() => active()?.content ?? "")
|
||||||
|
const config = createMemo(() =>
|
||||||
|
store.collapsed
|
||||||
|
? {
|
||||||
|
visualDuration: props.collapseVisualDuration ?? props.visualDuration ?? 0.3,
|
||||||
|
bounce: props.collapseBounce ?? props.bounce ?? 0,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
visualDuration: props.expandVisualDuration ?? props.visualDuration ?? 0.3,
|
||||||
|
bounce: props.expandBounce ?? props.bounce ?? 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const collapse = useSpring(() => (store.collapsed ? 1 : 0), config)
|
||||||
|
const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress ?? 1)))
|
||||||
|
const shut = createMemo(() => 1 - dock())
|
||||||
|
const value = createMemo(() => Math.max(0, Math.min(1, collapse())))
|
||||||
|
const hide = createMemo(() => Math.max(value(), shut()))
|
||||||
|
const off = createMemo(() => hide() > 0.98)
|
||||||
|
const turn = createMemo(() => Math.max(0, Math.min(1, value())))
|
||||||
|
const [height, setHeight] = createSignal(320)
|
||||||
|
const full = createMemo(() => Math.max(78, height()))
|
||||||
|
let contentRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const el = contentRef
|
||||||
|
if (!el) return
|
||||||
|
const update = () => {
|
||||||
|
setHeight(el.getBoundingClientRect().height)
|
||||||
|
}
|
||||||
|
update()
|
||||||
|
const observer = new ResizeObserver(update)
|
||||||
|
observer.observe(el)
|
||||||
|
onCleanup(() => observer.disconnect())
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DockTray
|
<DockTray
|
||||||
data-component="session-todo-dock"
|
data-component="session-todo-dock"
|
||||||
classList={{
|
style={{
|
||||||
"h-[78px]": store.collapsed,
|
"overflow-x": "visible",
|
||||||
|
"overflow-y": "hidden",
|
||||||
|
"max-height": `${Math.max(78, full() - value() * (full() - 78))}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div ref={contentRef}>
|
||||||
data-action="session-todo-toggle"
|
<div
|
||||||
class="pl-3 pr-2 py-2 flex items-center gap-2"
|
data-action="session-todo-toggle"
|
||||||
role="button"
|
class="pl-3 pr-2 py-2 flex items-center gap-2 overflow-visible"
|
||||||
tabIndex={0}
|
role="button"
|
||||||
onClick={toggle}
|
tabIndex={0}
|
||||||
onKeyDown={(event) => {
|
onClick={toggle}
|
||||||
if (event.key !== "Enter" && event.key !== " ") return
|
onKeyDown={(event) => {
|
||||||
event.preventDefault()
|
if (event.key !== "Enter" && event.key !== " ") return
|
||||||
toggle()
|
event.preventDefault()
|
||||||
}}
|
toggle()
|
||||||
>
|
}}
|
||||||
<span class="text-14-regular text-text-strong cursor-default">{summary()}</span>
|
>
|
||||||
<Show when={store.collapsed}>
|
<span
|
||||||
<div class="ml-1 flex-1 min-w-0">
|
class="text-14-regular text-text-strong cursor-default inline-flex items-baseline shrink-0 whitespace-nowrap overflow-visible"
|
||||||
<Show when={preview()}>
|
aria-label={label()}
|
||||||
<div class="text-14-regular text-text-base truncate cursor-default">{preview()}</div>
|
style={{
|
||||||
</Show>
|
"--tool-motion-odometer-ms": `${props.countDuration ?? 600}ms`,
|
||||||
|
"--tool-motion-mask": `${props.countMask ?? 18}%`,
|
||||||
|
"--tool-motion-mask-height": `${props.countMaskHeight ?? 0}px`,
|
||||||
|
"--tool-motion-spring-ms": `${props.countWidthDuration ?? 560}ms`,
|
||||||
|
opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`,
|
||||||
|
filter: `blur(${Math.max(0, Math.min(1, shut())) * 2}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AnimatedNumber value={done()} />
|
||||||
|
<span class="mx-1">of</span>
|
||||||
|
<AnimatedNumber value={total()} />
|
||||||
|
<span> {props.title.toLowerCase()} completed</span>
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
data-slot="session-todo-preview"
|
||||||
|
class="ml-1 min-w-0 overflow-hidden"
|
||||||
|
style={{
|
||||||
|
flex: "1 1 auto",
|
||||||
|
"max-width": "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextReveal
|
||||||
|
class="text-14-regular text-text-base cursor-default"
|
||||||
|
text={store.collapsed ? preview() : undefined}
|
||||||
|
duration={props.subtitleDuration ?? 600}
|
||||||
|
travel={props.subtitleTravel ?? 25}
|
||||||
|
edge={props.subtitleEdge ?? 17}
|
||||||
|
spring="cubic-bezier(0.34, 1, 0.64, 1)"
|
||||||
|
springSoft="cubic-bezier(0.34, 1, 0.64, 1)"
|
||||||
|
growOnly
|
||||||
|
truncate
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="ml-auto">
|
||||||
|
<IconButton
|
||||||
|
data-action="session-todo-toggle-button"
|
||||||
|
data-collapsed={store.collapsed ? "true" : "false"}
|
||||||
|
icon="chevron-down"
|
||||||
|
size="normal"
|
||||||
|
variant="ghost"
|
||||||
|
style={{ transform: `rotate(${turn() * 180}deg)` }}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
toggle()
|
||||||
|
}}
|
||||||
|
aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
|
||||||
<div classList={{ "ml-auto": !store.collapsed, "ml-1": store.collapsed }}>
|
|
||||||
<IconButton
|
|
||||||
data-action="session-todo-toggle-button"
|
|
||||||
icon="chevron-down"
|
|
||||||
size="normal"
|
|
||||||
variant="ghost"
|
|
||||||
classList={{ "rotate-180": store.collapsed }}
|
|
||||||
onMouseDown={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
}}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
toggle()
|
|
||||||
}}
|
|
||||||
aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div data-slot="session-todo-list" hidden={store.collapsed}>
|
<div
|
||||||
<TodoList todos={props.todos} open={!store.collapsed} />
|
data-slot="session-todo-list"
|
||||||
|
aria-hidden={store.collapsed || off()}
|
||||||
|
classList={{
|
||||||
|
"pointer-events-none": hide() > 0.1,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
visibility: off() ? "hidden" : "visible",
|
||||||
|
opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
|
||||||
|
filter: `blur(${Math.max(0, Math.min(1, hide())) * 2}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TodoList todos={props.todos} open={!store.collapsed} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DockTray>
|
</DockTray>
|
||||||
)
|
)
|
||||||
|
|
@ -171,33 +269,43 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
|
||||||
}, 250)
|
}, 250)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<For each={props.todos}>
|
<Index each={props.todos}>
|
||||||
{(todo) => (
|
{(todo) => (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
readOnly
|
readOnly
|
||||||
checked={todo.status === "completed"}
|
checked={todo().status === "completed"}
|
||||||
indeterminate={todo.status === "in_progress"}
|
indeterminate={todo().status === "in_progress"}
|
||||||
data-in-progress={todo.status === "in_progress" ? "" : undefined}
|
data-in-progress={todo().status === "in_progress" ? "" : undefined}
|
||||||
icon={dot(todo.status)}
|
data-state={todo().status}
|
||||||
style={{ "--checkbox-align": "flex-start", "--checkbox-offset": "1px" }}
|
icon={dot(todo().status)}
|
||||||
|
style={{
|
||||||
|
"--checkbox-align": "flex-start",
|
||||||
|
"--checkbox-offset": "1px",
|
||||||
|
transition:
|
||||||
|
"opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), filter 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
|
||||||
|
opacity: todo().status === "pending" ? "0.94" : "1",
|
||||||
|
filter: todo().status === "pending" ? "blur(0.3px)" : "blur(0px)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<TextStrikethrough
|
||||||
|
active={todo().status === "completed" || todo().status === "cancelled"}
|
||||||
|
text={todo().content}
|
||||||
class="text-14-regular min-w-0 break-words"
|
class="text-14-regular min-w-0 break-words"
|
||||||
classList={{
|
|
||||||
"text-text-weak": todo.status === "completed" || todo.status === "cancelled",
|
|
||||||
"text-text-strong": todo.status !== "completed" && todo.status !== "cancelled",
|
|
||||||
}}
|
|
||||||
style={{
|
style={{
|
||||||
"line-height": "var(--line-height-normal)",
|
"line-height": "var(--line-height-normal)",
|
||||||
"text-decoration":
|
transition:
|
||||||
todo.status === "completed" || todo.status === "cancelled" ? "line-through" : undefined,
|
"color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), filter 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
|
||||||
|
color:
|
||||||
|
todo().status === "completed" || todo().status === "cancelled"
|
||||||
|
? "var(--text-weak)"
|
||||||
|
: "var(--text-strong)",
|
||||||
|
opacity: todo().status === "pending" ? "0.92" : "1",
|
||||||
|
filter: todo().status === "pending" ? "blur(0.3px)" : "blur(0px)",
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
{todo.content}
|
|
||||||
</span>
|
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
)}
|
)}
|
||||||
</For>
|
</Index>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="pointer-events-none absolute top-0 left-0 right-0 h-4 transition-opacity duration-150"
|
class="pointer-events-none absolute top-0 left-0 right-0 h-4 transition-opacity duration-150"
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||||
|
|
||||||
let scroll: HTMLDivElement | undefined
|
let scroll: HTMLDivElement | undefined
|
||||||
let scrollFrame: number | undefined
|
let scrollFrame: number | undefined
|
||||||
|
let restoreFrame: number | undefined
|
||||||
let pending: { x: number; y: number } | undefined
|
let pending: { x: number; y: number } | undefined
|
||||||
let codeScroll: HTMLElement[] = []
|
let codeScroll: HTMLElement[] = []
|
||||||
let find: FileSearchHandle | null = null
|
let find: FileSearchHandle | null = null
|
||||||
|
|
@ -349,6 +350,15 @@ export function FileTabContent(props: { tab: string }) {
|
||||||
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
|
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const queueRestore = () => {
|
||||||
|
if (restoreFrame !== undefined) return
|
||||||
|
|
||||||
|
restoreFrame = requestAnimationFrame(() => {
|
||||||
|
restoreFrame = undefined
|
||||||
|
restoreScroll()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||||
if (codeScroll.length === 0) syncCodeScroll()
|
if (codeScroll.length === 0) syncCodeScroll()
|
||||||
|
|
||||||
|
|
@ -364,46 +374,29 @@ export function FileTabContent(props: { tab: string }) {
|
||||||
setNote("commenting", null)
|
setNote("commenting", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(
|
let prev = {
|
||||||
on(
|
loaded: false,
|
||||||
() => state()?.loaded,
|
ready: false,
|
||||||
(loaded) => {
|
active: false,
|
||||||
if (!loaded) return
|
}
|
||||||
requestAnimationFrame(restoreScroll)
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(
|
createEffect(() => {
|
||||||
on(
|
const loaded = !!state()?.loaded
|
||||||
() => file.ready(),
|
const ready = file.ready()
|
||||||
(ready) => {
|
const active = tabs().active() === props.tab
|
||||||
if (!ready) return
|
const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active)
|
||||||
requestAnimationFrame(restoreScroll)
|
prev = { loaded, ready, active }
|
||||||
},
|
if (!restore) return
|
||||||
{ defer: true },
|
queueRestore()
|
||||||
),
|
})
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => tabs().active() === props.tab,
|
|
||||||
(active) => {
|
|
||||||
if (!active) return
|
|
||||||
if (!state()?.loaded) return
|
|
||||||
requestAnimationFrame(restoreScroll)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
for (const item of codeScroll) {
|
for (const item of codeScroll) {
|
||||||
item.removeEventListener("scroll", handleCodeScroll)
|
item.removeEventListener("scroll", handleCodeScroll)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scrollFrame === undefined) return
|
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
|
||||||
cancelAnimationFrame(scrollFrame)
|
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
|
||||||
})
|
})
|
||||||
|
|
||||||
const renderFile = (source: string) => (
|
const renderFile = (source: string) => (
|
||||||
|
|
@ -421,7 +414,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||||
selectedLines={activeSelection()}
|
selectedLines={activeSelection()}
|
||||||
commentedLines={commentedLines()}
|
commentedLines={commentedLines()}
|
||||||
onRendered={() => {
|
onRendered={() => {
|
||||||
requestAnimationFrame(restoreScroll)
|
queueRestore()
|
||||||
}}
|
}}
|
||||||
annotations={commentsUi.annotations()}
|
annotations={commentsUi.annotations()}
|
||||||
renderAnnotation={commentsUi.renderAnnotation}
|
renderAnnotation={commentsUi.renderAnnotation}
|
||||||
|
|
@ -440,7 +433,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||||
mode: "auto",
|
mode: "auto",
|
||||||
path: path(),
|
path: path(),
|
||||||
current: state()?.content,
|
current: state()?.content,
|
||||||
onLoad: () => requestAnimationFrame(restoreScroll),
|
onLoad: queueRestore,
|
||||||
onError: (args: { kind: "image" | "audio" | "svg" }) => {
|
onError: (args: { kind: "image" | "audio" | "svg" }) => {
|
||||||
if (args.kind !== "svg") return
|
if (args.kind !== "svg") return
|
||||||
showToast({
|
showToast({
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { For, createEffect, createMemo, on, onCleanup, Show, type JSX } from "solid-js"
|
import { For, createEffect, createMemo, on, onCleanup, Show, startTransition, Index, type JSX } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { useNavigate, useParams } from "@solidjs/router"
|
import { useNavigate, useParams } from "@solidjs/router"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
|
@ -10,8 +10,9 @@ import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||||
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
||||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||||
import type { 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 { getFilename } from "@opencode-ai/util/path"
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
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"
|
||||||
|
|
@ -31,6 +32,9 @@ type MessageComment = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const emptyMessages: MessageType[] = []
|
||||||
|
const idle = { type: "idle" as const }
|
||||||
|
|
||||||
const messageComments = (parts: Part[]): MessageComment[] =>
|
const messageComments = (parts: Part[]): MessageComment[] =>
|
||||||
parts.flatMap((part) => {
|
parts.flatMap((part) => {
|
||||||
if (part.type !== "text" || !(part as TextPart).synthetic) return []
|
if (part.type !== "text" || !(part as TextPart).synthetic) return []
|
||||||
|
|
@ -81,6 +85,103 @@ const markBoundaryGesture = (input: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StageConfig = {
|
||||||
|
init: number
|
||||||
|
batch: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimelineStageInput = {
|
||||||
|
sessionKey: () => string
|
||||||
|
turnStart: () => number
|
||||||
|
messages: () => UserMessage[]
|
||||||
|
config: StageConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defer-mounts small timeline windows so revealing older turns does not
|
||||||
|
* block first paint with a large DOM mount.
|
||||||
|
*
|
||||||
|
* Once staging completes for a session it never re-stages — backfill and
|
||||||
|
* new messages render immediately.
|
||||||
|
*/
|
||||||
|
function createTimelineStaging(input: TimelineStageInput) {
|
||||||
|
const [state, setState] = createStore({
|
||||||
|
activeSession: "",
|
||||||
|
completedSession: "",
|
||||||
|
count: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const stagedCount = createMemo(() => {
|
||||||
|
const total = input.messages().length
|
||||||
|
if (input.turnStart() <= 0) return total
|
||||||
|
if (state.completedSession === input.sessionKey()) return total
|
||||||
|
const init = Math.min(total, input.config.init)
|
||||||
|
if (state.count <= init) return init
|
||||||
|
if (state.count >= total) return total
|
||||||
|
return state.count
|
||||||
|
})
|
||||||
|
|
||||||
|
const stagedUserMessages = createMemo(() => {
|
||||||
|
const list = input.messages()
|
||||||
|
const count = stagedCount()
|
||||||
|
if (count >= list.length) return list
|
||||||
|
return list.slice(Math.max(0, list.length - count))
|
||||||
|
})
|
||||||
|
|
||||||
|
let frame: number | undefined
|
||||||
|
const cancel = () => {
|
||||||
|
if (frame === undefined) return
|
||||||
|
cancelAnimationFrame(frame)
|
||||||
|
frame = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const,
|
||||||
|
([sessionKey, isWindowed, total]) => {
|
||||||
|
cancel()
|
||||||
|
const shouldStage =
|
||||||
|
isWindowed &&
|
||||||
|
total > input.config.init &&
|
||||||
|
state.completedSession !== sessionKey &&
|
||||||
|
state.activeSession !== sessionKey
|
||||||
|
if (!shouldStage) {
|
||||||
|
setState({ activeSession: "", count: total })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = Math.min(total, input.config.init)
|
||||||
|
setState({ activeSession: sessionKey, count })
|
||||||
|
|
||||||
|
const step = () => {
|
||||||
|
if (input.sessionKey() !== sessionKey) {
|
||||||
|
frame = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const currentTotal = input.messages().length
|
||||||
|
count = Math.min(currentTotal, count + input.config.batch)
|
||||||
|
startTransition(() => setState("count", count))
|
||||||
|
if (count >= currentTotal) {
|
||||||
|
setState({ completedSession: sessionKey, activeSession: "" })
|
||||||
|
frame = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
frame = requestAnimationFrame(step)
|
||||||
|
}
|
||||||
|
frame = requestAnimationFrame(step)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const isStaging = createMemo(() => {
|
||||||
|
const key = input.sessionKey()
|
||||||
|
return state.activeSession === key && state.completedSession !== key
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(cancel)
|
||||||
|
return { messages: stagedUserMessages, isStaging }
|
||||||
|
}
|
||||||
|
|
||||||
export function MessageTimeline(props: {
|
export function MessageTimeline(props: {
|
||||||
mobileChanges: boolean
|
mobileChanges: boolean
|
||||||
mobileFallback: JSX.Element
|
mobileFallback: JSX.Element
|
||||||
|
|
@ -93,11 +194,11 @@ export function MessageTimeline(props: {
|
||||||
hasScrollGesture: () => boolean
|
hasScrollGesture: () => boolean
|
||||||
isDesktop: boolean
|
isDesktop: boolean
|
||||||
onScrollSpyScroll: () => void
|
onScrollSpyScroll: () => void
|
||||||
|
onTurnBackfillScroll: () => void
|
||||||
onAutoScrollInteraction: (event: MouseEvent) => void
|
onAutoScrollInteraction: (event: MouseEvent) => void
|
||||||
centered: boolean
|
centered: boolean
|
||||||
setContentRef: (el: HTMLDivElement) => void
|
setContentRef: (el: HTMLDivElement) => void
|
||||||
turnStart: number
|
turnStart: number
|
||||||
onRenderEarlier: () => void
|
|
||||||
historyMore: boolean
|
historyMore: boolean
|
||||||
historyLoading: boolean
|
historyLoading: boolean
|
||||||
onLoadEarlier: () => void
|
onLoadEarlier: () => void
|
||||||
|
|
@ -105,7 +206,6 @@ export function MessageTimeline(props: {
|
||||||
anchor: (id: string) => string
|
anchor: (id: string) => string
|
||||||
onRegisterMessage: (el: HTMLDivElement, id: string) => void
|
onRegisterMessage: (el: HTMLDivElement, id: string) => void
|
||||||
onUnregisterMessage: (id: string) => void
|
onUnregisterMessage: (id: string) => void
|
||||||
lastUserMessageID?: string
|
|
||||||
}) {
|
}) {
|
||||||
let touchGesture: number | undefined
|
let touchGesture: number | undefined
|
||||||
|
|
||||||
|
|
@ -117,8 +217,43 @@ export function MessageTimeline(props: {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
|
const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
const sessionID = createMemo(() => params.id)
|
const sessionID = createMemo(() => params.id)
|
||||||
|
const sessionMessages = createMemo(() => {
|
||||||
|
const id = sessionID()
|
||||||
|
if (!id) return emptyMessages
|
||||||
|
return sync.data.message[id] ?? emptyMessages
|
||||||
|
})
|
||||||
|
const pending = createMemo(() =>
|
||||||
|
sessionMessages().findLast(
|
||||||
|
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const sessionStatus = createMemo(() => {
|
||||||
|
const id = sessionID()
|
||||||
|
if (!id) return idle
|
||||||
|
return sync.data.session_status[id] ?? idle
|
||||||
|
})
|
||||||
|
const activeMessageID = createMemo(() => {
|
||||||
|
const parentID = pending()?.parentID
|
||||||
|
if (parentID) {
|
||||||
|
const messages = sessionMessages()
|
||||||
|
const result = Binary.search(messages, parentID, (message) => message.id)
|
||||||
|
const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID)
|
||||||
|
if (message && message.role === "user") return message.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = sessionStatus()
|
||||||
|
if (status.type !== "idle") {
|
||||||
|
const messages = sessionMessages()
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
if (messages[i].role === "user") return messages[i].id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
const info = createMemo(() => {
|
const info = createMemo(() => {
|
||||||
const id = sessionID()
|
const id = sessionID()
|
||||||
if (!id) return
|
if (!id) return
|
||||||
|
|
@ -127,6 +262,13 @@ export function MessageTimeline(props: {
|
||||||
const titleValue = createMemo(() => info()?.title)
|
const titleValue = createMemo(() => info()?.title)
|
||||||
const parentID = createMemo(() => info()?.parentID)
|
const parentID = createMemo(() => info()?.parentID)
|
||||||
const showHeader = createMemo(() => !!(titleValue() || parentID()))
|
const showHeader = createMemo(() => !!(titleValue() || parentID()))
|
||||||
|
const stageCfg = { init: 1, batch: 3 }
|
||||||
|
const staging = createTimelineStaging({
|
||||||
|
sessionKey,
|
||||||
|
turnStart: () => props.turnStart,
|
||||||
|
messages: () => props.renderedUserMessages,
|
||||||
|
config: stageCfg,
|
||||||
|
})
|
||||||
|
|
||||||
const [title, setTitle] = createStore({
|
const [title, setTitle] = createStore({
|
||||||
draft: "",
|
draft: "",
|
||||||
|
|
@ -343,8 +485,10 @@ export function MessageTimeline(props: {
|
||||||
<div
|
<div
|
||||||
class="absolute left-1/2 -translate-x-1/2 bottom-6 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
class="absolute left-1/2 -translate-x-1/2 bottom-6 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
||||||
classList={{
|
classList={{
|
||||||
"opacity-100 translate-y-0 scale-100": props.scroll.overflow && !props.scroll.bottom,
|
"opacity-100 translate-y-0 scale-100":
|
||||||
"opacity-0 translate-y-2 scale-95 pointer-events-none": !props.scroll.overflow || props.scroll.bottom,
|
props.scroll.overflow && !props.scroll.bottom && !staging.isStaging(),
|
||||||
|
"opacity-0 translate-y-2 scale-95 pointer-events-none":
|
||||||
|
!props.scroll.overflow || props.scroll.bottom || staging.isStaging(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
|
@ -393,6 +537,7 @@ export function MessageTimeline(props: {
|
||||||
}}
|
}}
|
||||||
onScroll={(e) => {
|
onScroll={(e) => {
|
||||||
props.onScheduleScrollState(e.currentTarget)
|
props.onScheduleScrollState(e.currentTarget)
|
||||||
|
props.onTurnBackfillScroll()
|
||||||
if (!props.hasScrollGesture()) return
|
if (!props.hasScrollGesture()) return
|
||||||
props.onAutoScrollHandleScroll()
|
props.onAutoScrollHandleScroll()
|
||||||
props.onMarkScrollGesture(e.currentTarget)
|
props.onMarkScrollGesture(e.currentTarget)
|
||||||
|
|
@ -530,14 +675,7 @@ export function MessageTimeline(props: {
|
||||||
"mt-0": !props.centered,
|
"mt-0": !props.centered,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={props.turnStart > 0}>
|
<Show when={props.turnStart > 0 || props.historyMore}>
|
||||||
<div class="w-full flex justify-center">
|
|
||||||
<Button variant="ghost" size="large" class="text-12-medium opacity-50" onClick={props.onRenderEarlier}>
|
|
||||||
{language.t("session.messages.renderEarlier")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={props.historyMore}>
|
|
||||||
<div class="w-full flex justify-center">
|
<div class="w-full flex justify-center">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -552,56 +690,74 @@ export function MessageTimeline(props: {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<For each={props.renderedUserMessages}>
|
<For each={rendered()}>
|
||||||
{(message) => {
|
{(messageID) => {
|
||||||
const comments = createMemo(() => messageComments(sync.data.part[message.id] ?? []))
|
const active = createMemo(() => activeMessageID() === messageID)
|
||||||
|
const queued = createMemo(() => {
|
||||||
|
if (active()) return false
|
||||||
|
const activeID = activeMessageID()
|
||||||
|
if (activeID) return messageID > activeID
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
|
||||||
|
equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
|
||||||
|
})
|
||||||
|
const commentCount = createMemo(() => comments().length)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id={props.anchor(message.id)}
|
id={props.anchor(messageID)}
|
||||||
data-message-id={message.id}
|
data-message-id={messageID}
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
props.onRegisterMessage(el, message.id)
|
props.onRegisterMessage(el, messageID)
|
||||||
onCleanup(() => props.onUnregisterMessage(message.id))
|
onCleanup(() => props.onUnregisterMessage(messageID))
|
||||||
}}
|
}}
|
||||||
classList={{
|
classList={{
|
||||||
"min-w-0 w-full max-w-full": true,
|
"min-w-0 w-full max-w-full": true,
|
||||||
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={comments().length > 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="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
|
||||||
<div class="flex w-max min-w-full justify-end gap-2">
|
<div class="flex w-max min-w-full justify-end gap-2">
|
||||||
<For each={comments()}>
|
<Index each={comments()}>
|
||||||
{(comment) => (
|
{(commentAccessor: () => MessageComment) => {
|
||||||
<div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
|
const comment = createMemo(() => commentAccessor())
|
||||||
<div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
|
return (
|
||||||
<FileIcon node={{ path: comment.path, type: "file" }} class="size-3.5 shrink-0" />
|
<div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
|
||||||
<span class="truncate">{getFilename(comment.path)}</span>
|
<div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
|
||||||
<Show when={comment.selection}>
|
<FileIcon
|
||||||
{(selection) => (
|
node={{ path: comment().path, type: "file" }}
|
||||||
<span class="shrink-0 text-text-weak">
|
class="size-3.5 shrink-0"
|
||||||
{selection().startLine === selection().endLine
|
/>
|
||||||
? `:${selection().startLine}`
|
<span class="truncate">{getFilename(comment().path)}</span>
|
||||||
: `:${selection().startLine}-${selection().endLine}`}
|
<Show when={comment().selection}>
|
||||||
</span>
|
{(selection) => (
|
||||||
)}
|
<span class="shrink-0 text-text-weak">
|
||||||
</Show>
|
{selection().startLine === selection().endLine
|
||||||
|
? `:${selection().startLine}`
|
||||||
|
: `:${selection().startLine}-${selection().endLine}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words">
|
||||||
|
{comment().comment}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words">
|
)
|
||||||
{comment.comment}
|
}}
|
||||||
</div>
|
</Index>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<SessionTurn
|
<SessionTurn
|
||||||
sessionID={sessionID() ?? ""}
|
sessionID={sessionID() ?? ""}
|
||||||
messageID={message.id}
|
messageID={messageID}
|
||||||
lastUserMessageID={props.lastUserMessageID}
|
active={active()}
|
||||||
|
queued={queued()}
|
||||||
|
status={active() ? sessionStatus() : undefined}
|
||||||
showReasoningSummaries={settings.general.showReasoningSummaries()}
|
showReasoningSummaries={settings.general.showReasoningSummaries()}
|
||||||
shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
|
shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
|
||||||
editToolDefaultOpen={settings.general.editToolPartsExpanded()}
|
editToolDefaultOpen={settings.general.editToolPartsExpanded()}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { createEffect, on, onCleanup, type JSX } from "solid-js"
|
import { createEffect, onCleanup, type JSX } from "solid-js"
|
||||||
import type { FileDiff } from "@opencode-ai/sdk/v2"
|
import type { FileDiff } from "@opencode-ai/sdk/v2"
|
||||||
import { SessionReview } from "@opencode-ai/ui/session-review"
|
import { SessionReview } from "@opencode-ai/ui/session-review"
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -119,32 +119,12 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(
|
createEffect(() => {
|
||||||
on(
|
props.diffs().length
|
||||||
() => props.diffs().length,
|
props.diffStyle
|
||||||
() => queueRestore(),
|
if (!layout.ready()) return
|
||||||
{ defer: true },
|
queueRestore()
|
||||||
),
|
})
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => props.diffStyle,
|
|
||||||
() => queueRestore(),
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => layout.ready(),
|
|
||||||
(ready) => {
|
|
||||||
if (!ready) return
|
|
||||||
queueRestore()
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
|
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
|
||||||
|
|
@ -176,7 +156,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
|
||||||
open={props.view().review.open()}
|
open={props.view().review.open()}
|
||||||
onOpenChange={props.view().review.setOpen}
|
onOpenChange={props.view().review.setOpen}
|
||||||
classes={{
|
classes={{
|
||||||
root: props.classes?.root ?? "pb-6 pr-3",
|
root: props.classes?.root ?? "pr-3",
|
||||||
header: props.classes?.header ?? "px-3",
|
header: props.classes?.header ?? "px-3",
|
||||||
container: props.classes?.container ?? "pl-3",
|
container: props.classes?.container ?? "pl-3",
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,9 @@ export function TerminalPanel() {
|
||||||
on(
|
on(
|
||||||
() => terminal.all().length,
|
() => terminal.all().length,
|
||||||
(count, prevCount) => {
|
(count, prevCount) => {
|
||||||
if (prevCount !== undefined && prevCount > 0 && count === 0) {
|
if (prevCount === undefined || prevCount <= 0 || count !== 0) return
|
||||||
if (opened()) view().terminal.toggle()
|
if (!opened()) return
|
||||||
}
|
close()
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { createEffect, createMemo, on, onCleanup } from "solid-js"
|
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
export const messageIdFromHash = (hash: string) => {
|
export const messageIdFromHash = (hash: string) => {
|
||||||
|
|
@ -19,7 +19,6 @@ export const useSessionHashScroll = (input: {
|
||||||
setPendingMessage: (value: string | undefined) => void
|
setPendingMessage: (value: string | undefined) => void
|
||||||
setActiveMessage: (message: UserMessage | undefined) => void
|
setActiveMessage: (message: UserMessage | undefined) => void
|
||||||
setTurnStart: (value: number) => void
|
setTurnStart: (value: number) => void
|
||||||
scheduleTurnBackfill: () => void
|
|
||||||
autoScroll: { pause: () => void; forceScrollToBottom: () => void }
|
autoScroll: { pause: () => void; forceScrollToBottom: () => void }
|
||||||
scroller: () => HTMLDivElement | undefined
|
scroller: () => HTMLDivElement | undefined
|
||||||
anchor: (id: string) => string
|
anchor: (id: string) => string
|
||||||
|
|
@ -29,6 +28,7 @@ export const useSessionHashScroll = (input: {
|
||||||
const visibleUserMessages = createMemo(() => input.visibleUserMessages())
|
const visibleUserMessages = createMemo(() => input.visibleUserMessages())
|
||||||
const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m])))
|
const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m])))
|
||||||
const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
|
const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
|
||||||
|
let pendingKey = ""
|
||||||
|
|
||||||
const clearMessageHash = () => {
|
const clearMessageHash = () => {
|
||||||
if (!window.location.hash) return
|
if (!window.location.hash) return
|
||||||
|
|
@ -58,7 +58,6 @@ export const useSessionHashScroll = (input: {
|
||||||
const index = messageIndex().get(message.id) ?? -1
|
const index = messageIndex().get(message.id) ?? -1
|
||||||
if (index !== -1 && index < input.turnStart()) {
|
if (index !== -1 && index < input.turnStart()) {
|
||||||
input.setTurnStart(index)
|
input.setTurnStart(index)
|
||||||
input.scheduleTurnBackfill()
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const el = document.getElementById(input.anchor(message.id))
|
const el = document.getElementById(input.anchor(message.id))
|
||||||
|
|
@ -132,15 +131,6 @@ export const useSessionHashScroll = (input: {
|
||||||
if (el) input.scheduleScrollState(el)
|
if (el) input.scheduleScrollState(el)
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(input.sessionKey, (key) => {
|
|
||||||
if (!input.sessionID()) return
|
|
||||||
const messageID = input.consumePendingMessage(key)
|
|
||||||
if (!messageID) return
|
|
||||||
input.setPendingMessage(messageID)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!input.sessionID() || !input.messagesReady()) return
|
if (!input.sessionID() || !input.messagesReady()) return
|
||||||
requestAnimationFrame(() => applyHash("auto"))
|
requestAnimationFrame(() => applyHash("auto"))
|
||||||
|
|
@ -152,7 +142,20 @@ export const useSessionHashScroll = (input: {
|
||||||
visibleUserMessages()
|
visibleUserMessages()
|
||||||
input.turnStart()
|
input.turnStart()
|
||||||
|
|
||||||
const targetId = input.pendingMessage() ?? messageIdFromHash(window.location.hash)
|
let targetId = input.pendingMessage()
|
||||||
|
if (!targetId) {
|
||||||
|
const key = input.sessionKey()
|
||||||
|
if (pendingKey !== key) {
|
||||||
|
pendingKey = key
|
||||||
|
const next = input.consumePendingMessage(key)
|
||||||
|
if (next) {
|
||||||
|
input.setPendingMessage(next)
|
||||||
|
targetId = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetId) targetId = messageIdFromHash(window.location.hash)
|
||||||
if (!targetId) return
|
if (!targetId) return
|
||||||
if (input.currentMessageId() === targetId) return
|
if (input.currentMessageId() === targetId) return
|
||||||
|
|
||||||
|
|
@ -164,9 +167,16 @@ export const useSessionHashScroll = (input: {
|
||||||
requestAnimationFrame(() => scrollToMessage(msg, "auto"))
|
requestAnimationFrame(() => scrollToMessage(msg, "auto"))
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
onMount(() => {
|
||||||
if (!input.sessionID() || !input.messagesReady()) return
|
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
|
||||||
const handler = () => requestAnimationFrame(() => applyHash("auto"))
|
window.history.scrollRestoration = "manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = () => {
|
||||||
|
if (!input.sessionID() || !input.messagesReady()) return
|
||||||
|
requestAnimationFrame(() => applyHash("auto"))
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener("hashchange", handler)
|
window.addEventListener("hashchange", handler)
|
||||||
onCleanup(() => window.removeEventListener("hashchange", handler))
|
onCleanup(() => window.removeEventListener("hashchange", handler))
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,27 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
import { handleNotificationClick } from "./notification-click"
|
import { handleNotificationClick, setNavigate } from "./notification-click"
|
||||||
|
|
||||||
describe("notification click", () => {
|
describe("notification click", () => {
|
||||||
test("focuses and navigates when href exists", () => {
|
afterEach(() => {
|
||||||
const calls: string[] = []
|
setNavigate(undefined as any)
|
||||||
handleNotificationClick("/abc/session/123", {
|
|
||||||
focus: () => calls.push("focus"),
|
|
||||||
location: {
|
|
||||||
assign: (href) => calls.push(href),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(calls).toEqual(["focus", "/abc/session/123"])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("only focuses when href is missing", () => {
|
test("navigates via registered navigate function", () => {
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
handleNotificationClick(undefined, {
|
setNavigate((href) => calls.push(href))
|
||||||
focus: () => calls.push("focus"),
|
handleNotificationClick("/abc/session/123")
|
||||||
location: {
|
expect(calls).toEqual(["/abc/session/123"])
|
||||||
assign: (href) => calls.push(href),
|
})
|
||||||
},
|
|
||||||
})
|
test("does not navigate when href is missing", () => {
|
||||||
expect(calls).toEqual(["focus"])
|
const calls: string[] = []
|
||||||
|
setNavigate((href) => calls.push(href))
|
||||||
|
handleNotificationClick(undefined)
|
||||||
|
expect(calls).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("falls back to location.assign without registered navigate", () => {
|
||||||
|
handleNotificationClick("/abc/session/123")
|
||||||
|
// falls back to window.location.assign — no error thrown
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
type WindowTarget = {
|
let nav: ((href: string) => void) | undefined
|
||||||
focus: () => void
|
|
||||||
location: {
|
export const setNavigate = (fn: (href: string) => void) => {
|
||||||
assign: (href: string) => void
|
nav = fn
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const handleNotificationClick = (href?: string, target: WindowTarget = window) => {
|
export const handleNotificationClick = (href?: string) => {
|
||||||
target.focus()
|
window.focus()
|
||||||
if (!href) return
|
if (!href) return
|
||||||
target.location.assign(href)
|
if (nav) nav(href)
|
||||||
|
else window.location.assign(href)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,21 @@ import { Font } from "@opencode-ai/ui/font"
|
||||||
import "@ibm/plex/css/ibm-plex.css"
|
import "@ibm/plex/css/ibm-plex.css"
|
||||||
import "./app.css"
|
import "./app.css"
|
||||||
import { LanguageProvider } from "~/context/language"
|
import { LanguageProvider } from "~/context/language"
|
||||||
import { I18nProvider } from "~/context/i18n"
|
import { I18nProvider, useI18n } from "~/context/i18n"
|
||||||
import { strip } from "~/lib/language"
|
import { strip } from "~/lib/language"
|
||||||
|
|
||||||
|
function AppMeta() {
|
||||||
|
const i18n = useI18n()
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Title>opencode</Title>
|
||||||
|
<Meta name="description" content={i18n.t("app.meta.description")} />
|
||||||
|
<Favicon />
|
||||||
|
<Font />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<Router
|
<Router
|
||||||
|
|
@ -19,10 +31,7 @@ export default function App() {
|
||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<MetaProvider>
|
<MetaProvider>
|
||||||
<Title>opencode</Title>
|
<AppMeta />
|
||||||
<Meta name="description" content="OpenCode - The open source coding agent." />
|
|
||||||
<Favicon />
|
|
||||||
<Font />
|
|
||||||
<Suspense>{props.children}</Suspense>
|
<Suspense>{props.children}</Suspense>
|
||||||
</MetaProvider>
|
</MetaProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
|
|
|
||||||
|
|
@ -124,8 +124,8 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) {
|
||||||
<section data-component="top">
|
<section data-component="top">
|
||||||
<div onContextMenu={handleLogoContextMenu}>
|
<div onContextMenu={handleLogoContextMenu}>
|
||||||
<A href={language.route("/")}>
|
<A href={language.route("/")}>
|
||||||
<img data-slot="logo light" src={logoLight} alt="OpenCode" width="189" height="34" />
|
<img data-slot="logo light" src={logoLight} alt={i18n.t("nav.logoAlt")} width="189" height="34" />
|
||||||
<img data-slot="logo dark" src={logoDark} alt="OpenCode" width="189" height="34" />
|
<img data-slot="logo dark" src={logoDark} alt={i18n.t("nav.logoAlt")} width="189" height="34" />
|
||||||
</A>
|
</A>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export const dict = {
|
||||||
"nav.home": "الرئيسية",
|
"nav.home": "الرئيسية",
|
||||||
"nav.openMenu": "فتح القائمة",
|
"nav.openMenu": "فتح القائمة",
|
||||||
"nav.getStartedFree": "ابدأ مجانا",
|
"nav.getStartedFree": "ابدأ مجانا",
|
||||||
|
"nav.logoAlt": "OpenCode",
|
||||||
|
|
||||||
"nav.context.copyLogo": "نسخ الشعار كـ SVG",
|
"nav.context.copyLogo": "نسخ الشعار كـ SVG",
|
||||||
"nav.context.copyWordmark": "نسخ اسم العلامة كـ SVG",
|
"nav.context.copyWordmark": "نسخ اسم العلامة كـ SVG",
|
||||||
|
|
@ -42,9 +43,13 @@ export const dict = {
|
||||||
"notFound.docs": "الوثائق",
|
"notFound.docs": "الوثائق",
|
||||||
"notFound.github": "GitHub",
|
"notFound.github": "GitHub",
|
||||||
"notFound.discord": "Discord",
|
"notFound.discord": "Discord",
|
||||||
|
"notFound.logoLightAlt": "شعار opencode الفاتح",
|
||||||
|
"notFound.logoDarkAlt": "شعار opencode الداكن",
|
||||||
|
|
||||||
"user.logout": "تسجيل الخروج",
|
"user.logout": "تسجيل الخروج",
|
||||||
|
|
||||||
|
"auth.callback.error.codeMissing": "لم يتم العثور على رمز التفويض.",
|
||||||
|
|
||||||
"workspace.select": "اختر مساحة العمل",
|
"workspace.select": "اختر مساحة العمل",
|
||||||
"workspace.createNew": "+ إنشاء مساحة عمل جديدة",
|
"workspace.createNew": "+ إنشاء مساحة عمل جديدة",
|
||||||
"workspace.modal.title": "إنشاء مساحة عمل جديدة",
|
"workspace.modal.title": "إنشاء مساحة عمل جديدة",
|
||||||
|
|
@ -76,6 +81,8 @@ export const dict = {
|
||||||
"error.reloadAmountMin": "يجب أن يكون مبلغ الشحن ${{amount}} على الأقل",
|
"error.reloadAmountMin": "يجب أن يكون مبلغ الشحن ${{amount}} على الأقل",
|
||||||
"error.reloadTriggerMin": "يجب أن يكون حد الرصيد ${{amount}} على الأقل",
|
"error.reloadTriggerMin": "يجب أن يكون حد الرصيد ${{amount}} على الأقل",
|
||||||
|
|
||||||
|
"app.meta.description": "OpenCode - وكيل البرمجة مفتوح المصدر.",
|
||||||
|
|
||||||
"home.title": "OpenCode | وكيل برمجة بالذكاء الاصطناعي مفتوح المصدر",
|
"home.title": "OpenCode | وكيل برمجة بالذكاء الاصطناعي مفتوح المصدر",
|
||||||
|
|
||||||
"temp.title": "opencode | وكيل برمجة بالذكاء الاصطناعي مبني للطرفية",
|
"temp.title": "opencode | وكيل برمجة بالذكاء الاصطناعي مبني للطرفية",
|
||||||
|
|
@ -91,6 +98,8 @@ export const dict = {
|
||||||
"temp.feature.models.afterLink": "، بما في ذلك النماذج المحلية",
|
"temp.feature.models.afterLink": "، بما في ذلك النماذج المحلية",
|
||||||
"temp.screenshot.caption": "واجهة OpenCode الطرفية مع سمة tokyonight",
|
"temp.screenshot.caption": "واجهة OpenCode الطرفية مع سمة tokyonight",
|
||||||
"temp.screenshot.alt": "واجهة OpenCode الطرفية بسمة tokyonight",
|
"temp.screenshot.alt": "واجهة OpenCode الطرفية بسمة tokyonight",
|
||||||
|
"temp.logoLightAlt": "شعار opencode الفاتح",
|
||||||
|
"temp.logoDarkAlt": "شعار opencode الداكن",
|
||||||
|
|
||||||
"home.banner.badge": "جديد",
|
"home.banner.badge": "جديد",
|
||||||
"home.banner.text": "تطبيق سطح المكتب متاح بنسخة تجريبية",
|
"home.banner.text": "تطبيق سطح المكتب متاح بنسخة تجريبية",
|
||||||
|
|
@ -238,6 +247,24 @@ export const dict = {
|
||||||
"تتم استضافة جميع نماذج Zen في الولايات المتحدة. يتبع المزودون سياسة عدم الاحتفاظ بالبيانات ولا يستخدمون بياناتك لتدريب النماذج، مع",
|
"تتم استضافة جميع نماذج Zen في الولايات المتحدة. يتبع المزودون سياسة عدم الاحتفاظ بالبيانات ولا يستخدمون بياناتك لتدريب النماذج، مع",
|
||||||
"zen.privacy.exceptionsLink": "الاستثناءات التالية",
|
"zen.privacy.exceptionsLink": "الاستثناءات التالية",
|
||||||
|
|
||||||
|
"zen.api.error.rateLimitExceeded": "تم تجاوز حد الطلبات. يرجى المحاولة مرة أخرى لاحقًا.",
|
||||||
|
"zen.api.error.modelNotSupported": "النموذج {{model}} غير مدعوم",
|
||||||
|
"zen.api.error.modelFormatNotSupported": "النموذج {{model}} غير مدعوم للتنسيق {{format}}",
|
||||||
|
"zen.api.error.noProviderAvailable": "لا يوجد مزود متاح",
|
||||||
|
"zen.api.error.providerNotSupported": "المزود {{provider}} غير مدعوم",
|
||||||
|
"zen.api.error.missingApiKey": "مفتاح API مفقود.",
|
||||||
|
"zen.api.error.invalidApiKey": "مفتاح API غير صالح.",
|
||||||
|
"zen.api.error.subscriptionQuotaExceeded": "تم تجاوز حصة الاشتراك. أعد المحاولة خلال {{retryIn}}.",
|
||||||
|
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||||
|
"تم تجاوز حصة الاشتراك. يمكنك الاستمرار في استخدام النماذج المجانية.",
|
||||||
|
"zen.api.error.noPaymentMethod": "لا توجد طريقة دفع. أضف طريقة دفع هنا: {{billingUrl}}",
|
||||||
|
"zen.api.error.insufficientBalance": "رصيد غير كاف. إدارة فواتيرك هنا: {{billingUrl}}",
|
||||||
|
"zen.api.error.workspaceMonthlyLimitReached":
|
||||||
|
"وصلت مساحة العمل الخاصة بك إلى حد الإنفاق الشهري البالغ ${{amount}}. إدارة حدودك هنا: {{billingUrl}}",
|
||||||
|
"zen.api.error.userMonthlyLimitReached":
|
||||||
|
"لقد وصلت إلى حد الإنفاق الشهري البالغ ${{amount}}. إدارة حدودك هنا: {{membersUrl}}",
|
||||||
|
"zen.api.error.modelDisabled": "النموذج معطل",
|
||||||
|
|
||||||
"black.meta.title": "OpenCode Black | الوصول إلى أفضل نماذج البرمجة في العالم",
|
"black.meta.title": "OpenCode Black | الوصول إلى أفضل نماذج البرمجة في العالم",
|
||||||
"black.meta.description": "احصل على وصول إلى Claude، GPT، Gemini والمزيد مع خطط اشتراك OpenCode Black.",
|
"black.meta.description": "احصل على وصول إلى Claude، GPT، Gemini والمزيد مع خطط اشتراك OpenCode Black.",
|
||||||
"black.hero.title": "الوصول إلى أفضل نماذج البرمجة في العالم",
|
"black.hero.title": "الوصول إلى أفضل نماذج البرمجة في العالم",
|
||||||
|
|
@ -446,6 +473,7 @@ export const dict = {
|
||||||
"workspace.reload.updatePaymentMethod": "يرجى تحديث طريقة الدفع والمحاولة مرة أخرى.",
|
"workspace.reload.updatePaymentMethod": "يرجى تحديث طريقة الدفع والمحاولة مرة أخرى.",
|
||||||
"workspace.reload.retrying": "جارٍ إعادة المحاولة...",
|
"workspace.reload.retrying": "جارٍ إعادة المحاولة...",
|
||||||
"workspace.reload.retry": "أعد المحاولة",
|
"workspace.reload.retry": "أعد المحاولة",
|
||||||
|
"workspace.reload.error.paymentFailed": "فشلت عملية الدفع.",
|
||||||
|
|
||||||
"workspace.payments.title": "سجل المدفوعات",
|
"workspace.payments.title": "سجل المدفوعات",
|
||||||
"workspace.payments.subtitle": "معاملات الدفع الأخيرة.",
|
"workspace.payments.subtitle": "معاملات الدفع الأخيرة.",
|
||||||
|
|
@ -563,6 +591,10 @@ export const dict = {
|
||||||
"enterprise.form.send": "إرسال",
|
"enterprise.form.send": "إرسال",
|
||||||
"enterprise.form.sending": "جارٍ الإرسال...",
|
"enterprise.form.sending": "جارٍ الإرسال...",
|
||||||
"enterprise.form.success": "تم إرسال الرسالة، سنتواصل معك قريبًا.",
|
"enterprise.form.success": "تم إرسال الرسالة، سنتواصل معك قريبًا.",
|
||||||
|
"enterprise.form.success.submitted": "تم إرسال النموذج بنجاح.",
|
||||||
|
"enterprise.form.error.allFieldsRequired": "جميع الحقول مطلوبة.",
|
||||||
|
"enterprise.form.error.invalidEmailFormat": "تنسيق البريد الإلكتروني غير صالح.",
|
||||||
|
"enterprise.form.error.internalServer": "خطأ داخلي في الخادم.",
|
||||||
"enterprise.faq.title": "الأسئلة الشائعة",
|
"enterprise.faq.title": "الأسئلة الشائعة",
|
||||||
"enterprise.faq.q1": "ما هو OpenCode Enterprise؟",
|
"enterprise.faq.q1": "ما هو OpenCode Enterprise؟",
|
||||||
"enterprise.faq.a1":
|
"enterprise.faq.a1":
|
||||||
|
|
@ -595,6 +627,7 @@ export const dict = {
|
||||||
"bench.list.table.agent": "الوكيل",
|
"bench.list.table.agent": "الوكيل",
|
||||||
"bench.list.table.model": "النموذج",
|
"bench.list.table.model": "النموذج",
|
||||||
"bench.list.table.score": "الدرجة",
|
"bench.list.table.score": "الدرجة",
|
||||||
|
"bench.submission.error.allFieldsRequired": "جميع الحقول مطلوبة.",
|
||||||
|
|
||||||
"bench.detail.title": "المعيار - {{task}}",
|
"bench.detail.title": "المعيار - {{task}}",
|
||||||
"bench.detail.notFound": "المهمة غير موجودة",
|
"bench.detail.notFound": "المهمة غير موجودة",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export const dict = {
|
||||||
"nav.home": "Início",
|
"nav.home": "Início",
|
||||||
"nav.openMenu": "Abrir menu",
|
"nav.openMenu": "Abrir menu",
|
||||||
"nav.getStartedFree": "Começar grátis",
|
"nav.getStartedFree": "Começar grátis",
|
||||||
|
"nav.logoAlt": "OpenCode",
|
||||||
|
|
||||||
"nav.context.copyLogo": "Copiar logo como SVG",
|
"nav.context.copyLogo": "Copiar logo como SVG",
|
||||||
"nav.context.copyWordmark": "Copiar marca como SVG",
|
"nav.context.copyWordmark": "Copiar marca como SVG",
|
||||||
|
|
@ -42,9 +43,13 @@ export const dict = {
|
||||||
"notFound.docs": "Documentação",
|
"notFound.docs": "Documentação",
|
||||||
"notFound.github": "GitHub",
|
"notFound.github": "GitHub",
|
||||||
"notFound.discord": "Discord",
|
"notFound.discord": "Discord",
|
||||||
|
"notFound.logoLightAlt": "logo opencode claro",
|
||||||
|
"notFound.logoDarkAlt": "logo opencode escuro",
|
||||||
|
|
||||||
"user.logout": "Sair",
|
"user.logout": "Sair",
|
||||||
|
|
||||||
|
"auth.callback.error.codeMissing": "Nenhum código de autorização encontrado.",
|
||||||
|
|
||||||
"workspace.select": "Selecionar workspace",
|
"workspace.select": "Selecionar workspace",
|
||||||
"workspace.createNew": "+ Criar novo workspace",
|
"workspace.createNew": "+ Criar novo workspace",
|
||||||
"workspace.modal.title": "Criar novo workspace",
|
"workspace.modal.title": "Criar novo workspace",
|
||||||
|
|
@ -76,6 +81,8 @@ export const dict = {
|
||||||
"error.reloadAmountMin": "O valor de recarga deve ser de pelo menos ${{amount}}",
|
"error.reloadAmountMin": "O valor de recarga deve ser de pelo menos ${{amount}}",
|
||||||
"error.reloadTriggerMin": "O gatilho de saldo deve ser de pelo menos ${{amount}}",
|
"error.reloadTriggerMin": "O gatilho de saldo deve ser de pelo menos ${{amount}}",
|
||||||
|
|
||||||
|
"app.meta.description": "OpenCode - O agente de codificação de código aberto.",
|
||||||
|
|
||||||
"home.title": "OpenCode | O agente de codificação de código aberto com IA",
|
"home.title": "OpenCode | O agente de codificação de código aberto com IA",
|
||||||
|
|
||||||
"temp.title": "opencode | Agente de codificação com IA feito para o terminal",
|
"temp.title": "opencode | Agente de codificação com IA feito para o terminal",
|
||||||
|
|
@ -91,6 +98,8 @@ export const dict = {
|
||||||
"temp.feature.models.afterLink": ", incluindo modelos locais",
|
"temp.feature.models.afterLink": ", incluindo modelos locais",
|
||||||
"temp.screenshot.caption": "OpenCode TUI com o tema tokyonight",
|
"temp.screenshot.caption": "OpenCode TUI com o tema tokyonight",
|
||||||
"temp.screenshot.alt": "OpenCode TUI com tema tokyonight",
|
"temp.screenshot.alt": "OpenCode TUI com tema tokyonight",
|
||||||
|
"temp.logoLightAlt": "logo opencode claro",
|
||||||
|
"temp.logoDarkAlt": "logo opencode escuro",
|
||||||
|
|
||||||
"home.banner.badge": "Novo",
|
"home.banner.badge": "Novo",
|
||||||
"home.banner.text": "App desktop disponível em beta",
|
"home.banner.text": "App desktop disponível em beta",
|
||||||
|
|
@ -242,6 +251,24 @@ export const dict = {
|
||||||
"Todos os modelos Zen são hospedados nos EUA. Os provedores seguem uma política de retenção zero e não usam seus dados para treinamento de modelo, com as",
|
"Todos os modelos Zen são hospedados nos EUA. Os provedores seguem uma política de retenção zero e não usam seus dados para treinamento de modelo, com as",
|
||||||
"zen.privacy.exceptionsLink": "seguintes exceções",
|
"zen.privacy.exceptionsLink": "seguintes exceções",
|
||||||
|
|
||||||
|
"zen.api.error.rateLimitExceeded": "Limite de taxa excedido. Por favor, tente novamente mais tarde.",
|
||||||
|
"zen.api.error.modelNotSupported": "Modelo {{model}} não suportado",
|
||||||
|
"zen.api.error.modelFormatNotSupported": "Modelo {{model}} não suportado para o formato {{format}}",
|
||||||
|
"zen.api.error.noProviderAvailable": "Nenhum provedor disponível",
|
||||||
|
"zen.api.error.providerNotSupported": "Provedor {{provider}} não suportado",
|
||||||
|
"zen.api.error.missingApiKey": "Chave de API ausente.",
|
||||||
|
"zen.api.error.invalidApiKey": "Chave de API inválida.",
|
||||||
|
"zen.api.error.subscriptionQuotaExceeded": "Cota de assinatura excedida. Tente novamente em {{retryIn}}.",
|
||||||
|
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||||
|
"Cota de assinatura excedida. Você pode continuar usando modelos gratuitos.",
|
||||||
|
"zen.api.error.noPaymentMethod": "Nenhuma forma de pagamento. Adicione uma forma de pagamento aqui: {{billingUrl}}",
|
||||||
|
"zen.api.error.insufficientBalance": "Saldo insuficiente. Gerencie seu faturamento aqui: {{billingUrl}}",
|
||||||
|
"zen.api.error.workspaceMonthlyLimitReached":
|
||||||
|
"Seu workspace atingiu o limite de gastos mensais de ${{amount}}. Gerencie seus limites aqui: {{billingUrl}}",
|
||||||
|
"zen.api.error.userMonthlyLimitReached":
|
||||||
|
"Você atingiu seu limite de gastos mensais de ${{amount}}. Gerencie seus limites aqui: {{membersUrl}}",
|
||||||
|
"zen.api.error.modelDisabled": "O modelo está desabilitado",
|
||||||
|
|
||||||
"black.meta.title": "OpenCode Black | Acesse os melhores modelos de codificação do mundo",
|
"black.meta.title": "OpenCode Black | Acesse os melhores modelos de codificação do mundo",
|
||||||
"black.meta.description": "Tenha acesso ao Claude, GPT, Gemini e mais com os planos de assinatura OpenCode Black.",
|
"black.meta.description": "Tenha acesso ao Claude, GPT, Gemini e mais com os planos de assinatura OpenCode Black.",
|
||||||
"black.hero.title": "Acesse os melhores modelos de codificação do mundo",
|
"black.hero.title": "Acesse os melhores modelos de codificação do mundo",
|
||||||
|
|
@ -451,6 +478,7 @@ export const dict = {
|
||||||
"workspace.reload.updatePaymentMethod": "Por favor, atualize sua forma de pagamento e tente novamente.",
|
"workspace.reload.updatePaymentMethod": "Por favor, atualize sua forma de pagamento e tente novamente.",
|
||||||
"workspace.reload.retrying": "Tentando novamente...",
|
"workspace.reload.retrying": "Tentando novamente...",
|
||||||
"workspace.reload.retry": "Tentar novamente",
|
"workspace.reload.retry": "Tentar novamente",
|
||||||
|
"workspace.reload.error.paymentFailed": "Pagamento falhou.",
|
||||||
|
|
||||||
"workspace.payments.title": "Histórico de Pagamentos",
|
"workspace.payments.title": "Histórico de Pagamentos",
|
||||||
"workspace.payments.subtitle": "Transações de pagamento recentes.",
|
"workspace.payments.subtitle": "Transações de pagamento recentes.",
|
||||||
|
|
@ -571,6 +599,10 @@ export const dict = {
|
||||||
"enterprise.form.send": "Enviar",
|
"enterprise.form.send": "Enviar",
|
||||||
"enterprise.form.sending": "Enviando...",
|
"enterprise.form.sending": "Enviando...",
|
||||||
"enterprise.form.success": "Mensagem enviada, entraremos em contato em breve.",
|
"enterprise.form.success": "Mensagem enviada, entraremos em contato em breve.",
|
||||||
|
"enterprise.form.success.submitted": "Formulário enviado com sucesso.",
|
||||||
|
"enterprise.form.error.allFieldsRequired": "Todos os campos são obrigatórios.",
|
||||||
|
"enterprise.form.error.invalidEmailFormat": "Formato de e-mail inválido.",
|
||||||
|
"enterprise.form.error.internalServer": "Erro interno do servidor.",
|
||||||
"enterprise.faq.title": "FAQ",
|
"enterprise.faq.title": "FAQ",
|
||||||
"enterprise.faq.q1": "O que é OpenCode Enterprise?",
|
"enterprise.faq.q1": "O que é OpenCode Enterprise?",
|
||||||
"enterprise.faq.a1":
|
"enterprise.faq.a1":
|
||||||
|
|
@ -603,6 +635,7 @@ export const dict = {
|
||||||
"bench.list.table.agent": "Agente",
|
"bench.list.table.agent": "Agente",
|
||||||
"bench.list.table.model": "Modelo",
|
"bench.list.table.model": "Modelo",
|
||||||
"bench.list.table.score": "Pontuação",
|
"bench.list.table.score": "Pontuação",
|
||||||
|
"bench.submission.error.allFieldsRequired": "Todos os campos são obrigatórios.",
|
||||||
|
|
||||||
"bench.detail.title": "Benchmark - {{task}}",
|
"bench.detail.title": "Benchmark - {{task}}",
|
||||||
"bench.detail.notFound": "Tarefa não encontrada",
|
"bench.detail.notFound": "Tarefa não encontrada",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export const dict = {
|
||||||
"nav.home": "Hjem",
|
"nav.home": "Hjem",
|
||||||
"nav.openMenu": "Åbn menu",
|
"nav.openMenu": "Åbn menu",
|
||||||
"nav.getStartedFree": "Kom i gang gratis",
|
"nav.getStartedFree": "Kom i gang gratis",
|
||||||
|
"nav.logoAlt": "OpenCode",
|
||||||
|
|
||||||
"nav.context.copyLogo": "Kopier logo som SVG",
|
"nav.context.copyLogo": "Kopier logo som SVG",
|
||||||
"nav.context.copyWordmark": "Kopier wordmark som SVG",
|
"nav.context.copyWordmark": "Kopier wordmark som SVG",
|
||||||
|
|
@ -42,9 +43,13 @@ export const dict = {
|
||||||
"notFound.docs": "Dokumentation",
|
"notFound.docs": "Dokumentation",
|
||||||
"notFound.github": "GitHub",
|
"notFound.github": "GitHub",
|
||||||
"notFound.discord": "Discord",
|
"notFound.discord": "Discord",
|
||||||
|
"notFound.logoLightAlt": "opencode logo light",
|
||||||
|
"notFound.logoDarkAlt": "opencode logo dark",
|
||||||
|
|
||||||
"user.logout": "Log ud",
|
"user.logout": "Log ud",
|
||||||
|
|
||||||
|
"auth.callback.error.codeMissing": "Ingen autorisationskode fundet.",
|
||||||
|
|
||||||
"workspace.select": "Vælg workspace",
|
"workspace.select": "Vælg workspace",
|
||||||
"workspace.createNew": "+ Opret nyt workspace",
|
"workspace.createNew": "+ Opret nyt workspace",
|
||||||
"workspace.modal.title": "Opret nyt workspace",
|
"workspace.modal.title": "Opret nyt workspace",
|
||||||
|
|
@ -76,6 +81,8 @@ export const dict = {
|
||||||
"error.reloadAmountMin": "Genopfyldningsbeløb skal være mindst ${{amount}}",
|
"error.reloadAmountMin": "Genopfyldningsbeløb skal være mindst ${{amount}}",
|
||||||
"error.reloadTriggerMin": "Saldogrænse skal være mindst ${{amount}}",
|
"error.reloadTriggerMin": "Saldogrænse skal være mindst ${{amount}}",
|
||||||
|
|
||||||
|
"app.meta.description": "OpenCode - Den open source kodningsagent.",
|
||||||
|
|
||||||
"home.title": "OpenCode | Den open source AI-kodningsagent",
|
"home.title": "OpenCode | Den open source AI-kodningsagent",
|
||||||
|
|
||||||
"temp.title": "opencode | AI-kodningsagent bygget til terminalen",
|
"temp.title": "opencode | AI-kodningsagent bygget til terminalen",
|
||||||
|
|
@ -91,6 +98,8 @@ export const dict = {
|
||||||
"temp.feature.models.afterLink": ", inklusive lokale modeller",
|
"temp.feature.models.afterLink": ", inklusive lokale modeller",
|
||||||
"temp.screenshot.caption": "opencode TUI med tokyonight-temaet",
|
"temp.screenshot.caption": "opencode TUI med tokyonight-temaet",
|
||||||
"temp.screenshot.alt": "opencode TUI med tokyonight-temaet",
|
"temp.screenshot.alt": "opencode TUI med tokyonight-temaet",
|
||||||
|
"temp.logoLightAlt": "opencode logo light",
|
||||||
|
"temp.logoDarkAlt": "opencode logo dark",
|
||||||
|
|
||||||
"home.banner.badge": "Ny",
|
"home.banner.badge": "Ny",
|
||||||
"home.banner.text": "Desktop-app tilgængelig i beta",
|
"home.banner.text": "Desktop-app tilgængelig i beta",
|
||||||
|
|
@ -240,6 +249,24 @@ export const dict = {
|
||||||
"Alle Zen-modeller er hostet i USA. Udbydere følger en nulopbevaringspolitik og bruger ikke dine data til modeltræning med",
|
"Alle Zen-modeller er hostet i USA. Udbydere følger en nulopbevaringspolitik og bruger ikke dine data til modeltræning med",
|
||||||
"zen.privacy.exceptionsLink": "følgende undtagelser",
|
"zen.privacy.exceptionsLink": "følgende undtagelser",
|
||||||
|
|
||||||
|
"zen.api.error.rateLimitExceeded": "Hastighedsgrænse overskredet. Prøv venligst igen senere.",
|
||||||
|
"zen.api.error.modelNotSupported": "Model {{model}} understøttes ikke",
|
||||||
|
"zen.api.error.modelFormatNotSupported": "Model {{model}} understøttes ikke for format {{format}}",
|
||||||
|
"zen.api.error.noProviderAvailable": "Ingen udbyder tilgængelig",
|
||||||
|
"zen.api.error.providerNotSupported": "Udbyder {{provider}} understøttes ikke",
|
||||||
|
"zen.api.error.missingApiKey": "Manglende API-nøgle.",
|
||||||
|
"zen.api.error.invalidApiKey": "Ugyldig API-nøgle.",
|
||||||
|
"zen.api.error.subscriptionQuotaExceeded": "Abonnementskvote overskredet. Prøv igen om {{retryIn}}.",
|
||||||
|
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||||
|
"Abonnementskvote overskredet. Du kan fortsætte med at bruge gratis modeller.",
|
||||||
|
"zen.api.error.noPaymentMethod": "Ingen betalingsmetode. Tilføj en betalingsmetode her: {{billingUrl}}",
|
||||||
|
"zen.api.error.insufficientBalance": "Utilstrækkelig saldo. Administrer din fakturering her: {{billingUrl}}",
|
||||||
|
"zen.api.error.workspaceMonthlyLimitReached":
|
||||||
|
"Dit workspace har nået sin månedlige forbrugsgrænse på ${{amount}}. Administrer dine grænser her: {{billingUrl}}",
|
||||||
|
"zen.api.error.userMonthlyLimitReached":
|
||||||
|
"Du har nået din månedlige forbrugsgrænse på ${{amount}}. Administrer dine grænser her: {{membersUrl}}",
|
||||||
|
"zen.api.error.modelDisabled": "Modellen er deaktiveret",
|
||||||
|
|
||||||
"black.meta.title": "OpenCode Black | Få adgang til verdens bedste kodningsmodeller",
|
"black.meta.title": "OpenCode Black | Få adgang til verdens bedste kodningsmodeller",
|
||||||
"black.meta.description": "Få adgang til Claude, GPT, Gemini og mere med OpenCode Black-abonnementer.",
|
"black.meta.description": "Få adgang til Claude, GPT, Gemini og mere med OpenCode Black-abonnementer.",
|
||||||
"black.hero.title": "Få adgang til verdens bedste kodningsmodeller",
|
"black.hero.title": "Få adgang til verdens bedste kodningsmodeller",
|
||||||
|
|
@ -449,6 +476,7 @@ export const dict = {
|
||||||
"workspace.reload.updatePaymentMethod": "Opdater din betalingsmetode, og prøv igen.",
|
"workspace.reload.updatePaymentMethod": "Opdater din betalingsmetode, og prøv igen.",
|
||||||
"workspace.reload.retrying": "Prøver igen...",
|
"workspace.reload.retrying": "Prøver igen...",
|
||||||
"workspace.reload.retry": "Prøv igen",
|
"workspace.reload.retry": "Prøv igen",
|
||||||
|
"workspace.reload.error.paymentFailed": "Betaling mislykkedes.",
|
||||||
|
|
||||||
"workspace.payments.title": "Betalingshistorik",
|
"workspace.payments.title": "Betalingshistorik",
|
||||||
"workspace.payments.subtitle": "Seneste betalingstransaktioner.",
|
"workspace.payments.subtitle": "Seneste betalingstransaktioner.",
|
||||||
|
|
@ -567,6 +595,10 @@ export const dict = {
|
||||||
"enterprise.form.send": "Send",
|
"enterprise.form.send": "Send",
|
||||||
"enterprise.form.sending": "Sender...",
|
"enterprise.form.sending": "Sender...",
|
||||||
"enterprise.form.success": "Besked sendt, vi vender tilbage snart.",
|
"enterprise.form.success": "Besked sendt, vi vender tilbage snart.",
|
||||||
|
"enterprise.form.success.submitted": "Formular indsendt med succes.",
|
||||||
|
"enterprise.form.error.allFieldsRequired": "Alle felter er påkrævet.",
|
||||||
|
"enterprise.form.error.invalidEmailFormat": "Ugyldigt e-mailformat.",
|
||||||
|
"enterprise.form.error.internalServer": "Intern serverfejl.",
|
||||||
"enterprise.faq.title": "FAQ",
|
"enterprise.faq.title": "FAQ",
|
||||||
"enterprise.faq.q1": "Hvad er OpenCode Enterprise?",
|
"enterprise.faq.q1": "Hvad er OpenCode Enterprise?",
|
||||||
"enterprise.faq.a1":
|
"enterprise.faq.a1":
|
||||||
|
|
@ -599,6 +631,7 @@ export const dict = {
|
||||||
"bench.list.table.agent": "Agent",
|
"bench.list.table.agent": "Agent",
|
||||||
"bench.list.table.model": "Model",
|
"bench.list.table.model": "Model",
|
||||||
"bench.list.table.score": "Score",
|
"bench.list.table.score": "Score",
|
||||||
|
"bench.submission.error.allFieldsRequired": "Alle felter er påkrævet.",
|
||||||
|
|
||||||
"bench.detail.title": "Benchmark - {{task}}",
|
"bench.detail.title": "Benchmark - {{task}}",
|
||||||
"bench.detail.notFound": "Opgave ikke fundet",
|
"bench.detail.notFound": "Opgave ikke fundet",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export const dict = {
|
||||||
"nav.home": "Startseite",
|
"nav.home": "Startseite",
|
||||||
"nav.openMenu": "Menü öffnen",
|
"nav.openMenu": "Menü öffnen",
|
||||||
"nav.getStartedFree": "Kostenlos starten",
|
"nav.getStartedFree": "Kostenlos starten",
|
||||||
|
"nav.logoAlt": "OpenCode",
|
||||||
|
|
||||||
"nav.context.copyLogo": "Logo als SVG kopieren",
|
"nav.context.copyLogo": "Logo als SVG kopieren",
|
||||||
"nav.context.copyWordmark": "Wortmarke als SVG kopieren",
|
"nav.context.copyWordmark": "Wortmarke als SVG kopieren",
|
||||||
|
|
@ -42,9 +43,13 @@ export const dict = {
|
||||||
"notFound.docs": "Dokumentation",
|
"notFound.docs": "Dokumentation",
|
||||||
"notFound.github": "GitHub",
|
"notFound.github": "GitHub",
|
||||||
"notFound.discord": "Discord",
|
"notFound.discord": "Discord",
|
||||||
|
"notFound.logoLightAlt": "OpenCode Logo hell",
|
||||||
|
"notFound.logoDarkAlt": "OpenCode Logo dunkel",
|
||||||
|
|
||||||
"user.logout": "Abmelden",
|
"user.logout": "Abmelden",
|
||||||
|
|
||||||
|
"auth.callback.error.codeMissing": "Kein Autorisierungscode gefunden.",
|
||||||
|
|
||||||
"workspace.select": "Workspace auswählen",
|
"workspace.select": "Workspace auswählen",
|
||||||
"workspace.createNew": "+ Neuen Workspace erstellen",
|
"workspace.createNew": "+ Neuen Workspace erstellen",
|
||||||
"workspace.modal.title": "Neuen Workspace erstellen",
|
"workspace.modal.title": "Neuen Workspace erstellen",
|
||||||
|
|
@ -76,6 +81,8 @@ export const dict = {
|
||||||
"error.reloadAmountMin": "Aufladebetrag muss mindestens ${{amount}} betragen",
|
"error.reloadAmountMin": "Aufladebetrag muss mindestens ${{amount}} betragen",
|
||||||
"error.reloadTriggerMin": "Guthaben-Auslöser muss mindestens ${{amount}} betragen",
|
"error.reloadTriggerMin": "Guthaben-Auslöser muss mindestens ${{amount}} betragen",
|
||||||
|
|
||||||
|
"app.meta.description": "OpenCode - Der Open-Source Coding-Agent.",
|
||||||
|
|
||||||
"home.title": "OpenCode | Der Open-Source AI-Coding-Agent",
|
"home.title": "OpenCode | Der Open-Source AI-Coding-Agent",
|
||||||
|
|
||||||
"temp.title": "OpenCode | Für das Terminal gebauter AI-Coding-Agent",
|
"temp.title": "OpenCode | Für das Terminal gebauter AI-Coding-Agent",
|
||||||
|
|
@ -91,6 +98,8 @@ export const dict = {
|
||||||
"temp.feature.models.afterLink": ", einschließlich lokaler Modelle",
|
"temp.feature.models.afterLink": ", einschließlich lokaler Modelle",
|
||||||
"temp.screenshot.caption": "OpenCode TUI mit dem Tokyonight-Theme",
|
"temp.screenshot.caption": "OpenCode TUI mit dem Tokyonight-Theme",
|
||||||
"temp.screenshot.alt": "OpenCode TUI mit Tokyonight-Theme",
|
"temp.screenshot.alt": "OpenCode TUI mit Tokyonight-Theme",
|
||||||
|
"temp.logoLightAlt": "OpenCode Logo hell",
|
||||||
|
"temp.logoDarkAlt": "OpenCode Logo dunkel",
|
||||||
|
|
||||||
"home.banner.badge": "Neu",
|
"home.banner.badge": "Neu",
|
||||||
"home.banner.text": "Desktop-App in der Beta verfügbar",
|
"home.banner.text": "Desktop-App in der Beta verfügbar",
|
||||||
|
|
@ -242,6 +251,24 @@ export const dict = {
|
||||||
"Alle Zen-Modelle werden in den USA gehostet. Anbieter folgen einer Zero-Retention-Policy und nutzen deine Daten nicht für Modelltraining, mit den",
|
"Alle Zen-Modelle werden in den USA gehostet. Anbieter folgen einer Zero-Retention-Policy und nutzen deine Daten nicht für Modelltraining, mit den",
|
||||||
"zen.privacy.exceptionsLink": "folgenden Ausnahmen",
|
"zen.privacy.exceptionsLink": "folgenden Ausnahmen",
|
||||||
|
|
||||||
|
"zen.api.error.rateLimitExceeded": "Ratenlimit überschritten. Bitte versuche es später erneut.",
|
||||||
|
"zen.api.error.modelNotSupported": "Modell {{model}} wird nicht unterstützt",
|
||||||
|
"zen.api.error.modelFormatNotSupported": "Modell {{model}} wird für das Format {{format}} nicht unterstützt",
|
||||||
|
"zen.api.error.noProviderAvailable": "Kein Anbieter verfügbar",
|
||||||
|
"zen.api.error.providerNotSupported": "Anbieter {{provider}} wird nicht unterstützt",
|
||||||
|
"zen.api.error.missingApiKey": "Fehlender API-Key.",
|
||||||
|
"zen.api.error.invalidApiKey": "Ungültiger API-Key.",
|
||||||
|
"zen.api.error.subscriptionQuotaExceeded": "Abonnement-Quote überschritten. Erneuter Versuch in {{retryIn}}.",
|
||||||
|
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||||
|
"Abonnement-Quote überschritten. Du kannst weiterhin kostenlose Modelle nutzen.",
|
||||||
|
"zen.api.error.noPaymentMethod": "Keine Zahlungsmethode. Füge hier eine Zahlungsmethode hinzu: {{billingUrl}}",
|
||||||
|
"zen.api.error.insufficientBalance": "Unzureichendes Guthaben. Verwalte deine Abrechnung hier: {{billingUrl}}",
|
||||||
|
"zen.api.error.workspaceMonthlyLimitReached":
|
||||||
|
"Dein Workspace hat sein monatliches Ausgabenlimit von ${{amount}} erreicht. Verwalte deine Limits hier: {{billingUrl}}",
|
||||||
|
"zen.api.error.userMonthlyLimitReached":
|
||||||
|
"Du hast dein monatliches Ausgabenlimit von ${{amount}} erreicht. Verwalte deine Limits hier: {{membersUrl}}",
|
||||||
|
"zen.api.error.modelDisabled": "Modell ist deaktiviert",
|
||||||
|
|
||||||
"black.meta.title": "OpenCode Black | Zugriff auf die weltweit besten Coding-Modelle",
|
"black.meta.title": "OpenCode Black | Zugriff auf die weltweit besten Coding-Modelle",
|
||||||
"black.meta.description": "Erhalte Zugriff auf Claude, GPT, Gemini und mehr mit OpenCode Black Abos.",
|
"black.meta.description": "Erhalte Zugriff auf Claude, GPT, Gemini und mehr mit OpenCode Black Abos.",
|
||||||
"black.hero.title": "Zugriff auf die weltweit besten Coding-Modelle",
|
"black.hero.title": "Zugriff auf die weltweit besten Coding-Modelle",
|
||||||
|
|
@ -451,6 +478,7 @@ export const dict = {
|
||||||
"workspace.reload.updatePaymentMethod": "Bitte aktualisiere deine Zahlungsmethode und versuche es erneut.",
|
"workspace.reload.updatePaymentMethod": "Bitte aktualisiere deine Zahlungsmethode und versuche es erneut.",
|
||||||
"workspace.reload.retrying": "Versuche erneut...",
|
"workspace.reload.retrying": "Versuche erneut...",
|
||||||
"workspace.reload.retry": "Erneut versuchen",
|
"workspace.reload.retry": "Erneut versuchen",
|
||||||
|
"workspace.reload.error.paymentFailed": "Zahlung fehlgeschlagen.",
|
||||||
|
|
||||||
"workspace.payments.title": "Zahlungshistorie",
|
"workspace.payments.title": "Zahlungshistorie",
|
||||||
"workspace.payments.subtitle": "Kürzliche Zahlungstransaktionen.",
|
"workspace.payments.subtitle": "Kürzliche Zahlungstransaktionen.",
|
||||||
|
|
@ -571,6 +599,10 @@ export const dict = {
|
||||||
"enterprise.form.send": "Senden",
|
"enterprise.form.send": "Senden",
|
||||||
"enterprise.form.sending": "Sende...",
|
"enterprise.form.sending": "Sende...",
|
||||||
"enterprise.form.success": "Nachricht gesendet, wir melden uns bald.",
|
"enterprise.form.success": "Nachricht gesendet, wir melden uns bald.",
|
||||||
|
"enterprise.form.success.submitted": "Formular erfolgreich gesendet.",
|
||||||
|
"enterprise.form.error.allFieldsRequired": "Alle Felder sind erforderlich.",
|
||||||
|
"enterprise.form.error.invalidEmailFormat": "Ungültiges E-Mail-Format.",
|
||||||
|
"enterprise.form.error.internalServer": "Interner Serverfehler.",
|
||||||
"enterprise.faq.title": "FAQ",
|
"enterprise.faq.title": "FAQ",
|
||||||
"enterprise.faq.q1": "Was ist OpenCode Enterprise?",
|
"enterprise.faq.q1": "Was ist OpenCode Enterprise?",
|
||||||
"enterprise.faq.a1":
|
"enterprise.faq.a1":
|
||||||
|
|
@ -603,6 +635,7 @@ export const dict = {
|
||||||
"bench.list.table.agent": "Agent",
|
"bench.list.table.agent": "Agent",
|
||||||
"bench.list.table.model": "Modell",
|
"bench.list.table.model": "Modell",
|
||||||
"bench.list.table.score": "Score",
|
"bench.list.table.score": "Score",
|
||||||
|
"bench.submission.error.allFieldsRequired": "Alle Felder sind erforderlich.",
|
||||||
|
|
||||||
"bench.detail.title": "Benchmark - {{task}}",
|
"bench.detail.title": "Benchmark - {{task}}",
|
||||||
"bench.detail.notFound": "Task nicht gefunden",
|
"bench.detail.notFound": "Task nicht gefunden",
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export const dict = {
|
||||||
"nav.home": "Home",
|
"nav.home": "Home",
|
||||||
"nav.openMenu": "Open menu",
|
"nav.openMenu": "Open menu",
|
||||||
"nav.getStartedFree": "Get started for free",
|
"nav.getStartedFree": "Get started for free",
|
||||||
|
"nav.logoAlt": "OpenCode",
|
||||||
|
|
||||||
"nav.context.copyLogo": "Copy logo as SVG",
|
"nav.context.copyLogo": "Copy logo as SVG",
|
||||||
"nav.context.copyWordmark": "Copy wordmark as SVG",
|
"nav.context.copyWordmark": "Copy wordmark as SVG",
|
||||||
|
|
@ -38,9 +39,13 @@ export const dict = {
|
||||||
"notFound.docs": "Docs",
|
"notFound.docs": "Docs",
|
||||||
"notFound.github": "GitHub",
|
"notFound.github": "GitHub",
|
||||||
"notFound.discord": "Discord",
|
"notFound.discord": "Discord",
|
||||||
|
"notFound.logoLightAlt": "opencode logo light",
|
||||||
|
"notFound.logoDarkAlt": "opencode logo dark",
|
||||||
|
|
||||||
"user.logout": "Logout",
|
"user.logout": "Logout",
|
||||||
|
|
||||||
|
"auth.callback.error.codeMissing": "No authorization code found.",
|
||||||
|
|
||||||
"workspace.select": "Select workspace",
|
"workspace.select": "Select workspace",
|
||||||
"workspace.createNew": "+ Create New Workspace",
|
"workspace.createNew": "+ Create New Workspace",
|
||||||
"workspace.modal.title": "Create New Workspace",
|
"workspace.modal.title": "Create New Workspace",
|
||||||
|
|
@ -72,6 +77,8 @@ export const dict = {
|
||||||
"error.reloadAmountMin": "Reload amount must be at least ${{amount}}",
|
"error.reloadAmountMin": "Reload amount must be at least ${{amount}}",
|
||||||
"error.reloadTriggerMin": "Balance trigger must be at least ${{amount}}",
|
"error.reloadTriggerMin": "Balance trigger must be at least ${{amount}}",
|
||||||
|
|
||||||
|
"app.meta.description": "OpenCode - The open source coding agent.",
|
||||||
|
|
||||||
"home.title": "OpenCode | The open source AI coding agent",
|
"home.title": "OpenCode | The open source AI coding agent",
|
||||||
|
|
||||||
"temp.title": "opencode | AI coding agent built for the terminal",
|
"temp.title": "opencode | AI coding agent built for the terminal",
|
||||||
|
|
@ -87,6 +94,8 @@ export const dict = {
|
||||||
"temp.feature.models.afterLink": ", including local models",
|
"temp.feature.models.afterLink": ", including local models",
|
||||||
"temp.screenshot.caption": "opencode TUI with the tokyonight theme",
|
"temp.screenshot.caption": "opencode TUI with the tokyonight theme",
|
||||||
"temp.screenshot.alt": "opencode TUI with tokyonight theme",
|
"temp.screenshot.alt": "opencode TUI with tokyonight theme",
|
||||||
|
"temp.logoLightAlt": "opencode logo light",
|
||||||
|
"temp.logoDarkAlt": "opencode logo dark",
|
||||||
|
|
||||||
"home.banner.badge": "New",
|
"home.banner.badge": "New",
|
||||||
"home.banner.text": "Desktop app available in beta",
|
"home.banner.text": "Desktop app available in beta",
|
||||||
|
|
@ -234,6 +243,24 @@ export const dict = {
|
||||||
"All Zen models are hosted in the US. Providers follow a zero-retention policy and do not use your data for model training, with the",
|
"All Zen models are hosted in the US. Providers follow a zero-retention policy and do not use your data for model training, with the",
|
||||||
"zen.privacy.exceptionsLink": "following exceptions",
|
"zen.privacy.exceptionsLink": "following exceptions",
|
||||||
|
|
||||||
|
"zen.api.error.rateLimitExceeded": "Rate limit exceeded. Please try again later.",
|
||||||
|
"zen.api.error.modelNotSupported": "Model {{model}} not supported",
|
||||||
|
"zen.api.error.modelFormatNotSupported": "Model {{model}} not supported for format {{format}}",
|
||||||
|
"zen.api.error.noProviderAvailable": "No provider available",
|
||||||
|
"zen.api.error.providerNotSupported": "Provider {{provider}} not supported",
|
||||||
|
"zen.api.error.missingApiKey": "Missing API key.",
|
||||||
|
"zen.api.error.invalidApiKey": "Invalid API key.",
|
||||||
|
"zen.api.error.subscriptionQuotaExceeded": "Subscription quota exceeded. Retry in {{retryIn}}.",
|
||||||
|
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||||
|
"Subscription quota exceeded. You can continue using free models.",
|
||||||
|
"zen.api.error.noPaymentMethod": "No payment method. Add a payment method here: {{billingUrl}}",
|
||||||
|
"zen.api.error.insufficientBalance": "Insufficient balance. Manage your billing here: {{billingUrl}}",
|
||||||
|
"zen.api.error.workspaceMonthlyLimitReached":
|
||||||
|
"Your workspace has reached its monthly spending limit of ${{amount}}. Manage your limits here: {{billingUrl}}",
|
||||||
|
"zen.api.error.userMonthlyLimitReached":
|
||||||
|
"You have reached your monthly spending limit of ${{amount}}. Manage your limits here: {{membersUrl}}",
|
||||||
|
"zen.api.error.modelDisabled": "Model is disabled",
|
||||||
|
|
||||||
"black.meta.title": "OpenCode Black | Access all the world's best coding models",
|
"black.meta.title": "OpenCode Black | Access all the world's best coding models",
|
||||||
"black.meta.description": "Get access to Claude, GPT, Gemini and more with OpenCode Black subscription plans.",
|
"black.meta.description": "Get access to Claude, GPT, Gemini and more with OpenCode Black subscription plans.",
|
||||||
"black.hero.title": "Access all the world's best coding models",
|
"black.hero.title": "Access all the world's best coding models",
|
||||||
|
|
@ -443,6 +470,7 @@ export const dict = {
|
||||||
"workspace.reload.updatePaymentMethod": "Please update your payment method and try again.",
|
"workspace.reload.updatePaymentMethod": "Please update your payment method and try again.",
|
||||||
"workspace.reload.retrying": "Retrying...",
|
"workspace.reload.retrying": "Retrying...",
|
||||||
"workspace.reload.retry": "Retry",
|
"workspace.reload.retry": "Retry",
|
||||||
|
"workspace.reload.error.paymentFailed": "Payment failed.",
|
||||||
|
|
||||||
"workspace.payments.title": "Payments History",
|
"workspace.payments.title": "Payments History",
|
||||||
"workspace.payments.subtitle": "Recent payment transactions.",
|
"workspace.payments.subtitle": "Recent payment transactions.",
|
||||||
|
|
@ -561,6 +589,10 @@ export const dict = {
|
||||||
"enterprise.form.send": "Send",
|
"enterprise.form.send": "Send",
|
||||||
"enterprise.form.sending": "Sending...",
|
"enterprise.form.sending": "Sending...",
|
||||||
"enterprise.form.success": "Message sent, we'll be in touch soon.",
|
"enterprise.form.success": "Message sent, we'll be in touch soon.",
|
||||||
|
"enterprise.form.success.submitted": "Form submitted successfully.",
|
||||||
|
"enterprise.form.error.allFieldsRequired": "All fields are required.",
|
||||||
|
"enterprise.form.error.invalidEmailFormat": "Invalid email format.",
|
||||||
|
"enterprise.form.error.internalServer": "Internal server error.",
|
||||||
"enterprise.faq.title": "FAQ",
|
"enterprise.faq.title": "FAQ",
|
||||||
"enterprise.faq.q1": "What is OpenCode Enterprise?",
|
"enterprise.faq.q1": "What is OpenCode Enterprise?",
|
||||||
"enterprise.faq.a1":
|
"enterprise.faq.a1":
|
||||||
|
|
@ -593,6 +625,7 @@ export const dict = {
|
||||||
"bench.list.table.agent": "Agent",
|
"bench.list.table.agent": "Agent",
|
||||||
"bench.list.table.model": "Model",
|
"bench.list.table.model": "Model",
|
||||||
"bench.list.table.score": "Score",
|
"bench.list.table.score": "Score",
|
||||||
|
"bench.submission.error.allFieldsRequired": "All fields are required.",
|
||||||
|
|
||||||
"bench.detail.title": "Benchmark - {{task}}",
|
"bench.detail.title": "Benchmark - {{task}}",
|
||||||
"bench.detail.notFound": "Task not found",
|
"bench.detail.notFound": "Task not found",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export const dict = {
|
||||||
"nav.home": "Inicio",
|
"nav.home": "Inicio",
|
||||||
"nav.openMenu": "Abrir menú",
|
"nav.openMenu": "Abrir menú",
|
||||||
"nav.getStartedFree": "Empezar gratis",
|
"nav.getStartedFree": "Empezar gratis",
|
||||||
|
"nav.logoAlt": "OpenCode",
|
||||||
|
|
||||||
"nav.context.copyLogo": "Copiar logo como SVG",
|
"nav.context.copyLogo": "Copiar logo como SVG",
|
||||||
"nav.context.copyWordmark": "Copiar marca como SVG",
|
"nav.context.copyWordmark": "Copiar marca como SVG",
|
||||||
|
|
@ -42,9 +43,13 @@ export const dict = {
|
||||||
"notFound.docs": "Documentación",
|
"notFound.docs": "Documentación",
|
||||||
"notFound.github": "GitHub",
|
"notFound.github": "GitHub",
|
||||||
"notFound.discord": "Discord",
|
"notFound.discord": "Discord",
|
||||||
|
"notFound.logoLightAlt": "opencode logo claro",
|
||||||
|
"notFound.logoDarkAlt": "opencode logo oscuro",
|
||||||
|
|
||||||
"user.logout": "Cerrar sesión",
|
"user.logout": "Cerrar sesión",
|
||||||
|
|
||||||
|
"auth.callback.error.codeMissing": "No se encontró código de autorización.",
|
||||||
|
|
||||||
"workspace.select": "Seleccionar espacio de trabajo",
|
"workspace.select": "Seleccionar espacio de trabajo",
|
||||||
"workspace.createNew": "+ Crear nuevo espacio de trabajo",
|
"workspace.createNew": "+ Crear nuevo espacio de trabajo",
|
||||||
"workspace.modal.title": "Crear nuevo espacio de trabajo",
|
"workspace.modal.title": "Crear nuevo espacio de trabajo",
|
||||||
|
|
@ -76,6 +81,8 @@ export const dict = {
|
||||||
"error.reloadAmountMin": "La cantidad de recarga debe ser al menos ${{amount}}",
|
"error.reloadAmountMin": "La cantidad de recarga debe ser al menos ${{amount}}",
|
||||||
"error.reloadTriggerMin": "El disparador de saldo debe ser al menos ${{amount}}",
|
"error.reloadTriggerMin": "El disparador de saldo debe ser al menos ${{amount}}",
|
||||||
|
|
||||||
|
"app.meta.description": "OpenCode - El agente de codificación de código abierto.",
|
||||||
|
|
||||||
"home.title": "OpenCode | El agente de codificación IA de código abierto",
|
"home.title": "OpenCode | El agente de codificación IA de código abierto",
|
||||||
|
|
||||||
"temp.title": "opencode | Agente de codificación IA creado para la terminal",
|
"temp.title": "opencode | Agente de codificación IA creado para la terminal",
|
||||||
|
|
@ -91,6 +98,8 @@ export const dict = {
|
||||||
"temp.feature.models.afterLink": ", incluyendo modelos locales",
|
"temp.feature.models.afterLink": ", incluyendo modelos locales",
|
||||||
"temp.screenshot.caption": "opencode TUI con el tema tokyonight",
|
"temp.screenshot.caption": "opencode TUI con el tema tokyonight",
|
||||||
"temp.screenshot.alt": "opencode TUI con tema tokyonight",
|
"temp.screenshot.alt": "opencode TUI con tema tokyonight",
|
||||||
|
"temp.logoLightAlt": "logo de opencode claro",
|
||||||
|
"temp.logoDarkAlt": "logo de opencode oscuro",
|
||||||
|
|
||||||
"home.banner.badge": "Nuevo",
|
"home.banner.badge": "Nuevo",
|
||||||
"home.banner.text": "Aplicación de escritorio disponible en beta",
|
"home.banner.text": "Aplicación de escritorio disponible en beta",
|
||||||
|
|
@ -243,6 +252,24 @@ export const dict = {
|
||||||
"Todos los modelos Zen están alojados en EE. UU. Los proveedores siguen una política de cero retención y no usan tus datos para entrenamiento de modelos, con las",
|
"Todos los modelos Zen están alojados en EE. UU. Los proveedores siguen una política de cero retención y no usan tus datos para entrenamiento de modelos, con las",
|
||||||
"zen.privacy.exceptionsLink": "siguientes excepciones",
|
"zen.privacy.exceptionsLink": "siguientes excepciones",
|
||||||
|
|
||||||
|
"zen.api.error.rateLimitExceeded": "Límite de tasa excedido. Por favor, inténtalo de nuevo más tarde.",
|
||||||
|
"zen.api.error.modelNotSupported": "Modelo {{model}} no soportado",
|
||||||
|
"zen.api.error.modelFormatNotSupported": "Modelo {{model}} no soportado para el formato {{format}}",
|
||||||
|
"zen.api.error.noProviderAvailable": "Ningún proveedor disponible",
|
||||||
|
"zen.api.error.providerNotSupported": "Proveedor {{provider}} no soportado",
|
||||||
|
"zen.api.error.missingApiKey": "Falta la clave API.",
|
||||||
|
"zen.api.error.invalidApiKey": "Clave API inválida.",
|
||||||
|
"zen.api.error.subscriptionQuotaExceeded": "Cuota de suscripción excedida. Reintenta en {{retryIn}}.",
|
||||||
|
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||||
|
"Cuota de suscripción excedida. Puedes continuar usando modelos gratuitos.",
|
||||||
|
"zen.api.error.noPaymentMethod": "Sin método de pago. Añade un método de pago aquí: {{billingUrl}}",
|
||||||
|
"zen.api.error.insufficientBalance": "Saldo insuficiente. Gestiona tu facturación aquí: {{billingUrl}}",
|
||||||
|
"zen.api.error.workspaceMonthlyLimitReached":
|
||||||
|
"Tu espacio de trabajo ha alcanzado su límite de gasto mensual de ${{amount}}. Gestiona tus límites aquí: {{billingUrl}}",
|
||||||
|
"zen.api.error.userMonthlyLimitReached":
|
||||||
|
"Has alcanzado tu límite de gasto mensual de ${{amount}}. Gestiona tus límites aquí: {{membersUrl}}",
|
||||||
|
"zen.api.error.modelDisabled": "El modelo está deshabilitado",
|
||||||
|
|
||||||
"black.meta.title": "OpenCode Black | Accede a los mejores modelos de codificación del mundo",
|
"black.meta.title": "OpenCode Black | Accede a los mejores modelos de codificación del mundo",
|
||||||
"black.meta.description": "Obtén acceso a Claude, GPT, Gemini y más con los planes de suscripción de OpenCode Black.",
|
"black.meta.description": "Obtén acceso a Claude, GPT, Gemini y más con los planes de suscripción de OpenCode Black.",
|
||||||
"black.hero.title": "Accede a los mejores modelos de codificación del mundo",
|
"black.hero.title": "Accede a los mejores modelos de codificación del mundo",
|
||||||
|
|
@ -452,6 +479,7 @@ export const dict = {
|
||||||
"workspace.reload.updatePaymentMethod": "Por favor actualiza tu método de pago e intenta de nuevo.",
|
"workspace.reload.updatePaymentMethod": "Por favor actualiza tu método de pago e intenta de nuevo.",
|
||||||
"workspace.reload.retrying": "Reintentando...",
|
"workspace.reload.retrying": "Reintentando...",
|
||||||
"workspace.reload.retry": "Reintentar",
|
"workspace.reload.retry": "Reintentar",
|
||||||
|
"workspace.reload.error.paymentFailed": "El pago falló.",
|
||||||
|
|
||||||
"workspace.payments.title": "Historial de Pagos",
|
"workspace.payments.title": "Historial de Pagos",
|
||||||
"workspace.payments.subtitle": "Transacciones de pago recientes.",
|
"workspace.payments.subtitle": "Transacciones de pago recientes.",
|
||||||
|
|
@ -571,6 +599,10 @@ export const dict = {
|
||||||
"enterprise.form.send": "Enviar",
|
"enterprise.form.send": "Enviar",
|
||||||
"enterprise.form.sending": "Enviando...",
|
"enterprise.form.sending": "Enviando...",
|
||||||
"enterprise.form.success": "Mensaje enviado, estaremos en contacto pronto.",
|
"enterprise.form.success": "Mensaje enviado, estaremos en contacto pronto.",
|
||||||
|
"enterprise.form.success.submitted": "Formulario enviado con éxito.",
|
||||||
|
"enterprise.form.error.allFieldsRequired": "Todos los campos son obligatorios.",
|
||||||
|
"enterprise.form.error.invalidEmailFormat": "Formato de correo inválido.",
|
||||||
|
"enterprise.form.error.internalServer": "Error interno del servidor.",
|
||||||
"enterprise.faq.title": "FAQ",
|
"enterprise.faq.title": "FAQ",
|
||||||
"enterprise.faq.q1": "¿Qué es OpenCode Enterprise?",
|
"enterprise.faq.q1": "¿Qué es OpenCode Enterprise?",
|
||||||
"enterprise.faq.a1":
|
"enterprise.faq.a1":
|
||||||
|
|
@ -603,6 +635,7 @@ export const dict = {
|
||||||
"bench.list.table.agent": "Agente",
|
"bench.list.table.agent": "Agente",
|
||||||
"bench.list.table.model": "Modelo",
|
"bench.list.table.model": "Modelo",
|
||||||
"bench.list.table.score": "Puntuación",
|
"bench.list.table.score": "Puntuación",
|
||||||
|
"bench.submission.error.allFieldsRequired": "Todos los campos son obligatorios.",
|
||||||
|
|
||||||
"bench.detail.title": "Benchmark - {{task}}",
|
"bench.detail.title": "Benchmark - {{task}}",
|
||||||
"bench.detail.notFound": "Tarea no encontrada",
|
"bench.detail.notFound": "Tarea no encontrada",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { dict as en } from "./en"
|
||||||
|
|
||||||
export const dict = {
|
export const dict = {
|
||||||
...en,
|
...en,
|
||||||
|
"app.meta.description": "OpenCode - L'agent de code open source.",
|
||||||
"nav.github": "GitHub",
|
"nav.github": "GitHub",
|
||||||
"nav.docs": "Documentation",
|
"nav.docs": "Documentation",
|
||||||
"nav.changelog": "Changelog",
|
"nav.changelog": "Changelog",
|
||||||
|
|
@ -15,6 +16,7 @@ export const dict = {
|
||||||
"nav.home": "Accueil",
|
"nav.home": "Accueil",
|
||||||
"nav.openMenu": "Ouvrir le menu",
|
"nav.openMenu": "Ouvrir le menu",
|
||||||
"nav.getStartedFree": "Commencer gratuitement",
|
"nav.getStartedFree": "Commencer gratuitement",
|
||||||
|
"nav.logoAlt": "OpenCode",
|
||||||
|
|
||||||
"nav.context.copyLogo": "Copier le logo en SVG",
|
"nav.context.copyLogo": "Copier le logo en SVG",
|
||||||
"nav.context.copyWordmark": "Copier le logotype en SVG",
|
"nav.context.copyWordmark": "Copier le logotype en SVG",
|
||||||
|
|
@ -42,6 +44,8 @@ export const dict = {
|
||||||
"notFound.docs": "Documentation",
|
"notFound.docs": "Documentation",
|
||||||
"notFound.github": "GitHub",
|
"notFound.github": "GitHub",
|
||||||
"notFound.discord": "Discord",
|
"notFound.discord": "Discord",
|
||||||
|
"notFound.logoLightAlt": "opencode logo light",
|
||||||
|
"notFound.logoDarkAlt": "opencode logo dark",
|
||||||
|
|
||||||
"user.logout": "Se déconnecter",
|
"user.logout": "Se déconnecter",
|
||||||
|
|
||||||
|
|
@ -75,6 +79,7 @@ export const dict = {
|
||||||
"error.modelRequired": "Le modèle est requis",
|
"error.modelRequired": "Le modèle est requis",
|
||||||
"error.reloadAmountMin": "Le montant de recharge doit être d'au moins {{amount}} $",
|
"error.reloadAmountMin": "Le montant de recharge doit être d'au moins {{amount}} $",
|
||||||
"error.reloadTriggerMin": "Le seuil de déclenchement doit être d'au moins {{amount}} $",
|
"error.reloadTriggerMin": "Le seuil de déclenchement doit être d'au moins {{amount}} $",
|
||||||
|
"auth.callback.error.codeMissing": "Aucun code d'autorisation trouvé.",
|
||||||
|
|
||||||
"home.title": "OpenCode | L'agent de code IA open source",
|
"home.title": "OpenCode | L'agent de code IA open source",
|
||||||
|
|
||||||
|
|
@ -91,6 +96,8 @@ export const dict = {
|
||||||
"temp.feature.models.afterLink": ", y compris les modèles locaux",
|
"temp.feature.models.afterLink": ", y compris les modèles locaux",
|
||||||
"temp.screenshot.caption": "OpenCode TUI avec le thème tokyonight",
|
"temp.screenshot.caption": "OpenCode TUI avec le thème tokyonight",
|
||||||
"temp.screenshot.alt": "OpenCode TUI avec le thème tokyonight",
|
"temp.screenshot.alt": "OpenCode TUI avec le thème tokyonight",
|
||||||
|
"temp.logoLightAlt": "opencode logo light",
|
||||||
|
"temp.logoDarkAlt": "opencode logo dark",
|
||||||
|
|
||||||
"home.banner.badge": "Nouveau",
|
"home.banner.badge": "Nouveau",
|
||||||
"home.banner.text": "Application desktop disponible en bêta",
|
"home.banner.text": "Application desktop disponible en bêta",
|
||||||
|
|
@ -246,6 +253,24 @@ export const dict = {
|
||||||
"Tous les modèles Zen sont hébergés aux États-Unis. Les fournisseurs suivent une politique de rétention zéro et n'utilisent pas vos données pour l'entraînement des modèles, avec les",
|
"Tous les modèles Zen sont hébergés aux États-Unis. Les fournisseurs suivent une politique de rétention zéro et n'utilisent pas vos données pour l'entraînement des modèles, avec les",
|
||||||
"zen.privacy.exceptionsLink": "exceptions suivantes",
|
"zen.privacy.exceptionsLink": "exceptions suivantes",
|
||||||
|
|
||||||
|
"zen.api.error.rateLimitExceeded": "Limite de débit dépassée. Veuillez réessayer plus tard.",
|
||||||
|
"zen.api.error.modelNotSupported": "Modèle {{model}} non pris en charge",
|
||||||
|
"zen.api.error.modelFormatNotSupported": "Modèle {{model}} non pris en charge pour le format {{format}}",
|
||||||
|
"zen.api.error.noProviderAvailable": "Aucun fournisseur disponible",
|
||||||
|
"zen.api.error.providerNotSupported": "Fournisseur {{provider}} non pris en charge",
|
||||||
|
"zen.api.error.missingApiKey": "Clé API manquante.",
|
||||||
|
"zen.api.error.invalidApiKey": "Clé API invalide.",
|
||||||
|
"zen.api.error.subscriptionQuotaExceeded": "Quota d'abonnement dépassé. Réessayez dans {{retryIn}}.",
|
||||||
|
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||||
|
"Quota d'abonnement dépassé. Vous pouvez continuer à utiliser les modèles gratuits.",
|
||||||
|
"zen.api.error.noPaymentMethod": "Aucune méthode de paiement. Ajoutez une méthode de paiement ici : {{billingUrl}}",
|
||||||
|
"zen.api.error.insufficientBalance": "Solde insuffisant. Gérez votre facturation ici : {{billingUrl}}",
|
||||||
|
"zen.api.error.workspaceMonthlyLimitReached":
|
||||||
|
"Votre espace de travail a atteint sa limite de dépense mensuelle de {{amount}} $. Gérez vos limites ici : {{billingUrl}}",
|
||||||
|
"zen.api.error.userMonthlyLimitReached":
|
||||||
|
"Vous avez atteint votre limite de dépense mensuelle de {{amount}} $. Gérez vos limites ici : {{membersUrl}}",
|
||||||
|
"zen.api.error.modelDisabled": "Le modèle est désactivé",
|
||||||
|
|
||||||
"black.meta.title": "OpenCode Black | Accédez aux meilleurs modèles de code au monde",
|
"black.meta.title": "OpenCode Black | Accédez aux meilleurs modèles de code au monde",
|
||||||
"black.meta.description": "Accédez à Claude, GPT, Gemini et plus avec les forfaits d'abonnement OpenCode Black.",
|
"black.meta.description": "Accédez à Claude, GPT, Gemini et plus avec les forfaits d'abonnement OpenCode Black.",
|
||||||
"black.hero.title": "Accédez aux meilleurs modèles de code au monde",
|
"black.hero.title": "Accédez aux meilleurs modèles de code au monde",
|
||||||
|
|
@ -457,6 +482,7 @@ export const dict = {
|
||||||
"workspace.reload.updatePaymentMethod": "Veuillez mettre à jour votre méthode de paiement et réessayer.",
|
"workspace.reload.updatePaymentMethod": "Veuillez mettre à jour votre méthode de paiement et réessayer.",
|
||||||
"workspace.reload.retrying": "Nouvelle tentative...",
|
"workspace.reload.retrying": "Nouvelle tentative...",
|
||||||
"workspace.reload.retry": "Réessayer",
|
"workspace.reload.retry": "Réessayer",
|
||||||
|
"workspace.reload.error.paymentFailed": "Échec du paiement.",
|
||||||
|
|
||||||
"workspace.payments.title": "Historique des paiements",
|
"workspace.payments.title": "Historique des paiements",
|
||||||
"workspace.payments.subtitle": "Transactions de paiement récentes.",
|
"workspace.payments.subtitle": "Transactions de paiement récentes.",
|
||||||
|
|
@ -581,6 +607,10 @@ export const dict = {
|
||||||
"enterprise.form.send": "Envoyer",
|
"enterprise.form.send": "Envoyer",
|
||||||
"enterprise.form.sending": "Envoi...",
|
"enterprise.form.sending": "Envoi...",
|
||||||
"enterprise.form.success": "Message envoyé, nous vous contacterons bientôt.",
|
"enterprise.form.success": "Message envoyé, nous vous contacterons bientôt.",
|
||||||
|
"enterprise.form.success.submitted": "Formulaire soumis avec succès.",
|
||||||
|
"enterprise.form.error.allFieldsRequired": "Tous les champs sont requis.",
|
||||||
|
"enterprise.form.error.invalidEmailFormat": "Format d'e-mail invalide.",
|
||||||
|
"enterprise.form.error.internalServer": "Erreur interne du serveur.",
|
||||||
"enterprise.faq.title": "FAQ",
|
"enterprise.faq.title": "FAQ",
|
||||||
"enterprise.faq.q1": "Qu'est-ce que OpenCode Enterprise ?",
|
"enterprise.faq.q1": "Qu'est-ce que OpenCode Enterprise ?",
|
||||||
"enterprise.faq.a1":
|
"enterprise.faq.a1":
|
||||||
|
|
@ -640,4 +670,5 @@ export const dict = {
|
||||||
"bench.detail.table.duration": "Durée",
|
"bench.detail.table.duration": "Durée",
|
||||||
"bench.detail.run.title": "Exécution {{n}}",
|
"bench.detail.run.title": "Exécution {{n}}",
|
||||||
"bench.detail.rawJson": "JSON brut",
|
"bench.detail.rawJson": "JSON brut",
|
||||||
|
"bench.submission.error.allFieldsRequired": "Tous les champs sont requis.",
|
||||||
} satisfies Dict
|
} satisfies Dict
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export const dict = {
|
||||||
"nav.home": "Home",
|
"nav.home": "Home",
|
||||||
"nav.openMenu": "Apri menu",
|
"nav.openMenu": "Apri menu",
|
||||||
"nav.getStartedFree": "Inizia gratis",
|
"nav.getStartedFree": "Inizia gratis",
|
||||||
|
"nav.logoAlt": "OpenCode",
|
||||||
|
|
||||||
"nav.context.copyLogo": "Copia il logo come SVG",
|
"nav.context.copyLogo": "Copia il logo come SVG",
|
||||||
"nav.context.copyWordmark": "Copia il wordmark come SVG",
|
"nav.context.copyWordmark": "Copia il wordmark come SVG",
|
||||||
|
|
@ -42,9 +43,13 @@ export const dict = {
|
||||||
"notFound.docs": "Documentazione",
|
"notFound.docs": "Documentazione",
|
||||||
"notFound.github": "GitHub",
|
"notFound.github": "GitHub",
|
||||||
"notFound.discord": "Discord",
|
"notFound.discord": "Discord",
|
||||||
|
"notFound.logoLightAlt": "logo chiaro di opencode",
|
||||||
|
"notFound.logoDarkAlt": "logo scuro di opencode",
|
||||||
|
|
||||||
"user.logout": "Esci",
|
"user.logout": "Esci",
|
||||||
|
|
||||||
|
"auth.callback.error.codeMissing": "Nessun codice di autorizzazione trovato.",
|
||||||
|
|
||||||
"workspace.select": "Seleziona workspace",
|
"workspace.select": "Seleziona workspace",
|
||||||
"workspace.createNew": "+ Crea nuovo workspace",
|
"workspace.createNew": "+ Crea nuovo workspace",
|
||||||
"workspace.modal.title": "Crea nuovo workspace",
|
"workspace.modal.title": "Crea nuovo workspace",
|
||||||
|
|
@ -76,6 +81,8 @@ export const dict = {
|
||||||
"error.reloadAmountMin": "L'importo della ricarica deve essere almeno ${{amount}}",
|
"error.reloadAmountMin": "L'importo della ricarica deve essere almeno ${{amount}}",
|
||||||
"error.reloadTriggerMin": "La soglia del saldo deve essere almeno ${{amount}}",
|
"error.reloadTriggerMin": "La soglia del saldo deve essere almeno ${{amount}}",
|
||||||
|
|
||||||
|
"app.meta.description": "OpenCode - L'agente di programmazione open source.",
|
||||||
|
|
||||||
"home.title": "OpenCode | L'agente di coding IA open source",
|
"home.title": "OpenCode | L'agente di coding IA open source",
|
||||||
|
|
||||||
"temp.title": "opencode | Agente di coding IA costruito per il terminale",
|
"temp.title": "opencode | Agente di coding IA costruito per il terminale",
|
||||||
|
|
@ -91,6 +98,8 @@ export const dict = {
|
||||||
"temp.feature.models.afterLink": ", inclusi modelli locali",
|
"temp.feature.models.afterLink": ", inclusi modelli locali",
|
||||||
"temp.screenshot.caption": "OpenCode TUI con il tema tokyonight",
|
"temp.screenshot.caption": "OpenCode TUI con il tema tokyonight",
|
||||||
"temp.screenshot.alt": "OpenCode TUI con tema tokyonight",
|
"temp.screenshot.alt": "OpenCode TUI con tema tokyonight",
|
||||||
|
"temp.logoLightAlt": "logo chiaro di opencode",
|
||||||
|
"temp.logoDarkAlt": "logo scuro di opencode",
|
||||||
|
|
||||||
"home.banner.badge": "Nuovo",
|
"home.banner.badge": "Nuovo",
|
||||||
"home.banner.text": "App desktop disponibile in beta",
|
"home.banner.text": "App desktop disponibile in beta",
|
||||||
|
|
@ -240,6 +249,24 @@ export const dict = {
|
||||||
"Tutti i modelli Zen sono ospitati negli Stati Uniti. I provider seguono una policy di zero-retention e non usano i tuoi dati per l'addestramento dei modelli, con le",
|
"Tutti i modelli Zen sono ospitati negli Stati Uniti. I provider seguono una policy di zero-retention e non usano i tuoi dati per l'addestramento dei modelli, con le",
|
||||||
"zen.privacy.exceptionsLink": "seguenti eccezioni",
|
"zen.privacy.exceptionsLink": "seguenti eccezioni",
|
||||||
|
|
||||||
|
"zen.api.error.rateLimitExceeded": "Limite di richieste superato. Riprova più tardi.",
|
||||||
|
"zen.api.error.modelNotSupported": "Modello {{model}} non supportato",
|
||||||
|
"zen.api.error.modelFormatNotSupported": "Modello {{model}} non supportato per il formato {{format}}",
|
||||||
|
"zen.api.error.noProviderAvailable": "Nessun provider disponibile",
|
||||||
|
"zen.api.error.providerNotSupported": "Provider {{provider}} non supportato",
|
||||||
|
"zen.api.error.missingApiKey": "Chiave API mancante.",
|
||||||
|
"zen.api.error.invalidApiKey": "Chiave API non valida.",
|
||||||
|
"zen.api.error.subscriptionQuotaExceeded": "Quota dell'abbonamento superata. Riprova tra {{retryIn}}.",
|
||||||
|
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||||
|
"Quota dell'abbonamento superata. Puoi continuare a utilizzare modelli gratuiti.",
|
||||||
|
"zen.api.error.noPaymentMethod": "Nessun metodo di pagamento. Aggiungi un metodo di pagamento qui: {{billingUrl}}",
|
||||||
|
"zen.api.error.insufficientBalance": "Saldo insufficiente. Gestisci la tua fatturazione qui: {{billingUrl}}",
|
||||||
|
"zen.api.error.workspaceMonthlyLimitReached":
|
||||||
|
"La tua area di lavoro ha raggiunto il limite di spesa mensile di ${{amount}}. Gestisci i tuoi limiti qui: {{billingUrl}}",
|
||||||
|
"zen.api.error.userMonthlyLimitReached":
|
||||||
|
"Hai raggiunto il tuo limite di spesa mensile di ${{amount}}. Gestisci i tuoi limiti qui: {{membersUrl}}",
|
||||||
|
"zen.api.error.modelDisabled": "Il modello è disabilitato",
|
||||||
|
|
||||||
"black.meta.title": "OpenCode Black | Accedi ai migliori modelli di coding al mondo",
|
"black.meta.title": "OpenCode Black | Accedi ai migliori modelli di coding al mondo",
|
||||||
"black.meta.description":
|
"black.meta.description":
|
||||||
"Ottieni l'accesso a Claude, GPT, Gemini e altri con i piani di abbonamento OpenCode Black.",
|
"Ottieni l'accesso a Claude, GPT, Gemini e altri con i piani di abbonamento OpenCode Black.",
|
||||||
|
|
@ -451,6 +478,7 @@ export const dict = {
|
||||||
"workspace.reload.updatePaymentMethod": "Aggiorna il tuo metodo di pagamento e riprova.",
|
"workspace.reload.updatePaymentMethod": "Aggiorna il tuo metodo di pagamento e riprova.",
|
||||||
"workspace.reload.retrying": "Riprovo...",
|
"workspace.reload.retrying": "Riprovo...",
|
||||||
"workspace.reload.retry": "Riprova",
|
"workspace.reload.retry": "Riprova",
|
||||||
|
"workspace.reload.error.paymentFailed": "Pagamento fallito.",
|
||||||
|
|
||||||
"workspace.payments.title": "Cronologia Pagamenti",
|
"workspace.payments.title": "Cronologia Pagamenti",
|
||||||
"workspace.payments.subtitle": "Transazioni di pagamento recenti.",
|
"workspace.payments.subtitle": "Transazioni di pagamento recenti.",
|
||||||
|
|
@ -569,6 +597,10 @@ export const dict = {
|
||||||
"enterprise.form.send": "Invia",
|
"enterprise.form.send": "Invia",
|
||||||
"enterprise.form.sending": "Invio...",
|
"enterprise.form.sending": "Invio...",
|
||||||
"enterprise.form.success": "Messaggio inviato, ti contatteremo presto.",
|
"enterprise.form.success": "Messaggio inviato, ti contatteremo presto.",
|
||||||
|
"enterprise.form.success.submitted": "Modulo inviato con successo.",
|
||||||
|
"enterprise.form.error.allFieldsRequired": "Tutti i campi sono obbligatori.",
|
||||||
|
"enterprise.form.error.invalidEmailFormat": "Formato email non valido.",
|
||||||
|
"enterprise.form.error.internalServer": "Errore interno del server.",
|
||||||
"enterprise.faq.title": "FAQ",
|
"enterprise.faq.title": "FAQ",
|
||||||
"enterprise.faq.q1": "Cos'è OpenCode Enterprise?",
|
"enterprise.faq.q1": "Cos'è OpenCode Enterprise?",
|
||||||
"enterprise.faq.a1":
|
"enterprise.faq.a1":
|
||||||
|
|
@ -601,6 +633,7 @@ export const dict = {
|
||||||
"bench.list.table.agent": "Agente",
|
"bench.list.table.agent": "Agente",
|
||||||
"bench.list.table.model": "Modello",
|
"bench.list.table.model": "Modello",
|
||||||
"bench.list.table.score": "Punteggio",
|
"bench.list.table.score": "Punteggio",
|
||||||
|
"bench.submission.error.allFieldsRequired": "Tutti i campi sono obbligatori.",
|
||||||
|
|
||||||
"bench.detail.title": "Benchmark - {{task}}",
|
"bench.detail.title": "Benchmark - {{task}}",
|
||||||
"bench.detail.notFound": "Task non trovato",
|
"bench.detail.notFound": "Task non trovato",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export const dict = {
|
||||||
"nav.home": "ホーム",
|
"nav.home": "ホーム",
|
||||||
"nav.openMenu": "メニューを開く",
|
"nav.openMenu": "メニューを開く",
|
||||||
"nav.getStartedFree": "無料ではじめる",
|
"nav.getStartedFree": "無料ではじめる",
|
||||||
|
"nav.logoAlt": "OpenCode",
|
||||||
|
|
||||||
"nav.context.copyLogo": "ロゴをSVGでコピー",
|
"nav.context.copyLogo": "ロゴをSVGでコピー",
|
||||||
"nav.context.copyWordmark": "ワードマークをSVGでコピー",
|
"nav.context.copyWordmark": "ワードマークをSVGでコピー",
|
||||||
|
|
@ -42,9 +43,13 @@ export const dict = {
|
||||||
"notFound.docs": "ドキュメント",
|
"notFound.docs": "ドキュメント",
|
||||||
"notFound.github": "GitHub",
|
"notFound.github": "GitHub",
|
||||||
"notFound.discord": "Discord",
|
"notFound.discord": "Discord",
|
||||||
|
"notFound.logoLightAlt": "opencodeのロゴ(ライト)",
|
||||||
|
"notFound.logoDarkAlt": "opencodeのロゴ(ダーク)",
|
||||||
|
|
||||||
"user.logout": "ログアウト",
|
"user.logout": "ログアウト",
|
||||||
|
|
||||||
|
"auth.callback.error.codeMissing": "認証コードが見つかりません。",
|
||||||
|
|
||||||
"workspace.select": "ワークスペースを選択",
|
"workspace.select": "ワークスペースを選択",
|
||||||
"workspace.createNew": "+ 新しいワークスペースを作成",
|
"workspace.createNew": "+ 新しいワークスペースを作成",
|
||||||
"workspace.modal.title": "新しいワークスペースを作成",
|
"workspace.modal.title": "新しいワークスペースを作成",
|
||||||
|
|
@ -76,6 +81,8 @@ export const dict = {
|
||||||
"error.reloadAmountMin": "リロード額は少なくとも ${{amount}} である必要があります",
|
"error.reloadAmountMin": "リロード額は少なくとも ${{amount}} である必要があります",
|
||||||
"error.reloadTriggerMin": "残高トリガーは少なくとも ${{amount}} である必要があります",
|
"error.reloadTriggerMin": "残高トリガーは少なくとも ${{amount}} である必要があります",
|
||||||
|
|
||||||
|
"app.meta.description": "OpenCode - オープンソースのコーディングエージェント。",
|
||||||
|
|
||||||
"home.title": "OpenCode | オープンソースのAIコーディングエージェント",
|
"home.title": "OpenCode | オープンソースのAIコーディングエージェント",
|
||||||
|
|
||||||
"temp.title": "OpenCode | ターミナル向けに構築されたAIコーディングエージェント",
|
"temp.title": "OpenCode | ターミナル向けに構築されたAIコーディングエージェント",
|
||||||
|
|
@ -91,6 +98,8 @@ export const dict = {
|
||||||
"temp.feature.models.afterLink": "を通じて75以上のLLMプロバイダーをサポート",
|
"temp.feature.models.afterLink": "を通じて75以上のLLMプロバイダーをサポート",
|
||||||
"temp.screenshot.caption": "tokyonight テーマを使用した OpenCode TUI",
|
"temp.screenshot.caption": "tokyonight テーマを使用した OpenCode TUI",
|
||||||
"temp.screenshot.alt": "tokyonight テーマの OpenCode TUI",
|
"temp.screenshot.alt": "tokyonight テーマの OpenCode TUI",
|
||||||
|
"temp.logoLightAlt": "opencodeのロゴ(ライト)",
|
||||||
|
"temp.logoDarkAlt": "opencodeのロゴ(ダーク)",
|
||||||
|
|
||||||
"home.banner.badge": "新着",
|
"home.banner.badge": "新着",
|
||||||
"home.banner.text": "デスクトップアプリのベータ版が利用可能",
|
"home.banner.text": "デスクトップアプリのベータ版が利用可能",
|
||||||
|
|
@ -239,6 +248,25 @@ export const dict = {
|
||||||
"すべてのZenモデルは米国でホストされています。プロバイダーはゼロ保持ポリシーに従い、モデルのトレーニングにデータを使用しません(",
|
"すべてのZenモデルは米国でホストされています。プロバイダーはゼロ保持ポリシーに従い、モデルのトレーニングにデータを使用しません(",
|
||||||
"zen.privacy.exceptionsLink": "以下の例外",
|
"zen.privacy.exceptionsLink": "以下の例外",
|
||||||
|
|
||||||
|
"zen.api.error.rateLimitExceeded": "レート制限を超えました。後でもう一度お試しください。",
|
||||||
|
"zen.api.error.modelNotSupported": "モデル {{model}} はサポートされていません",
|
||||||
|
"zen.api.error.modelFormatNotSupported": "フォーマット {{format}} ではモデル {{model}} はサポートされていません",
|
||||||
|
"zen.api.error.noProviderAvailable": "利用可能なプロバイダーがありません",
|
||||||
|
"zen.api.error.providerNotSupported": "プロバイダー {{provider}} はサポートされていません",
|
||||||
|
"zen.api.error.missingApiKey": "APIキーがありません。",
|
||||||
|
"zen.api.error.invalidApiKey": "無効なAPIキーです。",
|
||||||
|
"zen.api.error.subscriptionQuotaExceeded":
|
||||||
|
"サブスクリプションの制限を超えました。{{retryIn}} 後に再試行してください。",
|
||||||
|
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||||
|
"サブスクリプションの制限を超えました。無料モデルは引き続きご利用いただけます。",
|
||||||
|
"zen.api.error.noPaymentMethod": "お支払い方法がありません。こちらからお支払い方法を追加してください: {{billingUrl}}",
|
||||||
|
"zen.api.error.insufficientBalance": "残高が不足しています。こちらから請求を管理してください: {{billingUrl}}",
|
||||||
|
"zen.api.error.workspaceMonthlyLimitReached":
|
||||||
|
"ワークスペースが月額の利用上限 ${{amount}} に達しました。こちらから上限を管理してください: {{billingUrl}}",
|
||||||
|
"zen.api.error.userMonthlyLimitReached":
|
||||||
|
"月額の利用上限 ${{amount}} に達しました。こちらから上限を管理してください: {{membersUrl}}",
|
||||||
|
"zen.api.error.modelDisabled": "モデルが無効です",
|
||||||
|
|
||||||
"black.meta.title": "OpenCode Black | 世界最高峰のコーディングモデルすべてにアクセス",
|
"black.meta.title": "OpenCode Black | 世界最高峰のコーディングモデルすべてにアクセス",
|
||||||
"black.meta.description": "OpenCode Black サブスクリプションプランで、Claude、GPT、Gemini などにアクセス。",
|
"black.meta.description": "OpenCode Black サブスクリプションプランで、Claude、GPT、Gemini などにアクセス。",
|
||||||
"black.hero.title": "世界最高峰のコーディングモデルすべてにアクセス",
|
"black.hero.title": "世界最高峰のコーディングモデルすべてにアクセス",
|
||||||
|
|
@ -448,6 +476,7 @@ export const dict = {
|
||||||
"workspace.reload.updatePaymentMethod": "支払い方法を更新して、もう一度お試しください。",
|
"workspace.reload.updatePaymentMethod": "支払い方法を更新して、もう一度お試しください。",
|
||||||
"workspace.reload.retrying": "再試行中...",
|
"workspace.reload.retrying": "再試行中...",
|
||||||
"workspace.reload.retry": "再試行",
|
"workspace.reload.retry": "再試行",
|
||||||
|
"workspace.reload.error.paymentFailed": "支払いに失敗しました。",
|
||||||
|
|
||||||
"workspace.payments.title": "支払い履歴",
|
"workspace.payments.title": "支払い履歴",
|
||||||
"workspace.payments.subtitle": "最近の支払い取引。",
|
"workspace.payments.subtitle": "最近の支払い取引。",
|
||||||
|
|
@ -568,6 +597,10 @@ export const dict = {
|
||||||
"enterprise.form.send": "送信",
|
"enterprise.form.send": "送信",
|
||||||
"enterprise.form.sending": "送信中...",
|
"enterprise.form.sending": "送信中...",
|
||||||
"enterprise.form.success": "送信しました。まもなくご連絡いたします。",
|
"enterprise.form.success": "送信しました。まもなくご連絡いたします。",
|
||||||
|
"enterprise.form.success.submitted": "フォームが正常に送信されました。",
|
||||||
|
"enterprise.form.error.allFieldsRequired": "すべての項目は必須です。",
|
||||||
|
"enterprise.form.error.invalidEmailFormat": "無効なメール形式です。",
|
||||||
|
"enterprise.form.error.internalServer": "内部サーバーエラー。",
|
||||||
"enterprise.faq.title": "FAQ",
|
"enterprise.faq.title": "FAQ",
|
||||||
"enterprise.faq.q1": "OpenCode Enterpriseとは?",
|
"enterprise.faq.q1": "OpenCode Enterpriseとは?",
|
||||||
"enterprise.faq.a1":
|
"enterprise.faq.a1":
|
||||||
|
|
@ -600,6 +633,7 @@ export const dict = {
|
||||||
"bench.list.table.agent": "エージェント",
|
"bench.list.table.agent": "エージェント",
|
||||||
"bench.list.table.model": "モデル",
|
"bench.list.table.model": "モデル",
|
||||||
"bench.list.table.score": "スコア",
|
"bench.list.table.score": "スコア",
|
||||||
|
"bench.submission.error.allFieldsRequired": "すべての項目は必須です。",
|
||||||
|
|
||||||
"bench.detail.title": "ベンチマーク - {{task}}",
|
"bench.detail.title": "ベンチマーク - {{task}}",
|
||||||
"bench.detail.notFound": "タスクが見つかりません",
|
"bench.detail.notFound": "タスクが見つかりません",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export const dict = {
|
||||||
"nav.home": "홈",
|
"nav.home": "홈",
|
||||||
"nav.openMenu": "메뉴 열기",
|
"nav.openMenu": "메뉴 열기",
|
||||||
"nav.getStartedFree": "무료로 시작하기",
|
"nav.getStartedFree": "무료로 시작하기",
|
||||||
|
"nav.logoAlt": "OpenCode",
|
||||||
|
|
||||||
"nav.context.copyLogo": "로고를 SVG로 복사",
|
"nav.context.copyLogo": "로고를 SVG로 복사",
|
||||||
"nav.context.copyWordmark": "워드마크를 SVG로 복사",
|
"nav.context.copyWordmark": "워드마크를 SVG로 복사",
|
||||||
|
|
@ -42,9 +43,13 @@ export const dict = {
|
||||||
"notFound.docs": "문서",
|
"notFound.docs": "문서",
|
||||||
"notFound.github": "GitHub",
|
"notFound.github": "GitHub",
|
||||||
"notFound.discord": "Discord",
|
"notFound.discord": "Discord",
|
||||||
|
"notFound.logoLightAlt": "opencode 밝은 로고",
|
||||||
|
"notFound.logoDarkAlt": "opencode 어두운 로고",
|
||||||
|
|
||||||
"user.logout": "로그아웃",
|
"user.logout": "로그아웃",
|
||||||
|
|
||||||
|
"auth.callback.error.codeMissing": "인증 코드를 찾을 수 없습니다.",
|
||||||
|
|
||||||
"workspace.select": "워크스페이스 선택",
|
"workspace.select": "워크스페이스 선택",
|
||||||
"workspace.createNew": "+ 새 워크스페이스 만들기",
|
"workspace.createNew": "+ 새 워크스페이스 만들기",
|
||||||
"workspace.modal.title": "새 워크스페이스 만들기",
|
"workspace.modal.title": "새 워크스페이스 만들기",
|
||||||
|
|
@ -76,6 +81,8 @@ export const dict = {
|
||||||
"error.reloadAmountMin": "충전 금액은 최소 ${{amount}}이어야 합니다",
|
"error.reloadAmountMin": "충전 금액은 최소 ${{amount}}이어야 합니다",
|
||||||
"error.reloadTriggerMin": "잔액 트리거는 최소 ${{amount}}이어야 합니다",
|
"error.reloadTriggerMin": "잔액 트리거는 최소 ${{amount}}이어야 합니다",
|
||||||
|
|
||||||
|
"app.meta.description": "OpenCode - 오픈 소스 코딩 에이전트.",
|
||||||
|
|
||||||
"home.title": "OpenCode | 오픈 소스 AI 코딩 에이전트",
|
"home.title": "OpenCode | 오픈 소스 AI 코딩 에이전트",
|
||||||
|
|
||||||
"temp.title": "OpenCode | 터미널을 위해 만들어진 AI 코딩 에이전트",
|
"temp.title": "OpenCode | 터미널을 위해 만들어진 AI 코딩 에이전트",
|
||||||
|
|
@ -91,6 +98,8 @@ export const dict = {
|
||||||
"temp.feature.models.afterLink": "를 통해 75개 이상의 LLM 제공자 지원",
|
"temp.feature.models.afterLink": "를 통해 75개 이상의 LLM 제공자 지원",
|
||||||
"temp.screenshot.caption": "tokyonight 테마가 적용된 OpenCode TUI",
|
"temp.screenshot.caption": "tokyonight 테마가 적용된 OpenCode TUI",
|
||||||
"temp.screenshot.alt": "tokyonight 테마가 적용된 OpenCode TUI",
|
"temp.screenshot.alt": "tokyonight 테마가 적용된 OpenCode TUI",
|
||||||
|
"temp.logoLightAlt": "opencode 밝은 로고",
|
||||||
|
"temp.logoDarkAlt": "opencode 어두운 로고",
|
||||||
|
|
||||||
"home.banner.badge": "신규",
|
"home.banner.badge": "신규",
|
||||||
"home.banner.text": "데스크톱 앱 베타 버전 출시",
|
"home.banner.text": "데스크톱 앱 베타 버전 출시",
|
||||||
|
|
@ -236,6 +245,24 @@ export const dict = {
|
||||||
"모든 Zen 모델은 미국에서 호스팅됩니다. 제공자들은 데이터 보존 금지 정책을 따르며 모델 학습에 데이터를 사용하지 않습니다. 단,",
|
"모든 Zen 모델은 미국에서 호스팅됩니다. 제공자들은 데이터 보존 금지 정책을 따르며 모델 학습에 데이터를 사용하지 않습니다. 단,",
|
||||||
"zen.privacy.exceptionsLink": "다음 예외",
|
"zen.privacy.exceptionsLink": "다음 예외",
|
||||||
|
|
||||||
|
"zen.api.error.rateLimitExceeded": "속도 제한을 초과했습니다. 나중에 다시 시도해 주세요.",
|
||||||
|
"zen.api.error.modelNotSupported": "{{model}} 모델은 지원되지 않습니다",
|
||||||
|
"zen.api.error.modelFormatNotSupported": "{{model}} 모델은 {{format}} 형식에 대해 지원되지 않습니다",
|
||||||
|
"zen.api.error.noProviderAvailable": "사용 가능한 제공자가 없습니다",
|
||||||
|
"zen.api.error.providerNotSupported": "{{provider}} 제공자는 지원되지 않습니다",
|
||||||
|
"zen.api.error.missingApiKey": "API 키가 누락되었습니다.",
|
||||||
|
"zen.api.error.invalidApiKey": "유효하지 않은 API 키입니다.",
|
||||||
|
"zen.api.error.subscriptionQuotaExceeded": "구독 할당량을 초과했습니다. {{retryIn}} 후 다시 시도해 주세요.",
|
||||||
|
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||||
|
"구독 할당량을 초과했습니다. 무료 모델은 계속 사용할 수 있습니다.",
|
||||||
|
"zen.api.error.noPaymentMethod": "결제 수단이 없습니다. 결제 수단을 추가하세요: {{billingUrl}}",
|
||||||
|
"zen.api.error.insufficientBalance": "잔액이 부족합니다. 결제 관리를 여기서 하세요: {{billingUrl}}",
|
||||||
|
"zen.api.error.workspaceMonthlyLimitReached":
|
||||||
|
"워크스페이스의 월간 지출 한도인 ${{amount}}에 도달했습니다. 한도 관리를 여기서 하세요: {{billingUrl}}",
|
||||||
|
"zen.api.error.userMonthlyLimitReached":
|
||||||
|
"월간 지출 한도인 ${{amount}}에 도달했습니다. 한도 관리를 여기서 하세요: {{membersUrl}}",
|
||||||
|
"zen.api.error.modelDisabled": "모델이 비활성화되었습니다",
|
||||||
|
|
||||||
"black.meta.title": "OpenCode Black | 세계 최고의 코딩 모델에 액세스하세요",
|
"black.meta.title": "OpenCode Black | 세계 최고의 코딩 모델에 액세스하세요",
|
||||||
"black.meta.description": "OpenCode Black 구독 플랜으로 Claude, GPT, Gemini 등에 액세스하세요.",
|
"black.meta.description": "OpenCode Black 구독 플랜으로 Claude, GPT, Gemini 등에 액세스하세요.",
|
||||||
"black.hero.title": "세계 최고의 코딩 모델에 액세스하세요",
|
"black.hero.title": "세계 최고의 코딩 모델에 액세스하세요",
|
||||||
|
|
@ -445,6 +472,7 @@ export const dict = {
|
||||||
"workspace.reload.updatePaymentMethod": "결제 수단을 업데이트하고 다시 시도해 주세요.",
|
"workspace.reload.updatePaymentMethod": "결제 수단을 업데이트하고 다시 시도해 주세요.",
|
||||||
"workspace.reload.retrying": "재시도 중...",
|
"workspace.reload.retrying": "재시도 중...",
|
||||||
"workspace.reload.retry": "재시도",
|
"workspace.reload.retry": "재시도",
|
||||||
|
"workspace.reload.error.paymentFailed": "결제에 실패했습니다.",
|
||||||
|
|
||||||
"workspace.payments.title": "결제 내역",
|
"workspace.payments.title": "결제 내역",
|
||||||
"workspace.payments.subtitle": "최근 결제 거래 내역입니다.",
|
"workspace.payments.subtitle": "최근 결제 거래 내역입니다.",
|
||||||
|
|
@ -562,6 +590,10 @@ export const dict = {
|
||||||
"enterprise.form.send": "전송",
|
"enterprise.form.send": "전송",
|
||||||
"enterprise.form.sending": "전송 중...",
|
"enterprise.form.sending": "전송 중...",
|
||||||
"enterprise.form.success": "메시지가 전송되었습니다. 곧 연락드리겠습니다.",
|
"enterprise.form.success": "메시지가 전송되었습니다. 곧 연락드리겠습니다.",
|
||||||
|
"enterprise.form.success.submitted": "양식이 성공적으로 제출되었습니다.",
|
||||||
|
"enterprise.form.error.allFieldsRequired": "모든 필드는 필수 항목입니다.",
|
||||||
|
"enterprise.form.error.invalidEmailFormat": "유효하지 않은 이메일 형식입니다.",
|
||||||
|
"enterprise.form.error.internalServer": "내부 서버 오류입니다.",
|
||||||
"enterprise.faq.title": "FAQ",
|
"enterprise.faq.title": "FAQ",
|
||||||
"enterprise.faq.q1": "OpenCode 엔터프라이즈란 무엇인가요?",
|
"enterprise.faq.q1": "OpenCode 엔터프라이즈란 무엇인가요?",
|
||||||
"enterprise.faq.a1":
|
"enterprise.faq.a1":
|
||||||
|
|
@ -594,6 +626,7 @@ export const dict = {
|
||||||
"bench.list.table.agent": "에이전트",
|
"bench.list.table.agent": "에이전트",
|
||||||
"bench.list.table.model": "모델",
|
"bench.list.table.model": "모델",
|
||||||
"bench.list.table.score": "점수",
|
"bench.list.table.score": "점수",
|
||||||
|
"bench.submission.error.allFieldsRequired": "모든 필드는 필수 항목입니다.",
|
||||||
|
|
||||||
"bench.detail.title": "벤치마크 - {{task}}",
|
"bench.detail.title": "벤치마크 - {{task}}",
|
||||||
"bench.detail.notFound": "태스크를 찾을 수 없음",
|
"bench.detail.notFound": "태스크를 찾을 수 없음",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export const dict = {
|
||||||
"nav.home": "Hjem",
|
"nav.home": "Hjem",
|
||||||
"nav.openMenu": "Åpne meny",
|
"nav.openMenu": "Åpne meny",
|
||||||
"nav.getStartedFree": "Kom i gang gratis",
|
"nav.getStartedFree": "Kom i gang gratis",
|
||||||
|
"nav.logoAlt": "OpenCode",
|
||||||
|
|
||||||
"nav.context.copyLogo": "Kopier logo som SVG",
|
"nav.context.copyLogo": "Kopier logo som SVG",
|
||||||
"nav.context.copyWordmark": "Kopier wordmark som SVG",
|
"nav.context.copyWordmark": "Kopier wordmark som SVG",
|
||||||
|
|
@ -42,9 +43,13 @@ export const dict = {
|
||||||
"notFound.docs": "Dokumentasjon",
|
"notFound.docs": "Dokumentasjon",
|
||||||
"notFound.github": "GitHub",
|
"notFound.github": "GitHub",
|
||||||
"notFound.discord": "Discord",
|
"notFound.discord": "Discord",
|
||||||
|
"notFound.logoLightAlt": "opencode logo lys",
|
||||||
|
"notFound.logoDarkAlt": "opencode logo mørk",
|
||||||
|
|
||||||
"user.logout": "Logg ut",
|
"user.logout": "Logg ut",
|
||||||
|
|
||||||
|
"auth.callback.error.codeMissing": "Ingen autorisasjonskode funnet.",
|
||||||
|
|
||||||
"workspace.select": "Velg arbeidsområde",
|
"workspace.select": "Velg arbeidsområde",
|
||||||
"workspace.createNew": "+ Opprett nytt arbeidsområde",
|
"workspace.createNew": "+ Opprett nytt arbeidsområde",
|
||||||
"workspace.modal.title": "Opprett nytt arbeidsområde",
|
"workspace.modal.title": "Opprett nytt arbeidsområde",
|
||||||
|
|
@ -76,6 +81,8 @@ export const dict = {
|
||||||
"error.reloadAmountMin": "Påfyllingsbeløp må være minst ${{amount}}",
|
"error.reloadAmountMin": "Påfyllingsbeløp må være minst ${{amount}}",
|
||||||
"error.reloadTriggerMin": "Saldo-trigger må være minst ${{amount}}",
|
"error.reloadTriggerMin": "Saldo-trigger må være minst ${{amount}}",
|
||||||
|
|
||||||
|
"app.meta.description": "OpenCode - Den åpne kildekode kodingsagenten.",
|
||||||
|
|
||||||
"home.title": "OpenCode | Den åpne kildekode AI-kodingsagenten",
|
"home.title": "OpenCode | Den åpne kildekode AI-kodingsagenten",
|
||||||
|
|
||||||
"temp.title": "opencode | AI-kodingsagent bygget for terminalen",
|
"temp.title": "opencode | AI-kodingsagent bygget for terminalen",
|
||||||
|
|
@ -91,6 +98,8 @@ export const dict = {
|
||||||
"temp.feature.models.afterLink": ", inkludert lokale modeller",
|
"temp.feature.models.afterLink": ", inkludert lokale modeller",
|
||||||
"temp.screenshot.caption": "opencode TUI med tokyonight-tema",
|
"temp.screenshot.caption": "opencode TUI med tokyonight-tema",
|
||||||
"temp.screenshot.alt": "opencode TUI med tokyonight-tema",
|
"temp.screenshot.alt": "opencode TUI med tokyonight-tema",
|
||||||
|
"temp.logoLightAlt": "opencode logo lys",
|
||||||
|
"temp.logoDarkAlt": "opencode logo mørk",
|
||||||
|
|
||||||
"home.banner.badge": "Ny",
|
"home.banner.badge": "Ny",
|
||||||
"home.banner.text": "Desktop-app tilgjengelig i beta",
|
"home.banner.text": "Desktop-app tilgjengelig i beta",
|
||||||
|
|
@ -240,6 +249,24 @@ export const dict = {
|
||||||
"Alle Zen-modeller hostes i USA. Leverandører følger en policy om null oppbevaring og bruker ikke dataene dine til modelltrening, med",
|
"Alle Zen-modeller hostes i USA. Leverandører følger en policy om null oppbevaring og bruker ikke dataene dine til modelltrening, med",
|
||||||
"zen.privacy.exceptionsLink": "følgende unntak",
|
"zen.privacy.exceptionsLink": "følgende unntak",
|
||||||
|
|
||||||
|
"zen.api.error.rateLimitExceeded": "Rate limit overskredet. Vennligst prøv igjen senere.",
|
||||||
|
"zen.api.error.modelNotSupported": "Modell {{model}} støttes ikke",
|
||||||
|
"zen.api.error.modelFormatNotSupported": "Modell {{model}} støttes ikke for format {{format}}",
|
||||||
|
"zen.api.error.noProviderAvailable": "Ingen leverandør tilgjengelig",
|
||||||
|
"zen.api.error.providerNotSupported": "Leverandør {{provider}} støttes ikke",
|
||||||
|
"zen.api.error.missingApiKey": "Mangler API-nøkkel.",
|
||||||
|
"zen.api.error.invalidApiKey": "Ugyldig API-nøkkel.",
|
||||||
|
"zen.api.error.subscriptionQuotaExceeded": "Abonnementskvote overskredet. Prøv igjen om {{retryIn}}.",
|
||||||
|
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||||
|
"Abonnementskvote overskredet. Du kan fortsette å bruke gratis modeller.",
|
||||||
|
"zen.api.error.noPaymentMethod": "Ingen betalingsmetode. Legg til en betalingsmetode her: {{billingUrl}}",
|
||||||
|
"zen.api.error.insufficientBalance": "Utilstrekkelig saldo. Administrer faktureringen din her: {{billingUrl}}",
|
||||||
|
"zen.api.error.workspaceMonthlyLimitReached":
|
||||||
|
"Arbeidsområdet ditt har nådd sin månedlige utgiftsgrense på ${{amount}}. Administrer grensene dine her: {{billingUrl}}",
|
||||||
|
"zen.api.error.userMonthlyLimitReached":
|
||||||
|
"Du har nådd din månedlige utgiftsgrense på ${{amount}}. Administrer grensene dine her: {{membersUrl}}",
|
||||||
|
"zen.api.error.modelDisabled": "Modellen er deaktivert",
|
||||||
|
|
||||||
"black.meta.title": "OpenCode Black | Få tilgang til verdens beste kodemodeller",
|
"black.meta.title": "OpenCode Black | Få tilgang til verdens beste kodemodeller",
|
||||||
"black.meta.description": "Få tilgang til Claude, GPT, Gemini og mer med OpenCode Black-abonnementer.",
|
"black.meta.description": "Få tilgang til Claude, GPT, Gemini og mer med OpenCode Black-abonnementer.",
|
||||||
"black.hero.title": "Få tilgang til verdens beste kodemodeller",
|
"black.hero.title": "Få tilgang til verdens beste kodemodeller",
|
||||||
|
|
@ -449,6 +476,7 @@ export const dict = {
|
||||||
"workspace.reload.updatePaymentMethod": "Vennligst oppdater betalingsmetoden din og prøv på nytt.",
|
"workspace.reload.updatePaymentMethod": "Vennligst oppdater betalingsmetoden din og prøv på nytt.",
|
||||||
"workspace.reload.retrying": "Prøver på nytt...",
|
"workspace.reload.retrying": "Prøver på nytt...",
|
||||||
"workspace.reload.retry": "Prøv på nytt",
|
"workspace.reload.retry": "Prøv på nytt",
|
||||||
|
"workspace.reload.error.paymentFailed": "Betaling mislyktes.",
|
||||||
|
|
||||||
"workspace.payments.title": "Betalingshistorikk",
|
"workspace.payments.title": "Betalingshistorikk",
|
||||||
"workspace.payments.subtitle": "Nylige betalingstransaksjoner.",
|
"workspace.payments.subtitle": "Nylige betalingstransaksjoner.",
|
||||||
|
|
@ -567,6 +595,10 @@ export const dict = {
|
||||||
"enterprise.form.send": "Send",
|
"enterprise.form.send": "Send",
|
||||||
"enterprise.form.sending": "Sender...",
|
"enterprise.form.sending": "Sender...",
|
||||||
"enterprise.form.success": "Melding sendt, vi tar kontakt snart.",
|
"enterprise.form.success": "Melding sendt, vi tar kontakt snart.",
|
||||||
|
"enterprise.form.success.submitted": "Skjemaet ble sendt inn.",
|
||||||
|
"enterprise.form.error.allFieldsRequired": "Alle felt er obligatoriske.",
|
||||||
|
"enterprise.form.error.invalidEmailFormat": "Ugyldig e-postformat.",
|
||||||
|
"enterprise.form.error.internalServer": "Intern serverfeil.",
|
||||||
"enterprise.faq.title": "FAQ",
|
"enterprise.faq.title": "FAQ",
|
||||||
"enterprise.faq.q1": "Hva er OpenCode Enterprise?",
|
"enterprise.faq.q1": "Hva er OpenCode Enterprise?",
|
||||||
"enterprise.faq.a1":
|
"enterprise.faq.a1":
|
||||||
|
|
@ -599,6 +631,7 @@ export const dict = {
|
||||||
"bench.list.table.agent": "Agent",
|
"bench.list.table.agent": "Agent",
|
||||||
"bench.list.table.model": "Modell",
|
"bench.list.table.model": "Modell",
|
||||||
"bench.list.table.score": "Poengsum",
|
"bench.list.table.score": "Poengsum",
|
||||||
|
"bench.submission.error.allFieldsRequired": "Alle felt er obligatoriske.",
|
||||||
|
|
||||||
"bench.detail.title": "Benchmark - {{task}}",
|
"bench.detail.title": "Benchmark - {{task}}",
|
||||||
"bench.detail.notFound": "Oppgave ikke funnet",
|
"bench.detail.notFound": "Oppgave ikke funnet",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ export const dict = {
|
||||||
"nav.home": "Strona główna",
|
"nav.home": "Strona główna",
|
||||||
"nav.openMenu": "Otwórz menu",
|
"nav.openMenu": "Otwórz menu",
|
||||||
"nav.getStartedFree": "Zacznij za darmo",
|
"nav.getStartedFree": "Zacznij za darmo",
|
||||||
|
"nav.logoAlt": "OpenCode",
|
||||||
|
|
||||||
"nav.context.copyLogo": "Skopiuj logo jako SVG",
|
"nav.context.copyLogo": "Skopiuj logo jako SVG",
|
||||||
"nav.context.copyWordmark": "Skopiuj logotyp jako SVG",
|
"nav.context.copyWordmark": "Skopiuj logotyp jako SVG",
|
||||||
|
|
@ -41,9 +42,13 @@ export const dict = {
|
||||||
"notFound.docs": "Dokumentacja",
|
"notFound.docs": "Dokumentacja",
|
||||||
"notFound.github": "GitHub",
|
"notFound.github": "GitHub",
|
||||||
"notFound.discord": "Discord",
|
"notFound.discord": "Discord",
|
||||||
|
"notFound.logoLightAlt": "jasne logo opencode",
|
||||||
|
"notFound.logoDarkAlt": "ciemne logo opencode",
|
||||||
|
|
||||||
"user.logout": "Wyloguj się",
|
"user.logout": "Wyloguj się",
|
||||||
|
|
||||||
|
"auth.callback.error.codeMissing": "Nie znaleziono kodu autoryzacji.",
|
||||||
|
|
||||||
"workspace.select": "Wybierz obszar roboczy",
|
"workspace.select": "Wybierz obszar roboczy",
|
||||||
"workspace.createNew": "+ Utwórz nowy obszar roboczy",
|
"workspace.createNew": "+ Utwórz nowy obszar roboczy",
|
||||||
"workspace.modal.title": "Utwórz nowy obszar roboczy",
|
"workspace.modal.title": "Utwórz nowy obszar roboczy",
|
||||||
|
|
@ -75,6 +80,8 @@ export const dict = {
|
||||||
"error.reloadAmountMin": "Kwota doładowania musi wynosić co najmniej ${{amount}}",
|
"error.reloadAmountMin": "Kwota doładowania musi wynosić co najmniej ${{amount}}",
|
||||||
"error.reloadTriggerMin": "Próg salda musi wynosić co najmniej ${{amount}}",
|
"error.reloadTriggerMin": "Próg salda musi wynosić co najmniej ${{amount}}",
|
||||||
|
|
||||||
|
"app.meta.description": "OpenCode - Otwartoźródłowy agent programistyczny.",
|
||||||
|
|
||||||
"home.title": "OpenCode | Open source'owy agent AI do kodowania",
|
"home.title": "OpenCode | Open source'owy agent AI do kodowania",
|
||||||
|
|
||||||
"temp.title": "opencode | Agent AI do kodowania zbudowany dla terminala",
|
"temp.title": "opencode | Agent AI do kodowania zbudowany dla terminala",
|
||||||
|
|
@ -90,6 +97,8 @@ export const dict = {
|
||||||
"temp.feature.models.afterLink": ", w tym modele lokalne",
|
"temp.feature.models.afterLink": ", w tym modele lokalne",
|
||||||
"temp.screenshot.caption": "OpenCode TUI z motywem tokyonight",
|
"temp.screenshot.caption": "OpenCode TUI z motywem tokyonight",
|
||||||
"temp.screenshot.alt": "OpenCode TUI z motywem tokyonight",
|
"temp.screenshot.alt": "OpenCode TUI z motywem tokyonight",
|
||||||
|
"temp.logoLightAlt": "jasne logo opencode",
|
||||||
|
"temp.logoDarkAlt": "ciemne logo opencode",
|
||||||
|
|
||||||
"home.banner.badge": "Nowość",
|
"home.banner.badge": "Nowość",
|
||||||
"home.banner.text": "Aplikacja desktopowa dostępna w wersji beta",
|
"home.banner.text": "Aplikacja desktopowa dostępna w wersji beta",
|
||||||
|
|
@ -241,6 +250,24 @@ export const dict = {
|
||||||
"Wszystkie modele Zen są hostowane w USA. Dostawcy stosują politykę zerowej retencji i nie wykorzystują Twoich danych do trenowania modeli, z",
|
"Wszystkie modele Zen są hostowane w USA. Dostawcy stosują politykę zerowej retencji i nie wykorzystują Twoich danych do trenowania modeli, z",
|
||||||
"zen.privacy.exceptionsLink": "następującymi wyjątkami",
|
"zen.privacy.exceptionsLink": "następującymi wyjątkami",
|
||||||
|
|
||||||
|
"zen.api.error.rateLimitExceeded": "Przekroczono limit zapytań. Spróbuj ponownie później.",
|
||||||
|
"zen.api.error.modelNotSupported": "Model {{model}} nie jest obsługiwany",
|
||||||
|
"zen.api.error.modelFormatNotSupported": "Model {{model}} nie jest obsługiwany dla formatu {{format}}",
|
||||||
|
"zen.api.error.noProviderAvailable": "Brak dostępnego dostawcy",
|
||||||
|
"zen.api.error.providerNotSupported": "Dostawca {{provider}} nie jest obsługiwany",
|
||||||
|
"zen.api.error.missingApiKey": "Brak klucza API.",
|
||||||
|
"zen.api.error.invalidApiKey": "Nieprawidłowy klucz API.",
|
||||||
|
"zen.api.error.subscriptionQuotaExceeded": "Przekroczono limit subskrypcji. Spróbuj ponownie za {{retryIn}}.",
|
||||||
|
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||||
|
"Przekroczono limit subskrypcji. Możesz kontynuować korzystanie z darmowych modeli.",
|
||||||
|
"zen.api.error.noPaymentMethod": "Brak metody płatności. Dodaj metodę płatności tutaj: {{billingUrl}}",
|
||||||
|
"zen.api.error.insufficientBalance": "Niewystarczające saldo. Zarządzaj swoimi płatnościami tutaj: {{billingUrl}}",
|
||||||
|
"zen.api.error.workspaceMonthlyLimitReached":
|
||||||
|
"Twoja przestrzeń robocza osiągnęła miesięczny limit wydatków w wysokości ${{amount}}. Zarządzaj swoimi limitami tutaj: {{billingUrl}}",
|
||||||
|
"zen.api.error.userMonthlyLimitReached":
|
||||||
|
"Osiągnąłeś swój miesięczny limit wydatków w wysokości ${{amount}}. Zarządzaj swoimi limitami tutaj: {{membersUrl}}",
|
||||||
|
"zen.api.error.modelDisabled": "Model jest wyłączony",
|
||||||
|
|
||||||
"black.meta.title": "OpenCode Black | Dostęp do najlepszych na świecie modeli kodujących",
|
"black.meta.title": "OpenCode Black | Dostęp do najlepszych na świecie modeli kodujących",
|
||||||
"black.meta.description": "Uzyskaj dostęp do Claude, GPT, Gemini i innych dzięki planom subskrypcji OpenCode Black.",
|
"black.meta.description": "Uzyskaj dostęp do Claude, GPT, Gemini i innych dzięki planom subskrypcji OpenCode Black.",
|
||||||
"black.hero.title": "Dostęp do najlepszych na świecie modeli kodujących",
|
"black.hero.title": "Dostęp do najlepszych na świecie modeli kodujących",
|
||||||
|
|
@ -450,6 +477,7 @@ export const dict = {
|
||||||
"workspace.reload.updatePaymentMethod": "Zaktualizuj metodę płatności i spróbuj ponownie.",
|
"workspace.reload.updatePaymentMethod": "Zaktualizuj metodę płatności i spróbuj ponownie.",
|
||||||
"workspace.reload.retrying": "Ponawianie...",
|
"workspace.reload.retrying": "Ponawianie...",
|
||||||
"workspace.reload.retry": "Spróbuj ponownie",
|
"workspace.reload.retry": "Spróbuj ponownie",
|
||||||
|
"workspace.reload.error.paymentFailed": "Płatność nie powiodła się.",
|
||||||
|
|
||||||
"workspace.payments.title": "Historia płatności",
|
"workspace.payments.title": "Historia płatności",
|
||||||
"workspace.payments.subtitle": "Ostatnie transakcje płatnicze.",
|
"workspace.payments.subtitle": "Ostatnie transakcje płatnicze.",
|
||||||
|
|
@ -570,6 +598,10 @@ export const dict = {
|
||||||
"enterprise.form.send": "Wyślij",
|
"enterprise.form.send": "Wyślij",
|
||||||
"enterprise.form.sending": "Wysyłanie...",
|
"enterprise.form.sending": "Wysyłanie...",
|
||||||
"enterprise.form.success": "Wiadomość wysłana, skontaktujemy się wkrótce.",
|
"enterprise.form.success": "Wiadomość wysłana, skontaktujemy się wkrótce.",
|
||||||
|
"enterprise.form.success.submitted": "Formularz został pomyślnie wysłany.",
|
||||||
|
"enterprise.form.error.allFieldsRequired": "Wszystkie pola są wymagane.",
|
||||||
|
"enterprise.form.error.invalidEmailFormat": "Nieprawidłowy format adresu e-mail.",
|
||||||
|
"enterprise.form.error.internalServer": "Wewnętrzny błąd serwera.",
|
||||||
"enterprise.faq.title": "FAQ",
|
"enterprise.faq.title": "FAQ",
|
||||||
"enterprise.faq.q1": "Czym jest OpenCode Enterprise?",
|
"enterprise.faq.q1": "Czym jest OpenCode Enterprise?",
|
||||||
"enterprise.faq.a1":
|
"enterprise.faq.a1":
|
||||||
|
|
@ -602,6 +634,7 @@ export const dict = {
|
||||||
"bench.list.table.agent": "Agent",
|
"bench.list.table.agent": "Agent",
|
||||||
"bench.list.table.model": "Model",
|
"bench.list.table.model": "Model",
|
||||||
"bench.list.table.score": "Wynik",
|
"bench.list.table.score": "Wynik",
|
||||||
|
"bench.submission.error.allFieldsRequired": "Wszystkie pola są wymagane.",
|
||||||
|
|
||||||
"bench.detail.title": "Benchmark - {{task}}",
|
"bench.detail.title": "Benchmark - {{task}}",
|
||||||
"bench.detail.notFound": "Nie znaleziono zadania",
|
"bench.detail.notFound": "Nie znaleziono zadania",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export const dict = {
|
||||||
"nav.home": "Главная",
|
"nav.home": "Главная",
|
||||||
"nav.openMenu": "Открыть меню",
|
"nav.openMenu": "Открыть меню",
|
||||||
"nav.getStartedFree": "Начать бесплатно",
|
"nav.getStartedFree": "Начать бесплатно",
|
||||||
|
"nav.logoAlt": "OpenCode",
|
||||||
|
|
||||||
"nav.context.copyLogo": "Скопировать логотип как SVG",
|
"nav.context.copyLogo": "Скопировать логотип как SVG",
|
||||||
"nav.context.copyWordmark": "Скопировать название как SVG",
|
"nav.context.copyWordmark": "Скопировать название как SVG",
|
||||||
|
|
@ -42,9 +43,13 @@ export const dict = {
|
||||||
"notFound.docs": "Документация",
|
"notFound.docs": "Документация",
|
||||||
"notFound.github": "GitHub",
|
"notFound.github": "GitHub",
|
||||||
"notFound.discord": "Discord",
|
"notFound.discord": "Discord",
|
||||||
|
"notFound.logoLightAlt": "светлый логотип opencode",
|
||||||
|
"notFound.logoDarkAlt": "темный логотип opencode",
|
||||||
|
|
||||||
"user.logout": "Выйти",
|
"user.logout": "Выйти",
|
||||||
|
|
||||||
|
"auth.callback.error.codeMissing": "Код авторизации не найден.",
|
||||||
|
|
||||||
"workspace.select": "Выбрать рабочее пространство",
|
"workspace.select": "Выбрать рабочее пространство",
|
||||||
"workspace.createNew": "+ Создать рабочее пространство",
|
"workspace.createNew": "+ Создать рабочее пространство",
|
||||||
"workspace.modal.title": "Создать рабочее пространство",
|
"workspace.modal.title": "Создать рабочее пространство",
|
||||||
|
|
@ -76,6 +81,8 @@ export const dict = {
|
||||||
"error.reloadAmountMin": "Сумма пополнения должна быть не менее ${{amount}}",
|
"error.reloadAmountMin": "Сумма пополнения должна быть не менее ${{amount}}",
|
||||||
"error.reloadTriggerMin": "Порог баланса должен быть не менее ${{amount}}",
|
"error.reloadTriggerMin": "Порог баланса должен быть не менее ${{amount}}",
|
||||||
|
|
||||||
|
"app.meta.description": "OpenCode - AI-агент с открытым кодом для программирования.",
|
||||||
|
|
||||||
"home.title": "OpenCode | AI-агент с открытым кодом для программирования",
|
"home.title": "OpenCode | AI-агент с открытым кодом для программирования",
|
||||||
|
|
||||||
"temp.title": "opencode | AI-агент для программирования в терминале",
|
"temp.title": "opencode | AI-агент для программирования в терминале",
|
||||||
|
|
@ -91,6 +98,8 @@ export const dict = {
|
||||||
"temp.feature.models.afterLink": ", включая локальные модели",
|
"temp.feature.models.afterLink": ", включая локальные модели",
|
||||||
"temp.screenshot.caption": "OpenCode TUI с темой tokyonight",
|
"temp.screenshot.caption": "OpenCode TUI с темой tokyonight",
|
||||||
"temp.screenshot.alt": "OpenCode TUI с темой tokyonight",
|
"temp.screenshot.alt": "OpenCode TUI с темой tokyonight",
|
||||||
|
"temp.logoLightAlt": "светлый логотип opencode",
|
||||||
|
"temp.logoDarkAlt": "темный логотип opencode",
|
||||||
|
|
||||||
"home.banner.badge": "Новое",
|
"home.banner.badge": "Новое",
|
||||||
"home.banner.text": "Доступно десктопное приложение (бета)",
|
"home.banner.text": "Доступно десктопное приложение (бета)",
|
||||||
|
|
@ -244,6 +253,24 @@ export const dict = {
|
||||||
"Все модели Zen размещены в США. Провайдеры следуют политике нулевого хранения и не используют ваши данные для обучения моделей, за",
|
"Все модели Zen размещены в США. Провайдеры следуют политике нулевого хранения и не используют ваши данные для обучения моделей, за",
|
||||||
"zen.privacy.exceptionsLink": "следующими исключениями",
|
"zen.privacy.exceptionsLink": "следующими исключениями",
|
||||||
|
|
||||||
|
"zen.api.error.rateLimitExceeded": "Превышен лимит запросов. Пожалуйста, попробуйте позже.",
|
||||||
|
"zen.api.error.modelNotSupported": "Модель {{model}} не поддерживается",
|
||||||
|
"zen.api.error.modelFormatNotSupported": "Модель {{model}} не поддерживается для формата {{format}}",
|
||||||
|
"zen.api.error.noProviderAvailable": "Нет доступных провайдеров",
|
||||||
|
"zen.api.error.providerNotSupported": "Провайдер {{provider}} не поддерживается",
|
||||||
|
"zen.api.error.missingApiKey": "Отсутствует API ключ.",
|
||||||
|
"zen.api.error.invalidApiKey": "Неверный API ключ.",
|
||||||
|
"zen.api.error.subscriptionQuotaExceeded": "Квота подписки превышена. Повторите попытку через {{retryIn}}.",
|
||||||
|
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||||
|
"Квота подписки превышена. Вы можете продолжить использовать бесплатные модели.",
|
||||||
|
"zen.api.error.noPaymentMethod": "Нет способа оплаты. Добавьте способ оплаты здесь: {{billingUrl}}",
|
||||||
|
"zen.api.error.insufficientBalance": "Недостаточно средств. Управляйте оплатой здесь: {{billingUrl}}",
|
||||||
|
"zen.api.error.workspaceMonthlyLimitReached":
|
||||||
|
"Ваше рабочее пространство достигло ежемесячного лимита расходов в ${{amount}}. Управляйте лимитами здесь: {{billingUrl}}",
|
||||||
|
"zen.api.error.userMonthlyLimitReached":
|
||||||
|
"Вы достигли ежемесячного лимита расходов в ${{amount}}. Управляйте лимитами здесь: {{membersUrl}}",
|
||||||
|
"zen.api.error.modelDisabled": "Модель отключена",
|
||||||
|
|
||||||
"black.meta.title": "OpenCode Black | Доступ к лучшим моделям для кодинга в мире",
|
"black.meta.title": "OpenCode Black | Доступ к лучшим моделям для кодинга в мире",
|
||||||
"black.meta.description": "Получите доступ к Claude, GPT, Gemini и другим моделям с подпиской OpenCode Black.",
|
"black.meta.description": "Получите доступ к Claude, GPT, Gemini и другим моделям с подпиской OpenCode Black.",
|
||||||
"black.hero.title": "Доступ к лучшим моделям для кодинга в мире",
|
"black.hero.title": "Доступ к лучшим моделям для кодинга в мире",
|
||||||
|
|
@ -455,6 +482,7 @@ export const dict = {
|
||||||
"workspace.reload.updatePaymentMethod": "Пожалуйста, обновите способ оплаты и попробуйте снова.",
|
"workspace.reload.updatePaymentMethod": "Пожалуйста, обновите способ оплаты и попробуйте снова.",
|
||||||
"workspace.reload.retrying": "Повторная попытка...",
|
"workspace.reload.retrying": "Повторная попытка...",
|
||||||
"workspace.reload.retry": "Повторить",
|
"workspace.reload.retry": "Повторить",
|
||||||
|
"workspace.reload.error.paymentFailed": "Ошибка оплаты.",
|
||||||
|
|
||||||
"workspace.payments.title": "История платежей",
|
"workspace.payments.title": "История платежей",
|
||||||
"workspace.payments.subtitle": "Недавние транзакции.",
|
"workspace.payments.subtitle": "Недавние транзакции.",
|
||||||
|
|
@ -574,6 +602,10 @@ export const dict = {
|
||||||
"enterprise.form.send": "Отправить",
|
"enterprise.form.send": "Отправить",
|
||||||
"enterprise.form.sending": "Отправка...",
|
"enterprise.form.sending": "Отправка...",
|
||||||
"enterprise.form.success": "Сообщение отправлено, мы скоро свяжемся с вами.",
|
"enterprise.form.success": "Сообщение отправлено, мы скоро свяжемся с вами.",
|
||||||
|
"enterprise.form.success.submitted": "Форма успешно отправлена.",
|
||||||
|
"enterprise.form.error.allFieldsRequired": "Все поля обязательны.",
|
||||||
|
"enterprise.form.error.invalidEmailFormat": "Неверный формат email.",
|
||||||
|
"enterprise.form.error.internalServer": "Внутренняя ошибка сервера.",
|
||||||
"enterprise.faq.title": "FAQ",
|
"enterprise.faq.title": "FAQ",
|
||||||
"enterprise.faq.q1": "Что такое OpenCode Enterprise?",
|
"enterprise.faq.q1": "Что такое OpenCode Enterprise?",
|
||||||
"enterprise.faq.a1":
|
"enterprise.faq.a1":
|
||||||
|
|
@ -606,6 +638,7 @@ export const dict = {
|
||||||
"bench.list.table.agent": "Агент",
|
"bench.list.table.agent": "Агент",
|
||||||
"bench.list.table.model": "Модель",
|
"bench.list.table.model": "Модель",
|
||||||
"bench.list.table.score": "Оценка",
|
"bench.list.table.score": "Оценка",
|
||||||
|
"bench.submission.error.allFieldsRequired": "Все поля обязательны.",
|
||||||
|
|
||||||
"bench.detail.title": "Бенчмарк - {{task}}",
|
"bench.detail.title": "Бенчмарк - {{task}}",
|
||||||
"bench.detail.notFound": "Задача не найдена",
|
"bench.detail.notFound": "Задача не найдена",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export const dict = {
|
||||||
"nav.home": "หน้าหลัก",
|
"nav.home": "หน้าหลัก",
|
||||||
"nav.openMenu": "เปิดเมนู",
|
"nav.openMenu": "เปิดเมนู",
|
||||||
"nav.getStartedFree": "เริ่มต้นฟรี",
|
"nav.getStartedFree": "เริ่มต้นฟรี",
|
||||||
|
"nav.logoAlt": "OpenCode",
|
||||||
|
|
||||||
"nav.context.copyLogo": "คัดลอกโลโก้เป็น SVG",
|
"nav.context.copyLogo": "คัดลอกโลโก้เป็น SVG",
|
||||||
"nav.context.copyWordmark": "คัดลอกตัวอักษรแบรนด์เป็น SVG",
|
"nav.context.copyWordmark": "คัดลอกตัวอักษรแบรนด์เป็น SVG",
|
||||||
|
|
@ -42,9 +43,13 @@ export const dict = {
|
||||||
"notFound.docs": "เอกสาร",
|
"notFound.docs": "เอกสาร",
|
||||||
"notFound.github": "GitHub",
|
"notFound.github": "GitHub",
|
||||||
"notFound.discord": "Discord",
|
"notFound.discord": "Discord",
|
||||||
|
"notFound.logoLightAlt": "โลโก้ opencode แบบสว่าง",
|
||||||
|
"notFound.logoDarkAlt": "โลโก้ opencode แบบมืด",
|
||||||
|
|
||||||
"user.logout": "ออกจากระบบ",
|
"user.logout": "ออกจากระบบ",
|
||||||
|
|
||||||
|
"auth.callback.error.codeMissing": "ไม่พบ authorization code",
|
||||||
|
|
||||||
"workspace.select": "เลือก Workspace",
|
"workspace.select": "เลือก Workspace",
|
||||||
"workspace.createNew": "+ สร้าง Workspace ใหม่",
|
"workspace.createNew": "+ สร้าง Workspace ใหม่",
|
||||||
"workspace.modal.title": "สร้าง Workspace ใหม่",
|
"workspace.modal.title": "สร้าง Workspace ใหม่",
|
||||||
|
|
@ -76,6 +81,8 @@ export const dict = {
|
||||||
"error.reloadAmountMin": "จำนวนเงินที่โหลดซ้ำต้องมีอย่างน้อย ${{amount}}",
|
"error.reloadAmountMin": "จำนวนเงินที่โหลดซ้ำต้องมีอย่างน้อย ${{amount}}",
|
||||||
"error.reloadTriggerMin": "ยอดคงเหลือที่กระตุ้นต้องมีอย่างน้อย ${{amount}}",
|
"error.reloadTriggerMin": "ยอดคงเหลือที่กระตุ้นต้องมีอย่างน้อย ${{amount}}",
|
||||||
|
|
||||||
|
"app.meta.description": "OpenCode - เอเจนต์เขียนโค้ดแบบโอเพนซอร์ส",
|
||||||
|
|
||||||
"home.title": "OpenCode | เอเจนต์เขียนโค้ดด้วย AI แบบโอเพนซอร์ส",
|
"home.title": "OpenCode | เอเจนต์เขียนโค้ดด้วย AI แบบโอเพนซอร์ส",
|
||||||
|
|
||||||
"temp.title": "OpenCode | เอเจนต์เขียนโค้ด AI ที่สร้างมาเพื่อเทอร์มินัล",
|
"temp.title": "OpenCode | เอเจนต์เขียนโค้ด AI ที่สร้างมาเพื่อเทอร์มินัล",
|
||||||
|
|
@ -91,6 +98,8 @@ export const dict = {
|
||||||
"temp.feature.models.afterLink": "รวมถึงโมเดล Local",
|
"temp.feature.models.afterLink": "รวมถึงโมเดล Local",
|
||||||
"temp.screenshot.caption": "OpenCode TUI พร้อมธีม tokyonight",
|
"temp.screenshot.caption": "OpenCode TUI พร้อมธีม tokyonight",
|
||||||
"temp.screenshot.alt": "OpenCode TUI พร้อมธีม tokyonight",
|
"temp.screenshot.alt": "OpenCode TUI พร้อมธีม tokyonight",
|
||||||
|
"temp.logoLightAlt": "โลโก้ opencode แบบสว่าง",
|
||||||
|
"temp.logoDarkAlt": "โลโก้ opencode แบบมืด",
|
||||||
|
|
||||||
"home.banner.badge": "ใหม่",
|
"home.banner.badge": "ใหม่",
|
||||||
"home.banner.text": "แอปเดสก์ท็อปพร้อมใช้งานในเวอร์ชันเบต้า",
|
"home.banner.text": "แอปเดสก์ท็อปพร้อมใช้งานในเวอร์ชันเบต้า",
|
||||||
|
|
@ -239,6 +248,24 @@ export const dict = {
|
||||||
"โมเดล Zen ทั้งหมดโฮสต์ในสหรัฐอเมริกา ผู้ให้บริการปฏิบัติตามนโยบายไม่เก็บรักษาข้อมูล (zero-retention policy) และไม่ใช้ข้อมูลของคุณสำหรับการฝึกโมเดล โดยมี",
|
"โมเดล Zen ทั้งหมดโฮสต์ในสหรัฐอเมริกา ผู้ให้บริการปฏิบัติตามนโยบายไม่เก็บรักษาข้อมูล (zero-retention policy) และไม่ใช้ข้อมูลของคุณสำหรับการฝึกโมเดล โดยมี",
|
||||||
"zen.privacy.exceptionsLink": "ข้อยกเว้นดังนี้",
|
"zen.privacy.exceptionsLink": "ข้อยกเว้นดังนี้",
|
||||||
|
|
||||||
|
"zen.api.error.rateLimitExceeded": "เกินขีดจำกัดอัตราการใช้งาน กรุณาลองใหม่ในภายหลัง",
|
||||||
|
"zen.api.error.modelNotSupported": "ไม่รองรับโมเดล {{model}}",
|
||||||
|
"zen.api.error.modelFormatNotSupported": "ไม่รองรับโมเดล {{model}} สำหรับรูปแบบ {{format}}",
|
||||||
|
"zen.api.error.noProviderAvailable": "ไม่มีผู้ให้บริการที่พร้อมใช้งาน",
|
||||||
|
"zen.api.error.providerNotSupported": "ไม่รองรับผู้ให้บริการ {{provider}}",
|
||||||
|
"zen.api.error.missingApiKey": "ไม่มี API key",
|
||||||
|
"zen.api.error.invalidApiKey": "API key ไม่ถูกต้อง",
|
||||||
|
"zen.api.error.subscriptionQuotaExceeded": "โควต้าการสมัครสมาชิกเกินขีดจำกัด ลองใหม่ในอีก {{retryIn}}",
|
||||||
|
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||||
|
"โควต้าการสมัครสมาชิกเกินขีดจำกัด คุณสามารถดำเนินการต่อโดยใช้โมเดลฟรี",
|
||||||
|
"zen.api.error.noPaymentMethod": "ไม่มีวิธีการชำระเงิน เพิ่มวิธีการชำระเงินที่นี่: {{billingUrl}}",
|
||||||
|
"zen.api.error.insufficientBalance": "ยอดเงินคงเหลือไม่เพียงพอ จัดการการเรียกเก็บเงินของคุณที่นี่: {{billingUrl}}",
|
||||||
|
"zen.api.error.workspaceMonthlyLimitReached":
|
||||||
|
"Workspace ของคุณถึงขีดจำกัดการใช้จ่ายรายเดือนที่ ${{amount}} แล้ว จัดการขีดจำกัดของคุณที่นี่: {{billingUrl}}",
|
||||||
|
"zen.api.error.userMonthlyLimitReached":
|
||||||
|
"คุณถึงขีดจำกัดการใช้จ่ายรายเดือนที่ ${{amount}} แล้ว จัดการขีดจำกัดของคุณที่นี่: {{membersUrl}}",
|
||||||
|
"zen.api.error.modelDisabled": "โมเดลถูกปิดใช้งาน",
|
||||||
|
|
||||||
"black.meta.title": "OpenCode Black | เข้าถึงโมเดลเขียนโค้ดที่ดีที่สุดในโลก",
|
"black.meta.title": "OpenCode Black | เข้าถึงโมเดลเขียนโค้ดที่ดีที่สุดในโลก",
|
||||||
"black.meta.description": "เข้าถึง Claude, GPT, Gemini และอื่นๆ ด้วยแผนสมาชิก OpenCode Black",
|
"black.meta.description": "เข้าถึง Claude, GPT, Gemini และอื่นๆ ด้วยแผนสมาชิก OpenCode Black",
|
||||||
"black.hero.title": "เข้าถึงโมเดลเขียนโค้ดที่ดีที่สุดในโลก",
|
"black.hero.title": "เข้าถึงโมเดลเขียนโค้ดที่ดีที่สุดในโลก",
|
||||||
|
|
@ -448,6 +475,7 @@ export const dict = {
|
||||||
"workspace.reload.updatePaymentMethod": "โปรดอัปเดตวิธีการชำระเงินของคุณแล้วลองอีกครั้ง",
|
"workspace.reload.updatePaymentMethod": "โปรดอัปเดตวิธีการชำระเงินของคุณแล้วลองอีกครั้ง",
|
||||||
"workspace.reload.retrying": "กำลังลองอีกครั้ง...",
|
"workspace.reload.retrying": "กำลังลองอีกครั้ง...",
|
||||||
"workspace.reload.retry": "ลองอีกครั้ง",
|
"workspace.reload.retry": "ลองอีกครั้ง",
|
||||||
|
"workspace.reload.error.paymentFailed": "การชำระเงินล้มเหลว",
|
||||||
|
|
||||||
"workspace.payments.title": "ประวัติการชำระเงิน",
|
"workspace.payments.title": "ประวัติการชำระเงิน",
|
||||||
"workspace.payments.subtitle": "รายการธุรกรรมการชำระเงินล่าสุด",
|
"workspace.payments.subtitle": "รายการธุรกรรมการชำระเงินล่าสุด",
|
||||||
|
|
@ -566,6 +594,10 @@ export const dict = {
|
||||||
"enterprise.form.send": "ส่ง",
|
"enterprise.form.send": "ส่ง",
|
||||||
"enterprise.form.sending": "กำลังส่ง...",
|
"enterprise.form.sending": "กำลังส่ง...",
|
||||||
"enterprise.form.success": "ส่งข้อความแล้ว เราจะติดต่อกลับเร็วๆ นี้",
|
"enterprise.form.success": "ส่งข้อความแล้ว เราจะติดต่อกลับเร็วๆ นี้",
|
||||||
|
"enterprise.form.success.submitted": "ส่งแบบฟอร์มสำเร็จแล้ว",
|
||||||
|
"enterprise.form.error.allFieldsRequired": "จำเป็นต้องกรอกทุกช่อง",
|
||||||
|
"enterprise.form.error.invalidEmailFormat": "รูปแบบอีเมลไม่ถูกต้อง",
|
||||||
|
"enterprise.form.error.internalServer": "เกิดข้อผิดพลาดภายในเซิร์ฟเวอร์",
|
||||||
"enterprise.faq.title": "คำถามที่พบบ่อย",
|
"enterprise.faq.title": "คำถามที่พบบ่อย",
|
||||||
"enterprise.faq.q1": "OpenCode Enterprise คืออะไร?",
|
"enterprise.faq.q1": "OpenCode Enterprise คืออะไร?",
|
||||||
"enterprise.faq.a1":
|
"enterprise.faq.a1":
|
||||||
|
|
@ -598,6 +630,7 @@ export const dict = {
|
||||||
"bench.list.table.agent": "เอเจนต์",
|
"bench.list.table.agent": "เอเจนต์",
|
||||||
"bench.list.table.model": "โมเดล",
|
"bench.list.table.model": "โมเดล",
|
||||||
"bench.list.table.score": "คะแนน",
|
"bench.list.table.score": "คะแนน",
|
||||||
|
"bench.submission.error.allFieldsRequired": "จำเป็นต้องกรอกทุกช่อง",
|
||||||
|
|
||||||
"bench.detail.title": "Benchmark - {{task}}",
|
"bench.detail.title": "Benchmark - {{task}}",
|
||||||
"bench.detail.notFound": "ไม่พบงาน",
|
"bench.detail.notFound": "ไม่พบงาน",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export const dict = {
|
||||||
"nav.home": "Ana sayfa",
|
"nav.home": "Ana sayfa",
|
||||||
"nav.openMenu": "Menüyü aç",
|
"nav.openMenu": "Menüyü aç",
|
||||||
"nav.getStartedFree": "Ücretsiz başla",
|
"nav.getStartedFree": "Ücretsiz başla",
|
||||||
|
"nav.logoAlt": "OpenCode",
|
||||||
|
|
||||||
"nav.context.copyLogo": "Logoyu SVG olarak kopyala",
|
"nav.context.copyLogo": "Logoyu SVG olarak kopyala",
|
||||||
"nav.context.copyWordmark": "Wordmark'ı SVG olarak kopyala",
|
"nav.context.copyWordmark": "Wordmark'ı SVG olarak kopyala",
|
||||||
|
|
@ -42,9 +43,13 @@ export const dict = {
|
||||||
"notFound.docs": "Dokümantasyon",
|
"notFound.docs": "Dokümantasyon",
|
||||||
"notFound.github": "GitHub",
|
"notFound.github": "GitHub",
|
||||||
"notFound.discord": "Discord",
|
"notFound.discord": "Discord",
|
||||||
|
"notFound.logoLightAlt": "opencode açık logo",
|
||||||
|
"notFound.logoDarkAlt": "opencode koyu logo",
|
||||||
|
|
||||||
"user.logout": "Çıkış",
|
"user.logout": "Çıkış",
|
||||||
|
|
||||||
|
"auth.callback.error.codeMissing": "Yetkilendirme kodu bulunamadı.",
|
||||||
|
|
||||||
"workspace.select": "Çalışma alanı seç",
|
"workspace.select": "Çalışma alanı seç",
|
||||||
"workspace.createNew": "+ Yeni çalışma alanı oluştur",
|
"workspace.createNew": "+ Yeni çalışma alanı oluştur",
|
||||||
"workspace.modal.title": "Yeni çalışma alanı oluştur",
|
"workspace.modal.title": "Yeni çalışma alanı oluştur",
|
||||||
|
|
@ -76,6 +81,8 @@ export const dict = {
|
||||||
"error.reloadAmountMin": "Yükleme tutarı en az ${{amount}} olmalıdır",
|
"error.reloadAmountMin": "Yükleme tutarı en az ${{amount}} olmalıdır",
|
||||||
"error.reloadTriggerMin": "Bakiye tetikleyicisi en az ${{amount}} olmalıdır",
|
"error.reloadTriggerMin": "Bakiye tetikleyicisi en az ${{amount}} olmalıdır",
|
||||||
|
|
||||||
|
"app.meta.description": "OpenCode - Açık kaynaklı kodlama ajanı.",
|
||||||
|
|
||||||
"home.title": "OpenCode | Açık kaynaklı yapay zeka kodlama ajanı",
|
"home.title": "OpenCode | Açık kaynaklı yapay zeka kodlama ajanı",
|
||||||
|
|
||||||
"temp.title": "opencode | Terminal için geliştirilmiş yapay zeka kodlama ajanı",
|
"temp.title": "opencode | Terminal için geliştirilmiş yapay zeka kodlama ajanı",
|
||||||
|
|
@ -91,9 +98,11 @@ export const dict = {
|
||||||
"temp.feature.models.afterLink": " üzerinden destekler",
|
"temp.feature.models.afterLink": " üzerinden destekler",
|
||||||
"temp.screenshot.caption": "opencode TUI ve tokyonight teması",
|
"temp.screenshot.caption": "opencode TUI ve tokyonight teması",
|
||||||
"temp.screenshot.alt": "tokyonight temalı opencode TUI",
|
"temp.screenshot.alt": "tokyonight temalı opencode TUI",
|
||||||
|
"temp.logoLightAlt": "opencode açık logo",
|
||||||
|
"temp.logoDarkAlt": "opencode koyu logo",
|
||||||
|
|
||||||
"home.banner.badge": "Yeni",
|
"home.banner.badge": "Yeni",
|
||||||
"home.banner.text": "Masaüstü uygulaması beta olarak kullanılabilir",
|
"home.banner.text": "Masaüstü uygulaması beta olarak mevcut",
|
||||||
"home.banner.platforms": "macOS, Windows ve Linux'ta",
|
"home.banner.platforms": "macOS, Windows ve Linux'ta",
|
||||||
"home.banner.downloadNow": "Şimdi indir",
|
"home.banner.downloadNow": "Şimdi indir",
|
||||||
"home.banner.downloadBetaNow": "Masaüstü betayı şimdi indir",
|
"home.banner.downloadBetaNow": "Masaüstü betayı şimdi indir",
|
||||||
|
|
@ -130,7 +139,7 @@ export const dict = {
|
||||||
"home.growth.contributors": "Katılımcılar",
|
"home.growth.contributors": "Katılımcılar",
|
||||||
"home.growth.monthlyDevs": "Aylık Geliştiriciler",
|
"home.growth.monthlyDevs": "Aylık Geliştiriciler",
|
||||||
|
|
||||||
"home.privacy.title": "Önce gizlilik için tasarlandı",
|
"home.privacy.title": "Gizlilik öncelikli tasarlandı",
|
||||||
"home.privacy.body":
|
"home.privacy.body":
|
||||||
"OpenCode kodunuzu veya bağlam verilerinizi saklamaz; bu sayede gizliliğe duyarlı ortamlarda çalışabilir.",
|
"OpenCode kodunuzu veya bağlam verilerinizi saklamaz; bu sayede gizliliğe duyarlı ortamlarda çalışabilir.",
|
||||||
"home.privacy.learnMore": "Hakkında daha fazla bilgi:",
|
"home.privacy.learnMore": "Hakkında daha fazla bilgi:",
|
||||||
|
|
@ -148,12 +157,12 @@ export const dict = {
|
||||||
"home.faq.a3.p2.afterZen": " hesabı oluşturabilirsiniz.",
|
"home.faq.a3.p2.afterZen": " hesabı oluşturabilirsiniz.",
|
||||||
"home.faq.a3.p3": "Zen'i öneriyoruz, ancak OpenCode OpenAI, Anthropic, xAI gibi popüler sağlayıcılarla da çalışır.",
|
"home.faq.a3.p3": "Zen'i öneriyoruz, ancak OpenCode OpenAI, Anthropic, xAI gibi popüler sağlayıcılarla da çalışır.",
|
||||||
"home.faq.a3.p4.beforeLocal": "Hatta",
|
"home.faq.a3.p4.beforeLocal": "Hatta",
|
||||||
"home.faq.a3.p4.localLink": "yerel modellerinizi",
|
"home.faq.a3.p4.localLink": "yerel modellerinizi bağlayabilirsiniz",
|
||||||
"home.faq.q4": "Mevcut AI aboneliklerimi OpenCode ile kullanabilir miyim?",
|
"home.faq.q4": "Mevcut AI aboneliklerimi OpenCode ile kullanabilir miyim?",
|
||||||
"home.faq.a4.p1":
|
"home.faq.a4.p1":
|
||||||
"Evet. OpenCode tüm büyük sağlayıcıların aboneliklerini destekler. Claude Pro/Max, ChatGPT Plus/Pro veya GitHub Copilot kullanabilirsiniz.",
|
"Evet. OpenCode tüm büyük sağlayıcıların aboneliklerini destekler. Claude Pro/Max, ChatGPT Plus/Pro veya GitHub Copilot kullanabilirsiniz.",
|
||||||
"home.faq.q5": "OpenCode'u sadece terminalde mi kullanabilirim?",
|
"home.faq.q5": "OpenCode'u sadece terminalde mi kullanabilirim?",
|
||||||
"home.faq.a5.beforeDesktop": "Artık hayır! OpenCode şimdi",
|
"home.faq.a5.beforeDesktop": "Artık hayır! OpenCode artık sizin bu cihazlarınıza",
|
||||||
"home.faq.a5.desktop": "masaüstü",
|
"home.faq.a5.desktop": "masaüstü",
|
||||||
"home.faq.a5.and": "ve",
|
"home.faq.a5.and": "ve",
|
||||||
"home.faq.a5.web": "web",
|
"home.faq.a5.web": "web",
|
||||||
|
|
@ -169,10 +178,10 @@ export const dict = {
|
||||||
"home.faq.a7.p2.shareLink": "paylaşım sayfaları",
|
"home.faq.a7.p2.shareLink": "paylaşım sayfaları",
|
||||||
"home.faq.q8": "OpenCode açık kaynak mı?",
|
"home.faq.q8": "OpenCode açık kaynak mı?",
|
||||||
"home.faq.a8.p1": "Evet, OpenCode tamamen açık kaynaktır. Kaynak kodu",
|
"home.faq.a8.p1": "Evet, OpenCode tamamen açık kaynaktır. Kaynak kodu",
|
||||||
"home.faq.a8.p2": "altında",
|
"home.faq.a8.p2": "'da",
|
||||||
"home.faq.a8.mitLicense": "MIT Lisansı",
|
"home.faq.a8.mitLicense": "MIT Lisansı",
|
||||||
"home.faq.a8.p3":
|
"home.faq.a8.p3":
|
||||||
", yani herkes kullanabilir, değiştirebilir veya geliştirmeye katkıda bulunabilir. Topluluktan herkes issue açabilir, pull request gönderebilir ve işlevselliği genişletebilir.",
|
"altında herkese açıktır, yani herkes kullanabilir, değiştirebilir veya geliştirmeye katkıda bulunabilir. Topluluktan herkes issue açabilir, pull request gönderebilir ve işlevselliği genişletebilir.",
|
||||||
|
|
||||||
"home.zenCta.title": "Kodlama ajanları için güvenilir, optimize modeller",
|
"home.zenCta.title": "Kodlama ajanları için güvenilir, optimize modeller",
|
||||||
"home.zenCta.body":
|
"home.zenCta.body":
|
||||||
|
|
@ -242,6 +251,24 @@ export const dict = {
|
||||||
"Tüm Zen modelleri ABD'de barındırılmaktadır. Sağlayıcılar sıfır saklama politikası izler ve verilerinizi model eğitimi için kullanmaz; şu",
|
"Tüm Zen modelleri ABD'de barındırılmaktadır. Sağlayıcılar sıfır saklama politikası izler ve verilerinizi model eğitimi için kullanmaz; şu",
|
||||||
"zen.privacy.exceptionsLink": "aşağıdaki istisnalar",
|
"zen.privacy.exceptionsLink": "aşağıdaki istisnalar",
|
||||||
|
|
||||||
|
"zen.api.error.rateLimitExceeded": "İstek limiti aşıldı. Lütfen daha sonra tekrar deneyin.",
|
||||||
|
"zen.api.error.modelNotSupported": "{{model}} modeli desteklenmiyor",
|
||||||
|
"zen.api.error.modelFormatNotSupported": "{{model}} modeli {{format}} formatı için desteklenmiyor",
|
||||||
|
"zen.api.error.noProviderAvailable": "Kullanılabilir sağlayıcı yok",
|
||||||
|
"zen.api.error.providerNotSupported": "{{provider}} sağlayıcısı desteklenmiyor",
|
||||||
|
"zen.api.error.missingApiKey": "API anahtarı eksik.",
|
||||||
|
"zen.api.error.invalidApiKey": "Geçersiz API anahtarı.",
|
||||||
|
"zen.api.error.subscriptionQuotaExceeded": "Abonelik kotası aşıldı. {{retryIn}} içinde tekrar deneyin.",
|
||||||
|
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||||
|
"Abonelik kotası aşıldı. Ücretsiz modelleri kullanmaya devam edebilirsiniz.",
|
||||||
|
"zen.api.error.noPaymentMethod": "Ödeme yöntemi bulunamadı. Buradan bir ödeme yöntemi ekleyin: {{billingUrl}}",
|
||||||
|
"zen.api.error.insufficientBalance": "Yetersiz bakiye. Faturalandırmanızı buradan yönetin: {{billingUrl}}",
|
||||||
|
"zen.api.error.workspaceMonthlyLimitReached":
|
||||||
|
"Çalışma alanınız aylık ${{amount}} harcama limitine ulaştı. Limitlerinizi buradan yönetin: {{billingUrl}}",
|
||||||
|
"zen.api.error.userMonthlyLimitReached":
|
||||||
|
"Aylık ${{amount}} harcama limitinize ulaştınız. Limitlerinizi buradan yönetin: {{membersUrl}}",
|
||||||
|
"zen.api.error.modelDisabled": "Model devre dışı",
|
||||||
|
|
||||||
"black.meta.title": "OpenCode Black | Dünyanın en iyi kodlama modellerine erişin",
|
"black.meta.title": "OpenCode Black | Dünyanın en iyi kodlama modellerine erişin",
|
||||||
"black.meta.description": "OpenCode Black abonelik planlarıyla Claude, GPT, Gemini ve daha fazlasına erişin.",
|
"black.meta.description": "OpenCode Black abonelik planlarıyla Claude, GPT, Gemini ve daha fazlasına erişin.",
|
||||||
"black.hero.title": "Dünyanın en iyi kodlama modellerine erişin",
|
"black.hero.title": "Dünyanın en iyi kodlama modellerine erişin",
|
||||||
|
|
@ -451,6 +478,7 @@ export const dict = {
|
||||||
"workspace.reload.updatePaymentMethod": "Lütfen ödeme yönteminizi güncelleyin ve tekrar deneyin.",
|
"workspace.reload.updatePaymentMethod": "Lütfen ödeme yönteminizi güncelleyin ve tekrar deneyin.",
|
||||||
"workspace.reload.retrying": "Yeniden deneniyor...",
|
"workspace.reload.retrying": "Yeniden deneniyor...",
|
||||||
"workspace.reload.retry": "Yeniden dene",
|
"workspace.reload.retry": "Yeniden dene",
|
||||||
|
"workspace.reload.error.paymentFailed": "Ödeme başarısız.",
|
||||||
|
|
||||||
"workspace.payments.title": "Ödeme Geçmişi",
|
"workspace.payments.title": "Ödeme Geçmişi",
|
||||||
"workspace.payments.subtitle": "Son ödeme işlemleri.",
|
"workspace.payments.subtitle": "Son ödeme işlemleri.",
|
||||||
|
|
@ -571,6 +599,10 @@ export const dict = {
|
||||||
"enterprise.form.send": "Gönder",
|
"enterprise.form.send": "Gönder",
|
||||||
"enterprise.form.sending": "Gönderiliyor...",
|
"enterprise.form.sending": "Gönderiliyor...",
|
||||||
"enterprise.form.success": "Mesaj gönderildi, yakında size dönüş yapacağız.",
|
"enterprise.form.success": "Mesaj gönderildi, yakında size dönüş yapacağız.",
|
||||||
|
"enterprise.form.success.submitted": "Form başarıyla gönderildi.",
|
||||||
|
"enterprise.form.error.allFieldsRequired": "Tüm alanlar gereklidir.",
|
||||||
|
"enterprise.form.error.invalidEmailFormat": "Geçersiz e-posta formatı.",
|
||||||
|
"enterprise.form.error.internalServer": "İç sunucu hatası.",
|
||||||
"enterprise.faq.title": "SSS",
|
"enterprise.faq.title": "SSS",
|
||||||
"enterprise.faq.q1": "OpenCode Enterprise nedir?",
|
"enterprise.faq.q1": "OpenCode Enterprise nedir?",
|
||||||
"enterprise.faq.a1":
|
"enterprise.faq.a1":
|
||||||
|
|
@ -603,6 +635,7 @@ export const dict = {
|
||||||
"bench.list.table.agent": "Ajan",
|
"bench.list.table.agent": "Ajan",
|
||||||
"bench.list.table.model": "Model",
|
"bench.list.table.model": "Model",
|
||||||
"bench.list.table.score": "Puan",
|
"bench.list.table.score": "Puan",
|
||||||
|
"bench.submission.error.allFieldsRequired": "Tüm alanlar gereklidir.",
|
||||||
|
|
||||||
"bench.detail.title": "Benchmark - {{task}}",
|
"bench.detail.title": "Benchmark - {{task}}",
|
||||||
"bench.detail.notFound": "Görev bulunamadı",
|
"bench.detail.notFound": "Görev bulunamadı",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export const dict = {
|
||||||
"nav.home": "首页",
|
"nav.home": "首页",
|
||||||
"nav.openMenu": "打开菜单",
|
"nav.openMenu": "打开菜单",
|
||||||
"nav.getStartedFree": "免费开始",
|
"nav.getStartedFree": "免费开始",
|
||||||
|
"nav.logoAlt": "OpenCode",
|
||||||
|
|
||||||
"nav.context.copyLogo": "复制 Logo (SVG)",
|
"nav.context.copyLogo": "复制 Logo (SVG)",
|
||||||
"nav.context.copyWordmark": "复制商标 (SVG)",
|
"nav.context.copyWordmark": "复制商标 (SVG)",
|
||||||
|
|
@ -42,9 +43,13 @@ export const dict = {
|
||||||
"notFound.docs": "文档",
|
"notFound.docs": "文档",
|
||||||
"notFound.github": "GitHub",
|
"notFound.github": "GitHub",
|
||||||
"notFound.discord": "Discord",
|
"notFound.discord": "Discord",
|
||||||
|
"notFound.logoLightAlt": "opencode logo 亮色",
|
||||||
|
"notFound.logoDarkAlt": "opencode logo 暗色",
|
||||||
|
|
||||||
"user.logout": "退出登录",
|
"user.logout": "退出登录",
|
||||||
|
|
||||||
|
"auth.callback.error.codeMissing": "未找到授权码。",
|
||||||
|
|
||||||
"workspace.select": "选择工作区",
|
"workspace.select": "选择工作区",
|
||||||
"workspace.createNew": "+ 新建工作区",
|
"workspace.createNew": "+ 新建工作区",
|
||||||
"workspace.modal.title": "新建工作区",
|
"workspace.modal.title": "新建工作区",
|
||||||
|
|
@ -76,6 +81,8 @@ export const dict = {
|
||||||
"error.reloadAmountMin": "充值金额必须至少为 ${{amount}}",
|
"error.reloadAmountMin": "充值金额必须至少为 ${{amount}}",
|
||||||
"error.reloadTriggerMin": "余额触发阈值必须至少为 ${{amount}}",
|
"error.reloadTriggerMin": "余额触发阈值必须至少为 ${{amount}}",
|
||||||
|
|
||||||
|
"app.meta.description": "OpenCode - 开源编程代理。",
|
||||||
|
|
||||||
"home.title": "OpenCode | 开源 AI 编程代理",
|
"home.title": "OpenCode | 开源 AI 编程代理",
|
||||||
|
|
||||||
"temp.title": "OpenCode | 专为终端打造的 AI 编程代理",
|
"temp.title": "OpenCode | 专为终端打造的 AI 编程代理",
|
||||||
|
|
@ -91,6 +98,8 @@ export const dict = {
|
||||||
"temp.feature.models.afterLink": ",包括本地模型",
|
"temp.feature.models.afterLink": ",包括本地模型",
|
||||||
"temp.screenshot.caption": "使用 Tokyonight 主题的 OpenCode TUI",
|
"temp.screenshot.caption": "使用 Tokyonight 主题的 OpenCode TUI",
|
||||||
"temp.screenshot.alt": "使用 Tokyonight 主题的 OpenCode TUI",
|
"temp.screenshot.alt": "使用 Tokyonight 主题的 OpenCode TUI",
|
||||||
|
"temp.logoLightAlt": "opencode logo 亮色",
|
||||||
|
"temp.logoDarkAlt": "opencode logo 暗色",
|
||||||
|
|
||||||
"home.banner.badge": "新",
|
"home.banner.badge": "新",
|
||||||
"home.banner.text": "桌面应用 Beta 版现已推出",
|
"home.banner.text": "桌面应用 Beta 版现已推出",
|
||||||
|
|
@ -229,6 +238,22 @@ export const dict = {
|
||||||
"zen.privacy.beforeExceptions": "所有 Zen 模型均托管在美国。提供商遵循零留存政策,不使用您的数据进行模型训练,",
|
"zen.privacy.beforeExceptions": "所有 Zen 模型均托管在美国。提供商遵循零留存政策,不使用您的数据进行模型训练,",
|
||||||
"zen.privacy.exceptionsLink": "以下例外情况除外",
|
"zen.privacy.exceptionsLink": "以下例外情况除外",
|
||||||
|
|
||||||
|
"zen.api.error.rateLimitExceeded": "超出速率限制。请稍后重试。",
|
||||||
|
"zen.api.error.modelNotSupported": "不支持模型 {{model}}",
|
||||||
|
"zen.api.error.modelFormatNotSupported": "格式 {{format}} 不支持模型 {{model}}",
|
||||||
|
"zen.api.error.noProviderAvailable": "没有可用的提供商",
|
||||||
|
"zen.api.error.providerNotSupported": "不支持提供商 {{provider}}",
|
||||||
|
"zen.api.error.missingApiKey": "缺少 API 密钥。",
|
||||||
|
"zen.api.error.invalidApiKey": "无效的 API 密钥。",
|
||||||
|
"zen.api.error.subscriptionQuotaExceeded": "超出订阅配额。请在 {{retryIn}} 后重试。",
|
||||||
|
"zen.api.error.subscriptionQuotaExceededUseFreeModels": "超出订阅配额。您可以继续使用免费模型。",
|
||||||
|
"zen.api.error.noPaymentMethod": "没有付款方式。请在此处添加付款方式:{{billingUrl}}",
|
||||||
|
"zen.api.error.insufficientBalance": "余额不足。请在此处管理您的计费:{{billingUrl}}",
|
||||||
|
"zen.api.error.workspaceMonthlyLimitReached":
|
||||||
|
"您的工作区已达到每月支出限额 ${{amount}}。请在此处管理您的限额:{{billingUrl}}",
|
||||||
|
"zen.api.error.userMonthlyLimitReached": "您已达到每月支出限额 ${{amount}}。请在此处管理您的限额:{{membersUrl}}",
|
||||||
|
"zen.api.error.modelDisabled": "模型已禁用",
|
||||||
|
|
||||||
"black.meta.title": "OpenCode Black | 访问全球顶尖编程模型",
|
"black.meta.title": "OpenCode Black | 访问全球顶尖编程模型",
|
||||||
"black.meta.description": "通过 OpenCode Black 订阅计划使用 Claude, GPT, Gemini 等模型。",
|
"black.meta.description": "通过 OpenCode Black 订阅计划使用 Claude, GPT, Gemini 等模型。",
|
||||||
"black.hero.title": "访问全球顶尖编程模型",
|
"black.hero.title": "访问全球顶尖编程模型",
|
||||||
|
|
@ -436,6 +461,7 @@ export const dict = {
|
||||||
"workspace.reload.updatePaymentMethod": "请更新您的付款方式并重试。",
|
"workspace.reload.updatePaymentMethod": "请更新您的付款方式并重试。",
|
||||||
"workspace.reload.retrying": "正在重试...",
|
"workspace.reload.retrying": "正在重试...",
|
||||||
"workspace.reload.retry": "重试",
|
"workspace.reload.retry": "重试",
|
||||||
|
"workspace.reload.error.paymentFailed": "支付失败。",
|
||||||
|
|
||||||
"workspace.payments.title": "支付历史",
|
"workspace.payments.title": "支付历史",
|
||||||
"workspace.payments.subtitle": "近期支付交易。",
|
"workspace.payments.subtitle": "近期支付交易。",
|
||||||
|
|
@ -552,6 +578,10 @@ export const dict = {
|
||||||
"enterprise.form.send": "发送",
|
"enterprise.form.send": "发送",
|
||||||
"enterprise.form.sending": "正在发送...",
|
"enterprise.form.sending": "正在发送...",
|
||||||
"enterprise.form.success": "消息已发送,我们会尽快与您联系。",
|
"enterprise.form.success": "消息已发送,我们会尽快与您联系。",
|
||||||
|
"enterprise.form.success.submitted": "表单提交成功。",
|
||||||
|
"enterprise.form.error.allFieldsRequired": "所有字段均为必填项。",
|
||||||
|
"enterprise.form.error.invalidEmailFormat": "邮箱格式无效。",
|
||||||
|
"enterprise.form.error.internalServer": "内部服务器错误。",
|
||||||
"enterprise.faq.title": "常见问题",
|
"enterprise.faq.title": "常见问题",
|
||||||
"enterprise.faq.q1": "什么是 OpenCode 企业版?",
|
"enterprise.faq.q1": "什么是 OpenCode 企业版?",
|
||||||
"enterprise.faq.a1":
|
"enterprise.faq.a1":
|
||||||
|
|
@ -584,6 +614,7 @@ export const dict = {
|
||||||
"bench.list.table.agent": "代理",
|
"bench.list.table.agent": "代理",
|
||||||
"bench.list.table.model": "模型",
|
"bench.list.table.model": "模型",
|
||||||
"bench.list.table.score": "分数",
|
"bench.list.table.score": "分数",
|
||||||
|
"bench.submission.error.allFieldsRequired": "所有字段均为必填项。",
|
||||||
|
|
||||||
"bench.detail.title": "基准测试 - {{task}}",
|
"bench.detail.title": "基准测试 - {{task}}",
|
||||||
"bench.detail.notFound": "未找到任务",
|
"bench.detail.notFound": "未找到任务",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export const dict = {
|
||||||
"nav.home": "首頁",
|
"nav.home": "首頁",
|
||||||
"nav.openMenu": "開啟選單",
|
"nav.openMenu": "開啟選單",
|
||||||
"nav.getStartedFree": "免費開始使用",
|
"nav.getStartedFree": "免費開始使用",
|
||||||
|
"nav.logoAlt": "OpenCode",
|
||||||
|
|
||||||
"nav.context.copyLogo": "複製標誌(SVG)",
|
"nav.context.copyLogo": "複製標誌(SVG)",
|
||||||
"nav.context.copyWordmark": "複製字標(SVG)",
|
"nav.context.copyWordmark": "複製字標(SVG)",
|
||||||
|
|
@ -42,9 +43,13 @@ export const dict = {
|
||||||
"notFound.docs": "文件",
|
"notFound.docs": "文件",
|
||||||
"notFound.github": "GitHub",
|
"notFound.github": "GitHub",
|
||||||
"notFound.discord": "Discord",
|
"notFound.discord": "Discord",
|
||||||
|
"notFound.logoLightAlt": "opencode 淺色標誌",
|
||||||
|
"notFound.logoDarkAlt": "opencode 深色標誌",
|
||||||
|
|
||||||
"user.logout": "登出",
|
"user.logout": "登出",
|
||||||
|
|
||||||
|
"auth.callback.error.codeMissing": "找不到授權碼。",
|
||||||
|
|
||||||
"workspace.select": "選取工作區",
|
"workspace.select": "選取工作區",
|
||||||
"workspace.createNew": "+ 建立新工作區",
|
"workspace.createNew": "+ 建立新工作區",
|
||||||
"workspace.modal.title": "建立新工作區",
|
"workspace.modal.title": "建立新工作區",
|
||||||
|
|
@ -76,6 +81,8 @@ export const dict = {
|
||||||
"error.reloadAmountMin": "儲值金額必須至少為 ${{amount}}",
|
"error.reloadAmountMin": "儲值金額必須至少為 ${{amount}}",
|
||||||
"error.reloadTriggerMin": "餘額觸發門檻必須至少為 ${{amount}}",
|
"error.reloadTriggerMin": "餘額觸發門檻必須至少為 ${{amount}}",
|
||||||
|
|
||||||
|
"app.meta.description": "OpenCode - 開源編碼代理。",
|
||||||
|
|
||||||
"home.title": "OpenCode | 開源 AI 編碼代理",
|
"home.title": "OpenCode | 開源 AI 編碼代理",
|
||||||
|
|
||||||
"temp.title": "OpenCode | 專為終端打造的 AI 編碼代理",
|
"temp.title": "OpenCode | 專為終端打造的 AI 編碼代理",
|
||||||
|
|
@ -91,6 +98,8 @@ export const dict = {
|
||||||
"temp.feature.models.afterLink": "支援 75+ 家 LLM 供應商,包括本地模型",
|
"temp.feature.models.afterLink": "支援 75+ 家 LLM 供應商,包括本地模型",
|
||||||
"temp.screenshot.caption": "使用 tokyonight 主題的 OpenCode TUI",
|
"temp.screenshot.caption": "使用 tokyonight 主題的 OpenCode TUI",
|
||||||
"temp.screenshot.alt": "使用 tokyonight 主題的 OpenCode TUI",
|
"temp.screenshot.alt": "使用 tokyonight 主題的 OpenCode TUI",
|
||||||
|
"temp.logoLightAlt": "opencode 淺色標誌",
|
||||||
|
"temp.logoDarkAlt": "opencode 深色標誌",
|
||||||
|
|
||||||
"home.banner.badge": "新",
|
"home.banner.badge": "新",
|
||||||
"home.banner.text": "桌面應用已推出 Beta",
|
"home.banner.text": "桌面應用已推出 Beta",
|
||||||
|
|
@ -229,6 +238,22 @@ export const dict = {
|
||||||
"zen.privacy.beforeExceptions": "所有 Zen 模型均在美國託管。供應商遵循零留存政策,不會將你的資料用於模型訓練,並且有",
|
"zen.privacy.beforeExceptions": "所有 Zen 模型均在美國託管。供應商遵循零留存政策,不會將你的資料用於模型訓練,並且有",
|
||||||
"zen.privacy.exceptionsLink": "以下例外情況",
|
"zen.privacy.exceptionsLink": "以下例外情況",
|
||||||
|
|
||||||
|
"zen.api.error.rateLimitExceeded": "超出頻率限制。請稍後再試。",
|
||||||
|
"zen.api.error.modelNotSupported": "不支援模型 {{model}}",
|
||||||
|
"zen.api.error.modelFormatNotSupported": "模型 {{model}} 不支援格式 {{format}}",
|
||||||
|
"zen.api.error.noProviderAvailable": "無可用的供應商",
|
||||||
|
"zen.api.error.providerNotSupported": "不支援供應商 {{provider}}",
|
||||||
|
"zen.api.error.missingApiKey": "缺少 API 金鑰。",
|
||||||
|
"zen.api.error.invalidApiKey": "無效的 API 金鑰。",
|
||||||
|
"zen.api.error.subscriptionQuotaExceeded": "超出訂閱配額。請在 {{retryIn}} 後重試。",
|
||||||
|
"zen.api.error.subscriptionQuotaExceededUseFreeModels": "超出訂閱配額。你可以繼續使用免費模型。",
|
||||||
|
"zen.api.error.noPaymentMethod": "無付款方式。請在此處新增付款方式:{{billingUrl}}",
|
||||||
|
"zen.api.error.insufficientBalance": "餘額不足。請在此處管理你的帳務:{{billingUrl}}",
|
||||||
|
"zen.api.error.workspaceMonthlyLimitReached":
|
||||||
|
"你的工作區已達到每月支出限額 ${{amount}}。請在此處管理你的限額:{{billingUrl}}",
|
||||||
|
"zen.api.error.userMonthlyLimitReached": "你已達到每月支出限額 ${{amount}}。請在此處管理你的限額:{{membersUrl}}",
|
||||||
|
"zen.api.error.modelDisabled": "模型已停用",
|
||||||
|
|
||||||
"black.meta.title": "OpenCode Black | 存取全球最佳編碼模型",
|
"black.meta.title": "OpenCode Black | 存取全球最佳編碼模型",
|
||||||
"black.meta.description": "透過 OpenCode Black 訂閱方案存取 Claude、GPT、Gemini 等模型。",
|
"black.meta.description": "透過 OpenCode Black 訂閱方案存取 Claude、GPT、Gemini 等模型。",
|
||||||
"black.hero.title": "存取全球最佳編碼模型",
|
"black.hero.title": "存取全球最佳編碼模型",
|
||||||
|
|
@ -436,6 +461,7 @@ export const dict = {
|
||||||
"workspace.reload.updatePaymentMethod": "請更新你的付款方式並重試。",
|
"workspace.reload.updatePaymentMethod": "請更新你的付款方式並重試。",
|
||||||
"workspace.reload.retrying": "重試中...",
|
"workspace.reload.retrying": "重試中...",
|
||||||
"workspace.reload.retry": "重試",
|
"workspace.reload.retry": "重試",
|
||||||
|
"workspace.reload.error.paymentFailed": "付款失敗。",
|
||||||
|
|
||||||
"workspace.payments.title": "付款紀錄",
|
"workspace.payments.title": "付款紀錄",
|
||||||
"workspace.payments.subtitle": "最近的付款交易。",
|
"workspace.payments.subtitle": "最近的付款交易。",
|
||||||
|
|
@ -551,6 +577,10 @@ export const dict = {
|
||||||
"enterprise.form.send": "傳送",
|
"enterprise.form.send": "傳送",
|
||||||
"enterprise.form.sending": "傳送中...",
|
"enterprise.form.sending": "傳送中...",
|
||||||
"enterprise.form.success": "訊息已傳送,我們會盡快與你聯絡。",
|
"enterprise.form.success": "訊息已傳送,我們會盡快與你聯絡。",
|
||||||
|
"enterprise.form.success.submitted": "表單已成功送出。",
|
||||||
|
"enterprise.form.error.allFieldsRequired": "所有欄位均為必填。",
|
||||||
|
"enterprise.form.error.invalidEmailFormat": "無效的電子郵件格式。",
|
||||||
|
"enterprise.form.error.internalServer": "內部伺服器錯誤。",
|
||||||
"enterprise.faq.title": "常見問題",
|
"enterprise.faq.title": "常見問題",
|
||||||
"enterprise.faq.q1": "什麼是 OpenCode Enterprise?",
|
"enterprise.faq.q1": "什麼是 OpenCode Enterprise?",
|
||||||
"enterprise.faq.a1":
|
"enterprise.faq.a1":
|
||||||
|
|
@ -583,6 +613,7 @@ export const dict = {
|
||||||
"bench.list.table.agent": "代理",
|
"bench.list.table.agent": "代理",
|
||||||
"bench.list.table.model": "模型",
|
"bench.list.table.model": "模型",
|
||||||
"bench.list.table.score": "分數",
|
"bench.list.table.score": "分數",
|
||||||
|
"bench.submission.error.allFieldsRequired": "所有欄位均為必填。",
|
||||||
|
|
||||||
"bench.detail.title": "評測 - {{task}}",
|
"bench.detail.title": "評測 - {{task}}",
|
||||||
"bench.detail.notFound": "找不到任務",
|
"bench.detail.notFound": "找不到任務",
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,9 @@ const map = {
|
||||||
"Provider is required": "error.providerRequired",
|
"Provider is required": "error.providerRequired",
|
||||||
"API key is required": "error.apiKeyRequired",
|
"API key is required": "error.apiKeyRequired",
|
||||||
"Model is required": "error.modelRequired",
|
"Model is required": "error.modelRequired",
|
||||||
|
"workspace.reload.error.paymentFailed": "workspace.reload.error.paymentFailed",
|
||||||
|
"Payment failed": "workspace.reload.error.paymentFailed",
|
||||||
|
"Payment failed.": "workspace.reload.error.paymentFailed",
|
||||||
} as const satisfies Record<string, Key>
|
} as const satisfies Record<string, Key>
|
||||||
|
|
||||||
export function formErrorReloadAmountMin(amount: number) {
|
export function formErrorReloadAmountMin(amount: number) {
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ export default function NotFound() {
|
||||||
<div data-component="content">
|
<div data-component="content">
|
||||||
<section data-component="top">
|
<section data-component="top">
|
||||||
<a href={language.route("/")} data-slot="logo-link">
|
<a href={language.route("/")} data-slot="logo-link">
|
||||||
<img data-slot="logo light" src={logoLight} alt="opencode logo light" />
|
<img data-slot="logo light" src={logoLight} alt={i18n.t("notFound.logoLightAlt")} />
|
||||||
<img data-slot="logo dark" src={logoDark} alt="opencode logo dark" />
|
<img data-slot="logo dark" src={logoDark} alt={i18n.t("notFound.logoDarkAlt")} />
|
||||||
</a>
|
</a>
|
||||||
<h1 data-slot="title">{i18n.t("notFound.heading")}</h1>
|
<h1 data-slot="title">{i18n.t("notFound.heading")}</h1>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import type { APIEvent } from "@solidjs/start/server"
|
import type { APIEvent } from "@solidjs/start/server"
|
||||||
import { AWS } from "@opencode-ai/console-core/aws.js"
|
import { AWS } from "@opencode-ai/console-core/aws.js"
|
||||||
|
import { i18n } from "~/i18n"
|
||||||
|
import { localeFromRequest } from "~/lib/language"
|
||||||
|
|
||||||
interface EnterpriseFormData {
|
interface EnterpriseFormData {
|
||||||
name: string
|
name: string
|
||||||
|
|
@ -9,18 +11,19 @@ interface EnterpriseFormData {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(event: APIEvent) {
|
export async function POST(event: APIEvent) {
|
||||||
|
const dict = i18n(localeFromRequest(event.request))
|
||||||
try {
|
try {
|
||||||
const body = (await event.request.json()) as EnterpriseFormData
|
const body = (await event.request.json()) as EnterpriseFormData
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if (!body.name || !body.role || !body.email || !body.message) {
|
if (!body.name || !body.role || !body.email || !body.message) {
|
||||||
return Response.json({ error: "All fields are required" }, { status: 400 })
|
return Response.json({ error: dict["enterprise.form.error.allFieldsRequired"] }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate email format
|
// Validate email format
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
if (!emailRegex.test(body.email)) {
|
if (!emailRegex.test(body.email)) {
|
||||||
return Response.json({ error: "Invalid email format" }, { status: 400 })
|
return Response.json({ error: dict["enterprise.form.error.invalidEmailFormat"] }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create email content
|
// Create email content
|
||||||
|
|
@ -39,9 +42,9 @@ ${body.email}`.trim()
|
||||||
replyTo: body.email,
|
replyTo: body.email,
|
||||||
})
|
})
|
||||||
|
|
||||||
return Response.json({ success: true, message: "Form submitted successfully" }, { status: 200 })
|
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error processing enterprise form:", error)
|
console.error("Error processing enterprise form:", error)
|
||||||
return Response.json({ error: "Internal server error" }, { status: 500 })
|
return Response.json({ error: dict["enterprise.form.error.internalServer"] }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,17 @@ import { redirect } from "@solidjs/router"
|
||||||
import type { APIEvent } from "@solidjs/start/server"
|
import type { APIEvent } from "@solidjs/start/server"
|
||||||
import { AuthClient } from "~/context/auth"
|
import { AuthClient } from "~/context/auth"
|
||||||
import { useAuthSession } from "~/context/auth"
|
import { useAuthSession } from "~/context/auth"
|
||||||
|
import { i18n } from "~/i18n"
|
||||||
import { localeFromRequest, route } from "~/lib/language"
|
import { localeFromRequest, route } from "~/lib/language"
|
||||||
|
|
||||||
export async function GET(input: APIEvent) {
|
export async function GET(input: APIEvent) {
|
||||||
const url = new URL(input.request.url)
|
const url = new URL(input.request.url)
|
||||||
const locale = localeFromRequest(input.request)
|
const locale = localeFromRequest(input.request)
|
||||||
|
const dict = i18n(locale)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const code = url.searchParams.get("code")
|
const code = url.searchParams.get("code")
|
||||||
if (!code) throw new Error("No code found")
|
if (!code) throw new Error(dict["auth.callback.error.codeMissing"])
|
||||||
const result = await AuthClient.exchange(code, `${url.origin}${url.pathname}`)
|
const result = await AuthClient.exchange(code, `${url.origin}${url.pathname}`)
|
||||||
if (result.err) throw new Error(result.err.message)
|
if (result.err) throw new Error(result.err.message)
|
||||||
const decoded = AuthClient.decode(result.tokens.access, {} as any)
|
const decoded = AuthClient.decode(result.tokens.access, {} as any)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import type { APIEvent } from "@solidjs/start/server"
|
||||||
import { Database } from "@opencode-ai/console-core/drizzle/index.js"
|
import { Database } from "@opencode-ai/console-core/drizzle/index.js"
|
||||||
import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js"
|
import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js"
|
||||||
import { Identifier } from "@opencode-ai/console-core/identifier.js"
|
import { Identifier } from "@opencode-ai/console-core/identifier.js"
|
||||||
|
import { i18n } from "~/i18n"
|
||||||
|
import { localeFromRequest } from "~/lib/language"
|
||||||
|
|
||||||
interface SubmissionBody {
|
interface SubmissionBody {
|
||||||
model: string
|
model: string
|
||||||
|
|
@ -10,10 +12,11 @@ interface SubmissionBody {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(event: APIEvent) {
|
export async function POST(event: APIEvent) {
|
||||||
|
const dict = i18n(localeFromRequest(event.request))
|
||||||
const body = (await event.request.json()) as SubmissionBody
|
const body = (await event.request.json()) as SubmissionBody
|
||||||
|
|
||||||
if (!body.model || !body.agent || !body.result) {
|
if (!body.model || !body.agent || !body.result) {
|
||||||
return Response.json({ error: "All fields are required" }, { status: 400 })
|
return Response.json({ error: dict["bench.submission.error.allFieldsRequired"] }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
await Database.use((tx) =>
|
await Database.use((tx) =>
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ const brandAssets = "/opencode-brand-assets.zip"
|
||||||
|
|
||||||
export default function Brand() {
|
export default function Brand() {
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
|
const alt = i18n.t("brand.meta.description")
|
||||||
const downloadFile = async (url: string, filename: string) => {
|
const downloadFile = async (url: string, filename: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url)
|
const response = await fetch(url)
|
||||||
|
|
@ -88,7 +89,7 @@ export default function Brand() {
|
||||||
|
|
||||||
<div data-component="brand-grid">
|
<div data-component="brand-grid">
|
||||||
<div>
|
<div>
|
||||||
<img src={previewLogoLight} alt="OpenCode brand guidelines" />
|
<img src={previewLogoLight} alt={alt} />
|
||||||
<div data-component="actions">
|
<div data-component="actions">
|
||||||
<button onClick={() => downloadFile(logoLightPng, "opencode-logo-light.png")}>
|
<button onClick={() => downloadFile(logoLightPng, "opencode-logo-light.png")}>
|
||||||
PNG
|
PNG
|
||||||
|
|
@ -115,7 +116,7 @@ export default function Brand() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<img src={previewLogoDark} alt="OpenCode brand guidelines" />
|
<img src={previewLogoDark} alt={alt} />
|
||||||
<div data-component="actions">
|
<div data-component="actions">
|
||||||
<button onClick={() => downloadFile(logoDarkPng, "opencode-logo-dark.png")}>
|
<button onClick={() => downloadFile(logoDarkPng, "opencode-logo-dark.png")}>
|
||||||
PNG
|
PNG
|
||||||
|
|
@ -142,7 +143,7 @@ export default function Brand() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<img src={previewLogoLightSquare} alt="OpenCode brand guidelines" />
|
<img src={previewLogoLightSquare} alt={alt} />
|
||||||
<div data-component="actions">
|
<div data-component="actions">
|
||||||
<button onClick={() => downloadFile(logoLightSquarePng, "opencode-logo-light-square.png")}>
|
<button onClick={() => downloadFile(logoLightSquarePng, "opencode-logo-light-square.png")}>
|
||||||
PNG
|
PNG
|
||||||
|
|
@ -169,7 +170,7 @@ export default function Brand() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<img src={previewLogoDarkSquare} alt="OpenCode brand guidelines" />
|
<img src={previewLogoDarkSquare} alt={alt} />
|
||||||
<div data-component="actions">
|
<div data-component="actions">
|
||||||
<button onClick={() => downloadFile(logoDarkSquarePng, "opencode-logo-dark-square.png")}>
|
<button onClick={() => downloadFile(logoDarkSquarePng, "opencode-logo-dark-square.png")}>
|
||||||
PNG
|
PNG
|
||||||
|
|
@ -196,7 +197,7 @@ export default function Brand() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<img src={previewWordmarkLight} alt="OpenCode brand guidelines" />
|
<img src={previewWordmarkLight} alt={alt} />
|
||||||
<div data-component="actions">
|
<div data-component="actions">
|
||||||
<button onClick={() => downloadFile(wordmarkLightPng, "opencode-wordmark-light.png")}>
|
<button onClick={() => downloadFile(wordmarkLightPng, "opencode-wordmark-light.png")}>
|
||||||
PNG
|
PNG
|
||||||
|
|
@ -223,7 +224,7 @@ export default function Brand() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<img src={previewWordmarkDark} alt="OpenCode brand guidelines" />
|
<img src={previewWordmarkDark} alt={alt} />
|
||||||
<div data-component="actions">
|
<div data-component="actions">
|
||||||
<button onClick={() => downloadFile(wordmarkDarkPng, "opencode-wordmark-dark.png")}>
|
<button onClick={() => downloadFile(wordmarkDarkPng, "opencode-wordmark-dark.png")}>
|
||||||
PNG
|
PNG
|
||||||
|
|
@ -250,7 +251,7 @@ export default function Brand() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<img src={previewWordmarkSimpleLight} alt="OpenCode brand guidelines" />
|
<img src={previewWordmarkSimpleLight} alt={alt} />
|
||||||
<div data-component="actions">
|
<div data-component="actions">
|
||||||
<button onClick={() => downloadFile(wordmarkSimpleLightPng, "opencode-wordmark-simple-light.png")}>
|
<button onClick={() => downloadFile(wordmarkSimpleLightPng, "opencode-wordmark-simple-light.png")}>
|
||||||
PNG
|
PNG
|
||||||
|
|
@ -277,7 +278,7 @@ export default function Brand() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<img src={previewWordmarkSimpleDark} alt="OpenCode brand guidelines" />
|
<img src={previewWordmarkSimpleDark} alt={alt} />
|
||||||
<div data-component="actions">
|
<div data-component="actions">
|
||||||
<button onClick={() => downloadFile(wordmarkSimpleDarkPng, "opencode-wordmark-simple-dark.png")}>
|
<button onClick={() => downloadFile(wordmarkSimpleDarkPng, "opencode-wordmark-simple-dark.png")}>
|
||||||
PNG
|
PNG
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ const downloadNames: Record<string, string> = {
|
||||||
|
|
||||||
export async function GET({ params: { platform, channel } }: APIEvent) {
|
export async function GET({ params: { platform, channel } }: APIEvent) {
|
||||||
const assetName = assetNames[platform]
|
const assetName = assetNames[platform]
|
||||||
if (!assetName) return new Response("Not Found", { status: 404 })
|
if (!assetName) return new Response(null, { status: 404 })
|
||||||
|
|
||||||
const resp = await fetch(
|
const resp = await fetch(
|
||||||
`https://github.com/anomalyco/${channel === "stable" ? "opencode" : "opencode-beta"}/releases/latest/download/${assetName}`,
|
`https://github.com/anomalyco/${channel === "stable" ? "opencode" : "opencode-beta"}/releases/latest/download/${assetName}`,
|
||||||
|
|
|
||||||
|
|
@ -306,7 +306,7 @@ export async function POST(input: APIEvent) {
|
||||||
.update(BillingTable)
|
.update(BillingTable)
|
||||||
.set({
|
.set({
|
||||||
reload: false,
|
reload: false,
|
||||||
reloadError: errorMessage ?? "Payment failed.",
|
reloadError: errorMessage ?? "workspace.reload.error.paymentFailed",
|
||||||
timeReloadError: sql`now()`,
|
timeReloadError: sql`now()`,
|
||||||
})
|
})
|
||||||
.where(eq(BillingTable.workspaceID, Actor.workspace())),
|
.where(eq(BillingTable.workspaceID, Actor.workspace())),
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,8 @@ export default function Home() {
|
||||||
|
|
||||||
<div data-component="content">
|
<div data-component="content">
|
||||||
<section data-component="top">
|
<section data-component="top">
|
||||||
<img data-slot="logo light" src={logoLight} alt="opencode logo light" />
|
<img data-slot="logo light" src={logoLight} alt={i18n.t("temp.logoLightAlt")} />
|
||||||
<img data-slot="logo dark" src={logoDark} alt="opencode logo dark" />
|
<img data-slot="logo dark" src={logoDark} alt={i18n.t("temp.logoDarkAlt")} />
|
||||||
<h1 data-slot="title">{i18n.t("temp.hero.title")}</h1>
|
<h1 data-slot="title">{i18n.t("temp.hero.title")}</h1>
|
||||||
<div data-slot="login">
|
<div data-slot="login">
|
||||||
<a href="/auth">{i18n.t("temp.zen")}</a>
|
<a href="/auth">{i18n.t("temp.zen")}</a>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { queryBillingInfo } from "../../common"
|
||||||
import styles from "./lite-section.module.css"
|
import styles from "./lite-section.module.css"
|
||||||
import { useI18n } from "~/context/i18n"
|
import { useI18n } from "~/context/i18n"
|
||||||
import { useLanguage } from "~/context/language"
|
import { useLanguage } from "~/context/language"
|
||||||
|
import { formError } from "~/lib/form-error"
|
||||||
|
|
||||||
const queryLiteSubscription = query(async (workspaceID: string) => {
|
const queryLiteSubscription = query(async (workspaceID: string) => {
|
||||||
"use server"
|
"use server"
|
||||||
|
|
@ -114,7 +115,7 @@ const createSessionUrl = action(async (workspaceID: string, returnUrl: string) =
|
||||||
const setLiteUseBalance = action(async (form: FormData) => {
|
const setLiteUseBalance = action(async (form: FormData) => {
|
||||||
"use server"
|
"use server"
|
||||||
const workspaceID = form.get("workspaceID")?.toString()
|
const workspaceID = form.get("workspaceID")?.toString()
|
||||||
if (!workspaceID) return { error: "Workspace ID is required" }
|
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||||
const useBalance = form.get("useBalance")?.toString() === "true"
|
const useBalance = form.get("useBalance")?.toString() === "true"
|
||||||
|
|
||||||
return json(
|
return json(
|
||||||
|
|
|
||||||
|
|
@ -202,7 +202,8 @@ export function ReloadSection() {
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
second: "2-digit",
|
second: "2-digit",
|
||||||
})}
|
})}
|
||||||
. {i18n.t("workspace.reload.reason")} {billingInfo()?.reloadError?.replace(/\.$/, "")}.{" "}
|
. {i18n.t("workspace.reload.reason")}{" "}
|
||||||
|
{localizeError(i18n.t, billingInfo()?.reloadError ?? undefined).replace(/\.$/, "")}.{" "}
|
||||||
{i18n.t("workspace.reload.updatePaymentMethod")}
|
{i18n.t("workspace.reload.updatePaymentMethod")}
|
||||||
</p>
|
</p>
|
||||||
<form action={reload} method="post" data-slot="create-form">
|
<form action={reload} method="post" data-slot="create-form">
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ import { createTrialLimiter } from "./trialLimiter"
|
||||||
import { createStickyTracker } from "./stickyProviderTracker"
|
import { createStickyTracker } from "./stickyProviderTracker"
|
||||||
import { LiteData } from "@opencode-ai/console-core/lite.js"
|
import { LiteData } from "@opencode-ai/console-core/lite.js"
|
||||||
import { Resource } from "@opencode-ai/console-resource"
|
import { Resource } from "@opencode-ai/console-resource"
|
||||||
|
import { i18n, type Key } from "~/i18n"
|
||||||
|
import { localeFromRequest } from "~/lib/language"
|
||||||
|
|
||||||
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
|
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
|
||||||
type RetryOptions = {
|
type RetryOptions = {
|
||||||
|
|
@ -43,6 +45,15 @@ type RetryOptions = {
|
||||||
}
|
}
|
||||||
type BillingSource = "anonymous" | "free" | "byok" | "subscription" | "lite" | "balance"
|
type BillingSource = "anonymous" | "free" | "byok" | "subscription" | "lite" | "balance"
|
||||||
|
|
||||||
|
function resolve(text: string, params?: Record<string, string | number>) {
|
||||||
|
if (!params) return text
|
||||||
|
return text.replace(/\{\{(\w+)\}\}/g, (raw, key) => {
|
||||||
|
const value = params[key]
|
||||||
|
if (value === undefined || value === null) return raw
|
||||||
|
return String(value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function handler(
|
export async function handler(
|
||||||
input: APIEvent,
|
input: APIEvent,
|
||||||
opts: {
|
opts: {
|
||||||
|
|
@ -60,6 +71,8 @@ export async function handler(
|
||||||
|
|
||||||
const MAX_FAILOVER_RETRIES = 3
|
const MAX_FAILOVER_RETRIES = 3
|
||||||
const MAX_429_RETRIES = 3
|
const MAX_429_RETRIES = 3
|
||||||
|
const dict = i18n(localeFromRequest(input.request))
|
||||||
|
const t = (key: Key, params?: Record<string, string | number>) => resolve(dict[key], params)
|
||||||
const ADMIN_WORKSPACES = [
|
const ADMIN_WORKSPACES = [
|
||||||
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
|
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
|
||||||
"wrk_01K6W1A3VE0KMNVSCQT43BG2SX", // opencode bench
|
"wrk_01K6W1A3VE0KMNVSCQT43BG2SX", // opencode bench
|
||||||
|
|
@ -84,9 +97,9 @@ export async function handler(
|
||||||
const zenData = ZenData.list(opts.modelList)
|
const zenData = ZenData.list(opts.modelList)
|
||||||
const modelInfo = validateModel(zenData, model)
|
const modelInfo = validateModel(zenData, model)
|
||||||
const dataDumper = createDataDumper(sessionId, requestId, projectId)
|
const dataDumper = createDataDumper(sessionId, requestId, projectId)
|
||||||
const trialLimiter = createTrialLimiter(modelInfo.trial, ip, ocClient)
|
const trialLimiter = createTrialLimiter(modelInfo.trialProvider, ip)
|
||||||
const isTrial = await trialLimiter?.isTrial()
|
const trialProvider = await trialLimiter?.check()
|
||||||
const rateLimiter = createRateLimiter(modelInfo.rateLimit, ip, input.request.headers)
|
const rateLimiter = createRateLimiter(modelInfo.allowAnonymous, ip, input.request)
|
||||||
await rateLimiter?.check()
|
await rateLimiter?.check()
|
||||||
const stickyTracker = createStickyTracker(modelInfo.stickyProvider, sessionId)
|
const stickyTracker = createStickyTracker(modelInfo.stickyProvider, sessionId)
|
||||||
const stickyProvider = await stickyTracker?.get()
|
const stickyProvider = await stickyTracker?.get()
|
||||||
|
|
@ -101,7 +114,7 @@ export async function handler(
|
||||||
authInfo,
|
authInfo,
|
||||||
modelInfo,
|
modelInfo,
|
||||||
sessionId,
|
sessionId,
|
||||||
isTrial ?? false,
|
trialProvider,
|
||||||
retry,
|
retry,
|
||||||
stickyProvider,
|
stickyProvider,
|
||||||
)
|
)
|
||||||
|
|
@ -131,9 +144,6 @@ export async function handler(
|
||||||
Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => {
|
Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => {
|
||||||
headers.set(k, headers.get(v)!)
|
headers.set(k, headers.get(v)!)
|
||||||
})
|
})
|
||||||
Object.entries(providerInfo.headers ?? {}).forEach(([k, v]) => {
|
|
||||||
headers.set(k, v)
|
|
||||||
})
|
|
||||||
headers.delete("host")
|
headers.delete("host")
|
||||||
headers.delete("content-length")
|
headers.delete("content-length")
|
||||||
headers.delete("x-opencode-request")
|
headers.delete("x-opencode-request")
|
||||||
|
|
@ -282,18 +292,13 @@ export async function handler(
|
||||||
part = part.trim()
|
part = part.trim()
|
||||||
usageParser.parse(part)
|
usageParser.parse(part)
|
||||||
|
|
||||||
if (providerInfo.responseModifier) {
|
if (providerInfo.format !== opts.format) {
|
||||||
for (const [k, v] of Object.entries(providerInfo.responseModifier)) {
|
|
||||||
part = part.replace(k, v)
|
|
||||||
}
|
|
||||||
c.enqueue(encoder.encode(part + "\n\n"))
|
|
||||||
} else if (providerInfo.format !== opts.format) {
|
|
||||||
part = streamConverter(part)
|
part = streamConverter(part)
|
||||||
c.enqueue(encoder.encode(part + "\n\n"))
|
c.enqueue(encoder.encode(part + "\n\n"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!providerInfo.responseModifier && providerInfo.format === opts.format) {
|
if (providerInfo.format === opts.format) {
|
||||||
c.enqueue(value)
|
c.enqueue(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -359,14 +364,20 @@ export async function handler(
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateModel(zenData: ZenData, reqModel: string) {
|
function validateModel(zenData: ZenData, reqModel: string) {
|
||||||
if (!(reqModel in zenData.models)) throw new ModelError(`Model ${reqModel} not supported`)
|
if (!(reqModel in zenData.models)) throw new ModelError(t("zen.api.error.modelNotSupported", { model: reqModel }))
|
||||||
|
|
||||||
const modelId = reqModel as keyof typeof zenData.models
|
const modelId = reqModel as keyof typeof zenData.models
|
||||||
const modelData = Array.isArray(zenData.models[modelId])
|
const modelData = Array.isArray(zenData.models[modelId])
|
||||||
? zenData.models[modelId].find((model) => opts.format === model.formatFilter)
|
? zenData.models[modelId].find((model) => opts.format === model.formatFilter)
|
||||||
: zenData.models[modelId]
|
: zenData.models[modelId]
|
||||||
|
|
||||||
if (!modelData) throw new ModelError(`Model ${reqModel} not supported for format ${opts.format}`)
|
if (!modelData)
|
||||||
|
throw new ModelError(
|
||||||
|
t("zen.api.error.modelFormatNotSupported", {
|
||||||
|
model: reqModel,
|
||||||
|
format: opts.format,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
logger.metric({ model: modelId })
|
logger.metric({ model: modelId })
|
||||||
|
|
||||||
|
|
@ -379,7 +390,7 @@ export async function handler(
|
||||||
authInfo: AuthInfo,
|
authInfo: AuthInfo,
|
||||||
modelInfo: ModelInfo,
|
modelInfo: ModelInfo,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
isTrial: boolean,
|
trialProvider: string | undefined,
|
||||||
retry: RetryOptions,
|
retry: RetryOptions,
|
||||||
stickyProvider: string | undefined,
|
stickyProvider: string | undefined,
|
||||||
) {
|
) {
|
||||||
|
|
@ -388,8 +399,8 @@ export async function handler(
|
||||||
return modelInfo.providers.find((provider) => provider.id === modelInfo.byokProvider)
|
return modelInfo.providers.find((provider) => provider.id === modelInfo.byokProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTrial) {
|
if (trialProvider) {
|
||||||
return modelInfo.providers.find((provider) => provider.id === modelInfo.trial!.provider)
|
return modelInfo.providers.find((provider) => provider.id === trialProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stickyProvider) {
|
if (stickyProvider) {
|
||||||
|
|
@ -418,8 +429,9 @@ export async function handler(
|
||||||
return modelInfo.providers.find((provider) => provider.id === modelInfo.fallbackProvider)
|
return modelInfo.providers.find((provider) => provider.id === modelInfo.fallbackProvider)
|
||||||
})()
|
})()
|
||||||
|
|
||||||
if (!modelProvider) throw new ModelError("No provider available")
|
if (!modelProvider) throw new ModelError(t("zen.api.error.noProviderAvailable"))
|
||||||
if (!(modelProvider.id in zenData.providers)) throw new ModelError(`Provider ${modelProvider.id} not supported`)
|
if (!(modelProvider.id in zenData.providers))
|
||||||
|
throw new ModelError(t("zen.api.error.providerNotSupported", { provider: modelProvider.id }))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...modelProvider,
|
...modelProvider,
|
||||||
|
|
@ -439,7 +451,7 @@ export async function handler(
|
||||||
const apiKey = opts.parseApiKey(input.request.headers)
|
const apiKey = opts.parseApiKey(input.request.headers)
|
||||||
if (!apiKey || apiKey === "public") {
|
if (!apiKey || apiKey === "public") {
|
||||||
if (modelInfo.allowAnonymous) return
|
if (modelInfo.allowAnonymous) return
|
||||||
throw new AuthError("Missing API key.")
|
throw new AuthError(t("zen.api.error.missingApiKey"))
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await Database.use((tx) =>
|
const data = await Database.use((tx) =>
|
||||||
|
|
@ -520,13 +532,13 @@ export async function handler(
|
||||||
.then((rows) => rows[0]),
|
.then((rows) => rows[0]),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!data) throw new AuthError("Invalid API key.")
|
if (!data) throw new AuthError(t("zen.api.error.invalidApiKey"))
|
||||||
if (
|
if (
|
||||||
modelInfo.id.startsWith("alpha-") &&
|
modelInfo.id.startsWith("alpha-") &&
|
||||||
Resource.App.stage === "production" &&
|
Resource.App.stage === "production" &&
|
||||||
!ADMIN_WORKSPACES.includes(data.workspaceID)
|
!ADMIN_WORKSPACES.includes(data.workspaceID)
|
||||||
)
|
)
|
||||||
throw new AuthError(`Model ${modelInfo.id} not supported`)
|
throw new AuthError(t("zen.api.error.modelNotSupported", { model: modelInfo.id }))
|
||||||
|
|
||||||
logger.metric({
|
logger.metric({
|
||||||
api_key: data.apiKey,
|
api_key: data.apiKey,
|
||||||
|
|
@ -590,7 +602,9 @@ export async function handler(
|
||||||
})
|
})
|
||||||
if (result.status === "rate-limited")
|
if (result.status === "rate-limited")
|
||||||
throw new SubscriptionUsageLimitError(
|
throw new SubscriptionUsageLimitError(
|
||||||
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
|
t("zen.api.error.subscriptionQuotaExceeded", {
|
||||||
|
retryIn: formatRetryTime(result.resetInSec),
|
||||||
|
}),
|
||||||
result.resetInSec,
|
result.resetInSec,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -606,7 +620,9 @@ export async function handler(
|
||||||
})
|
})
|
||||||
if (result.status === "rate-limited")
|
if (result.status === "rate-limited")
|
||||||
throw new SubscriptionUsageLimitError(
|
throw new SubscriptionUsageLimitError(
|
||||||
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
|
t("zen.api.error.subscriptionQuotaExceeded", {
|
||||||
|
retryIn: formatRetryTime(result.resetInSec),
|
||||||
|
}),
|
||||||
result.resetInSec,
|
result.resetInSec,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -632,7 +648,7 @@ export async function handler(
|
||||||
})
|
})
|
||||||
if (result.status === "rate-limited")
|
if (result.status === "rate-limited")
|
||||||
throw new SubscriptionUsageLimitError(
|
throw new SubscriptionUsageLimitError(
|
||||||
`Subscription quota exceeded. You can continue using free models.`,
|
t("zen.api.error.subscriptionQuotaExceededUseFreeModels"),
|
||||||
result.resetInSec,
|
result.resetInSec,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -647,7 +663,7 @@ export async function handler(
|
||||||
})
|
})
|
||||||
if (result.status === "rate-limited")
|
if (result.status === "rate-limited")
|
||||||
throw new SubscriptionUsageLimitError(
|
throw new SubscriptionUsageLimitError(
|
||||||
`Subscription quota exceeded. You can continue using free models.`,
|
t("zen.api.error.subscriptionQuotaExceededUseFreeModels"),
|
||||||
result.resetInSec,
|
result.resetInSec,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -662,7 +678,7 @@ export async function handler(
|
||||||
})
|
})
|
||||||
if (result.status === "rate-limited")
|
if (result.status === "rate-limited")
|
||||||
throw new SubscriptionUsageLimitError(
|
throw new SubscriptionUsageLimitError(
|
||||||
`Subscription quota exceeded. You can continue using free models.`,
|
t("zen.api.error.subscriptionQuotaExceededUseFreeModels"),
|
||||||
result.resetInSec,
|
result.resetInSec,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -675,14 +691,10 @@ export async function handler(
|
||||||
|
|
||||||
// Validate pay as you go billing
|
// Validate pay as you go billing
|
||||||
const billing = authInfo.billing
|
const billing = authInfo.billing
|
||||||
if (!billing.paymentMethodID)
|
const billingUrl = `https://opencode.ai/workspace/${authInfo.workspaceID}/billing`
|
||||||
throw new CreditsError(
|
const membersUrl = `https://opencode.ai/workspace/${authInfo.workspaceID}/members`
|
||||||
`No payment method. Add a payment method here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`,
|
if (!billing.paymentMethodID) throw new CreditsError(t("zen.api.error.noPaymentMethod", { billingUrl }))
|
||||||
)
|
if (billing.balance <= 0) throw new CreditsError(t("zen.api.error.insufficientBalance", { billingUrl }))
|
||||||
if (billing.balance <= 0)
|
|
||||||
throw new CreditsError(
|
|
||||||
`Insufficient balance. Manage your billing here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`,
|
|
||||||
)
|
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const currentYear = now.getUTCFullYear()
|
const currentYear = now.getUTCFullYear()
|
||||||
|
|
@ -696,7 +708,10 @@ export async function handler(
|
||||||
currentMonth === billing.timeMonthlyUsageUpdated.getUTCMonth()
|
currentMonth === billing.timeMonthlyUsageUpdated.getUTCMonth()
|
||||||
)
|
)
|
||||||
throw new MonthlyLimitError(
|
throw new MonthlyLimitError(
|
||||||
`Your workspace has reached its monthly spending limit of $${billing.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`,
|
t("zen.api.error.workspaceMonthlyLimitReached", {
|
||||||
|
amount: billing.monthlyLimit,
|
||||||
|
billingUrl,
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
@ -708,7 +723,10 @@ export async function handler(
|
||||||
currentMonth === authInfo.user.timeMonthlyUsageUpdated.getUTCMonth()
|
currentMonth === authInfo.user.timeMonthlyUsageUpdated.getUTCMonth()
|
||||||
)
|
)
|
||||||
throw new UserLimitError(
|
throw new UserLimitError(
|
||||||
`You have reached your monthly spending limit of $${authInfo.user.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/members`,
|
t("zen.api.error.userMonthlyLimitReached", {
|
||||||
|
amount: authInfo.user.monthlyLimit,
|
||||||
|
membersUrl,
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
return "balance"
|
return "balance"
|
||||||
|
|
@ -716,7 +734,7 @@ export async function handler(
|
||||||
|
|
||||||
function validateModelSettings(authInfo: AuthInfo) {
|
function validateModelSettings(authInfo: AuthInfo) {
|
||||||
if (!authInfo) return
|
if (!authInfo) return
|
||||||
if (authInfo.isDisabled) throw new ModelError("Model is disabled")
|
if (authInfo.isDisabled) throw new ModelError(t("zen.api.error.modelDisabled"))
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateProviderKey(authInfo: AuthInfo, providerInfo: ProviderInfo) {
|
function updateProviderKey(authInfo: AuthInfo, providerInfo: ProviderInfo) {
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
|
||||||
...(isBedrock
|
...(isBedrock
|
||||||
? {
|
? {
|
||||||
anthropic_version: "bedrock-2023-05-31",
|
anthropic_version: "bedrock-2023-05-31",
|
||||||
anthropic_beta: supports1m ? "context-1m-2025-08-07" : undefined,
|
anthropic_beta: supports1m ? ["context-1m-2025-08-07"] : undefined,
|
||||||
model: undefined,
|
model: undefined,
|
||||||
stream: undefined,
|
stream: undefined,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,26 +2,28 @@ import { Database, eq, and, sql, inArray } from "@opencode-ai/console-core/drizz
|
||||||
import { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
|
import { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
|
||||||
import { FreeUsageLimitError } from "./error"
|
import { FreeUsageLimitError } from "./error"
|
||||||
import { logger } from "./logger"
|
import { logger } from "./logger"
|
||||||
import { ZenData } from "@opencode-ai/console-core/model.js"
|
import { i18n } from "~/i18n"
|
||||||
|
import { localeFromRequest } from "~/lib/language"
|
||||||
|
import { Subscription } from "@opencode-ai/console-core/subscription.js"
|
||||||
|
|
||||||
export function createRateLimiter(limit: ZenData.RateLimit | undefined, rawIp: string, headers: Headers) {
|
export function createRateLimiter(allowAnonymous: boolean | undefined, rawIp: string, request: Request) {
|
||||||
if (!limit) return
|
if (!allowAnonymous) return
|
||||||
|
const dict = i18n(localeFromRequest(request))
|
||||||
|
|
||||||
const limitValue = limit.checkHeader && !headers.get(limit.checkHeader) ? limit.fallbackValue! : limit.value
|
const limits = Subscription.getFreeLimits()
|
||||||
|
const limitValue =
|
||||||
|
limits.checkHeader && !request.headers.get(limits.checkHeader) ? limits.fallbackValue : limits.dailyRequests
|
||||||
|
|
||||||
const ip = !rawIp.length ? "unknown" : rawIp
|
const ip = !rawIp.length ? "unknown" : rawIp
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const intervals =
|
const interval = buildYYYYMMDD(now)
|
||||||
limit.period === "day"
|
|
||||||
? [buildYYYYMMDD(now)]
|
|
||||||
: [buildYYYYMMDDHH(now), buildYYYYMMDDHH(now - 3_600_000), buildYYYYMMDDHH(now - 7_200_000)]
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
track: async () => {
|
track: async () => {
|
||||||
await Database.use((tx) =>
|
await Database.use((tx) =>
|
||||||
tx
|
tx
|
||||||
.insert(IpRateLimitTable)
|
.insert(IpRateLimitTable)
|
||||||
.values({ ip, interval: intervals[0], count: 1 })
|
.values({ ip, interval, count: 1 })
|
||||||
.onDuplicateKeyUpdate({ set: { count: sql`${IpRateLimitTable.count} + 1` } }),
|
.onDuplicateKeyUpdate({ set: { count: sql`${IpRateLimitTable.count} + 1` } }),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -30,15 +32,12 @@ export function createRateLimiter(limit: ZenData.RateLimit | undefined, rawIp: s
|
||||||
tx
|
tx
|
||||||
.select({ interval: IpRateLimitTable.interval, count: IpRateLimitTable.count })
|
.select({ interval: IpRateLimitTable.interval, count: IpRateLimitTable.count })
|
||||||
.from(IpRateLimitTable)
|
.from(IpRateLimitTable)
|
||||||
.where(and(eq(IpRateLimitTable.ip, ip), inArray(IpRateLimitTable.interval, intervals))),
|
.where(and(eq(IpRateLimitTable.ip, ip), inArray(IpRateLimitTable.interval, [interval]))),
|
||||||
)
|
)
|
||||||
const total = rows.reduce((sum, r) => sum + r.count, 0)
|
const total = rows.reduce((sum, r) => sum + r.count, 0)
|
||||||
logger.debug(`rate limit total: ${total}`)
|
logger.debug(`rate limit total: ${total}`)
|
||||||
if (total >= limitValue)
|
if (total >= limitValue)
|
||||||
throw new FreeUsageLimitError(
|
throw new FreeUsageLimitError(dict["zen.api.error.rateLimitExceeded"], getRetryAfterDay(now))
|
||||||
`Rate limit exceeded. Please try again later.`,
|
|
||||||
limit.period === "day" ? getRetryAfterDay(now) : getRetryAfterHour(rows, intervals, limitValue, now),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -47,37 +46,9 @@ export function getRetryAfterDay(now: number) {
|
||||||
return Math.ceil((86_400_000 - (now % 86_400_000)) / 1000)
|
return Math.ceil((86_400_000 - (now % 86_400_000)) / 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRetryAfterHour(
|
|
||||||
rows: { interval: string; count: number }[],
|
|
||||||
intervals: string[],
|
|
||||||
limit: number,
|
|
||||||
now: number,
|
|
||||||
) {
|
|
||||||
const counts = new Map(rows.map((r) => [r.interval, r.count]))
|
|
||||||
// intervals are ordered newest to oldest: [current, -1h, -2h]
|
|
||||||
// simulate dropping oldest intervals one at a time
|
|
||||||
let running = intervals.reduce((sum, i) => sum + (counts.get(i) ?? 0), 0)
|
|
||||||
for (let i = intervals.length - 1; i >= 0; i--) {
|
|
||||||
running -= counts.get(intervals[i]) ?? 0
|
|
||||||
if (running < limit) {
|
|
||||||
// interval at index i rolls out of the window (intervals.length - i) hours from the current hour start
|
|
||||||
const hours = intervals.length - i
|
|
||||||
return Math.ceil((hours * 3_600_000 - (now % 3_600_000)) / 1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Math.ceil((3_600_000 - (now % 3_600_000)) / 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildYYYYMMDD(timestamp: number) {
|
function buildYYYYMMDD(timestamp: number) {
|
||||||
return new Date(timestamp)
|
return new Date(timestamp)
|
||||||
.toISOString()
|
.toISOString()
|
||||||
.replace(/[^0-9]/g, "")
|
.replace(/[^0-9]/g, "")
|
||||||
.substring(0, 8)
|
.substring(0, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildYYYYMMDDHH(timestamp: number) {
|
|
||||||
return new Date(timestamp)
|
|
||||||
.toISOString()
|
|
||||||
.replace(/[^0-9]/g, "")
|
|
||||||
.substring(0, 10)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,18 @@
|
||||||
import { Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
import { Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
||||||
import { IpTable } from "@opencode-ai/console-core/schema/ip.sql.js"
|
import { IpTable } from "@opencode-ai/console-core/schema/ip.sql.js"
|
||||||
import { UsageInfo } from "./provider/provider"
|
import { UsageInfo } from "./provider/provider"
|
||||||
import { ZenData } from "@opencode-ai/console-core/model.js"
|
import { Subscription } from "@opencode-ai/console-core/subscription.js"
|
||||||
|
|
||||||
export function createTrialLimiter(trial: ZenData.Trial | undefined, ip: string, client: string) {
|
export function createTrialLimiter(trialProvider: string | undefined, ip: string) {
|
||||||
if (!trial) return
|
if (!trialProvider) return
|
||||||
if (!ip) return
|
if (!ip) return
|
||||||
|
|
||||||
const limit =
|
const limit = Subscription.getFreeLimits().promoTokens
|
||||||
trial.limits.find((limit) => limit.client === client)?.limit ??
|
|
||||||
trial.limits.find((limit) => limit.client === undefined)?.limit
|
|
||||||
if (!limit) return
|
|
||||||
|
|
||||||
let _isTrial: boolean
|
let _isTrial: boolean
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isTrial: async () => {
|
check: async () => {
|
||||||
const data = await Database.use((tx) =>
|
const data = await Database.use((tx) =>
|
||||||
tx
|
tx
|
||||||
.select({
|
.select({
|
||||||
|
|
@ -27,7 +24,7 @@ export function createTrialLimiter(trial: ZenData.Trial | undefined, ip: string,
|
||||||
)
|
)
|
||||||
|
|
||||||
_isTrial = (data?.usage ?? 0) < limit
|
_isTrial = (data?.usage ?? 0) < limit
|
||||||
return _isTrial
|
return _isTrial ? trialProvider : undefined
|
||||||
},
|
},
|
||||||
track: async (usageInfo: UsageInfo) => {
|
track: async (usageInfo: UsageInfo) => {
|
||||||
if (!_isTrial) return
|
if (!_isTrial) return
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { getRetryAfterDay, getRetryAfterHour } from "../src/routes/zen/util/rateLimiter"
|
import { getRetryAfterDay } from "../src/routes/zen/util/rateLimiter"
|
||||||
|
|
||||||
describe("getRetryAfterDay", () => {
|
describe("getRetryAfterDay", () => {
|
||||||
test("returns full day at midnight UTC", () => {
|
test("returns full day at midnight UTC", () => {
|
||||||
|
|
@ -17,76 +17,3 @@ describe("getRetryAfterDay", () => {
|
||||||
expect(getRetryAfterDay(almost)).toBe(1)
|
expect(getRetryAfterDay(almost)).toBe(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("getRetryAfterHour", () => {
|
|
||||||
// 14:30:00 UTC — 30 minutes into the current hour
|
|
||||||
const now = Date.UTC(2026, 0, 15, 14, 30, 0, 0)
|
|
||||||
const intervals = ["2026011514", "2026011513", "2026011512"]
|
|
||||||
|
|
||||||
test("waits 3 hours when all usage is in current hour", () => {
|
|
||||||
const rows = [{ interval: "2026011514", count: 10 }]
|
|
||||||
// only current hour has usage — it won't leave the window for 3 hours from hour start
|
|
||||||
// 3 * 3600 - 1800 = 9000s
|
|
||||||
expect(getRetryAfterHour(rows, intervals, 10, now)).toBe(9000)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("waits 1 hour when dropping oldest interval is sufficient", () => {
|
|
||||||
const rows = [
|
|
||||||
{ interval: "2026011514", count: 2 },
|
|
||||||
{ interval: "2026011512", count: 10 },
|
|
||||||
]
|
|
||||||
// total=12, drop oldest (-2h, count=10) -> 2 < 10
|
|
||||||
// hours = 3 - 2 = 1 -> 1 * 3600 - 1800 = 1800s
|
|
||||||
expect(getRetryAfterHour(rows, intervals, 10, now)).toBe(1800)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("waits 2 hours when usage spans oldest two intervals", () => {
|
|
||||||
const rows = [
|
|
||||||
{ interval: "2026011513", count: 8 },
|
|
||||||
{ interval: "2026011512", count: 5 },
|
|
||||||
]
|
|
||||||
// total=13, drop -2h (5) -> 8, 8 >= 8, drop -1h (8) -> 0 < 8
|
|
||||||
// hours = 3 - 1 = 2 -> 2 * 3600 - 1800 = 5400s
|
|
||||||
expect(getRetryAfterHour(rows, intervals, 8, now)).toBe(5400)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("waits 1 hour when oldest interval alone pushes over limit", () => {
|
|
||||||
const rows = [
|
|
||||||
{ interval: "2026011514", count: 1 },
|
|
||||||
{ interval: "2026011513", count: 1 },
|
|
||||||
{ interval: "2026011512", count: 10 },
|
|
||||||
]
|
|
||||||
// total=12, drop -2h (10) -> 2 < 10
|
|
||||||
// hours = 3 - 2 = 1 -> 1800s
|
|
||||||
expect(getRetryAfterHour(rows, intervals, 10, now)).toBe(1800)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("waits 2 hours when middle interval keeps total over limit", () => {
|
|
||||||
const rows = [
|
|
||||||
{ interval: "2026011514", count: 4 },
|
|
||||||
{ interval: "2026011513", count: 4 },
|
|
||||||
{ interval: "2026011512", count: 4 },
|
|
||||||
]
|
|
||||||
// total=12, drop -2h (4) -> 8, 8 >= 5, drop -1h (4) -> 4 < 5
|
|
||||||
// hours = 3 - 1 = 2 -> 5400s
|
|
||||||
expect(getRetryAfterHour(rows, intervals, 5, now)).toBe(5400)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("rounds up to nearest second", () => {
|
|
||||||
const offset = Date.UTC(2026, 0, 15, 14, 30, 0, 500)
|
|
||||||
const rows = [
|
|
||||||
{ interval: "2026011514", count: 2 },
|
|
||||||
{ interval: "2026011512", count: 10 },
|
|
||||||
]
|
|
||||||
// hours=1 -> 3_600_000 - 1_800_500 = 1_799_500ms -> ceil(1799.5) = 1800
|
|
||||||
expect(getRetryAfterHour(rows, intervals, 10, offset)).toBe(1800)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("fallback returns time until next hour when rows are empty", () => {
|
|
||||||
// edge case: rows empty but function called (shouldn't happen in practice)
|
|
||||||
// loop drops all zeros, running stays 0 which is < any positive limit on first iteration
|
|
||||||
const rows: { interval: string; count: number }[] = []
|
|
||||||
// drop -2h (0) -> 0 < 1 -> hours = 3 - 2 = 1 -> 1800s
|
|
||||||
expect(getRetryAfterHour(rows, intervals, 1, now)).toBe(1800)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|
@ -34,12 +34,9 @@
|
||||||
"promote-models-to-prod": "script/promote-models.ts production",
|
"promote-models-to-prod": "script/promote-models.ts production",
|
||||||
"pull-models-from-dev": "script/pull-models.ts dev",
|
"pull-models-from-dev": "script/pull-models.ts dev",
|
||||||
"pull-models-from-prod": "script/pull-models.ts production",
|
"pull-models-from-prod": "script/pull-models.ts production",
|
||||||
"update-black": "script/update-black.ts",
|
"update-limits": "script/update-limits.ts",
|
||||||
"promote-black-to-dev": "script/promote-black.ts dev",
|
"promote-limits-to-dev": "script/promote-limits.ts dev",
|
||||||
"promote-black-to-prod": "script/promote-black.ts production",
|
"promote-limits-to-prod": "script/promote-limits.ts production",
|
||||||
"update-lite": "script/update-lite.ts",
|
|
||||||
"promote-lite-to-dev": "script/promote-lite.ts dev",
|
|
||||||
"promote-lite-to-prod": "script/promote-lite.ts production",
|
|
||||||
"typecheck": "tsgo --noEmit"
|
"typecheck": "tsgo --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
312
packages/console/core/script/black-stats.ts
Normal file
312
packages/console/core/script/black-stats.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
||||||
|
import { Database, and, eq, inArray, isNotNull, sql } from "../src/drizzle/index.js"
|
||||||
|
import { BillingTable, BlackPlans, SubscriptionTable, UsageTable } from "../src/schema/billing.sql.js"
|
||||||
|
|
||||||
|
if (process.argv.length < 3) {
|
||||||
|
console.error("Usage: bun black-stats.ts <plan>")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
const plan = process.argv[2] as (typeof BlackPlans)[number]
|
||||||
|
if (!BlackPlans.includes(plan)) {
|
||||||
|
console.error("Usage: bun black-stats.ts <plan>")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
const cutoff = new Date(Date.UTC(2026, 1, 0, 23, 59, 59, 999))
|
||||||
|
|
||||||
|
// get workspaces
|
||||||
|
const workspaces = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select({ workspaceID: BillingTable.workspaceID })
|
||||||
|
.from(BillingTable)
|
||||||
|
.where(
|
||||||
|
and(isNotNull(BillingTable.subscriptionID), sql`JSON_UNQUOTE(JSON_EXTRACT(subscription, '$.plan')) = ${plan}`),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (workspaces.length === 0) throw new Error(`No active Black ${plan} subscriptions found`)
|
||||||
|
|
||||||
|
const week = sql<number>`YEARWEEK(${UsageTable.timeCreated}, 3)`
|
||||||
|
const workspaceIDs = workspaces.map((row) => row.workspaceID)
|
||||||
|
// Get subscription spend
|
||||||
|
const spend = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select({
|
||||||
|
workspaceID: UsageTable.workspaceID,
|
||||||
|
week,
|
||||||
|
amount: sql<number>`COALESCE(SUM(${UsageTable.cost}), 0)`,
|
||||||
|
})
|
||||||
|
.from(UsageTable)
|
||||||
|
.where(
|
||||||
|
and(inArray(UsageTable.workspaceID, workspaceIDs), sql`JSON_UNQUOTE(JSON_EXTRACT(enrichment, '$.plan')) = 'sub'`),
|
||||||
|
)
|
||||||
|
.groupBy(UsageTable.workspaceID, week),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get pay per use spend
|
||||||
|
const ppu = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select({
|
||||||
|
workspaceID: UsageTable.workspaceID,
|
||||||
|
week,
|
||||||
|
amount: sql<number>`COALESCE(SUM(${UsageTable.cost}), 0)`,
|
||||||
|
})
|
||||||
|
.from(UsageTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(UsageTable.workspaceID, workspaceIDs),
|
||||||
|
sql`(${UsageTable.enrichment} IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(enrichment, '$.plan')) != 'sub')`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(UsageTable.workspaceID, week),
|
||||||
|
)
|
||||||
|
|
||||||
|
const models = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select({
|
||||||
|
workspaceID: UsageTable.workspaceID,
|
||||||
|
model: UsageTable.model,
|
||||||
|
amount: sql<number>`COALESCE(SUM(${UsageTable.cost}), 0)`,
|
||||||
|
})
|
||||||
|
.from(UsageTable)
|
||||||
|
.where(
|
||||||
|
and(inArray(UsageTable.workspaceID, workspaceIDs), sql`JSON_UNQUOTE(JSON_EXTRACT(enrichment, '$.plan')) = 'sub'`),
|
||||||
|
)
|
||||||
|
.groupBy(UsageTable.workspaceID, UsageTable.model),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tokens = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select({
|
||||||
|
workspaceID: UsageTable.workspaceID,
|
||||||
|
week,
|
||||||
|
input: sql<number>`COALESCE(SUM(${UsageTable.inputTokens}), 0)`,
|
||||||
|
cacheRead: sql<number>`COALESCE(SUM(${UsageTable.cacheReadTokens}), 0)`,
|
||||||
|
output: sql<number>`COALESCE(SUM(${UsageTable.outputTokens}), 0) + COALESCE(SUM(${UsageTable.reasoningTokens}), 0)`,
|
||||||
|
})
|
||||||
|
.from(UsageTable)
|
||||||
|
.where(
|
||||||
|
and(inArray(UsageTable.workspaceID, workspaceIDs), sql`JSON_UNQUOTE(JSON_EXTRACT(enrichment, '$.plan')) = 'sub'`),
|
||||||
|
)
|
||||||
|
.groupBy(UsageTable.workspaceID, week),
|
||||||
|
)
|
||||||
|
|
||||||
|
const allWeeks = [...spend, ...ppu].map((row) => row.week)
|
||||||
|
const weeks = [...new Set(allWeeks)].sort((a, b) => a - b)
|
||||||
|
const spendMap = new Map<string, Map<number, number>>()
|
||||||
|
const totals = new Map<string, number>()
|
||||||
|
const ppuMap = new Map<string, Map<number, number>>()
|
||||||
|
const ppuTotals = new Map<string, number>()
|
||||||
|
const modelMap = new Map<string, { model: string; amount: number }[]>()
|
||||||
|
const tokenMap = new Map<string, Map<number, { input: number; cacheRead: number; output: number }>>()
|
||||||
|
|
||||||
|
for (const row of spend) {
|
||||||
|
const workspace = spendMap.get(row.workspaceID) ?? new Map<number, number>()
|
||||||
|
const total = totals.get(row.workspaceID) ?? 0
|
||||||
|
const amount = toNumber(row.amount)
|
||||||
|
workspace.set(row.week, amount)
|
||||||
|
totals.set(row.workspaceID, total + amount)
|
||||||
|
spendMap.set(row.workspaceID, workspace)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of ppu) {
|
||||||
|
const workspace = ppuMap.get(row.workspaceID) ?? new Map<number, number>()
|
||||||
|
const total = ppuTotals.get(row.workspaceID) ?? 0
|
||||||
|
const amount = toNumber(row.amount)
|
||||||
|
workspace.set(row.week, amount)
|
||||||
|
ppuTotals.set(row.workspaceID, total + amount)
|
||||||
|
ppuMap.set(row.workspaceID, workspace)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of models) {
|
||||||
|
const current = modelMap.get(row.workspaceID) ?? []
|
||||||
|
current.push({ model: row.model, amount: toNumber(row.amount) })
|
||||||
|
modelMap.set(row.workspaceID, current)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of tokens) {
|
||||||
|
const workspace = tokenMap.get(row.workspaceID) ?? new Map()
|
||||||
|
workspace.set(row.week, {
|
||||||
|
input: toNumber(row.input),
|
||||||
|
cacheRead: toNumber(row.cacheRead),
|
||||||
|
output: toNumber(row.output),
|
||||||
|
})
|
||||||
|
tokenMap.set(row.workspaceID, workspace)
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select({
|
||||||
|
workspaceID: SubscriptionTable.workspaceID,
|
||||||
|
subscribed: SubscriptionTable.timeCreated,
|
||||||
|
subscription: BillingTable.subscription,
|
||||||
|
})
|
||||||
|
.from(SubscriptionTable)
|
||||||
|
.innerJoin(BillingTable, eq(SubscriptionTable.workspaceID, BillingTable.workspaceID))
|
||||||
|
.where(
|
||||||
|
and(inArray(SubscriptionTable.workspaceID, workspaceIDs), sql`${SubscriptionTable.timeCreated} <= ${cutoff}`),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
for (const user of users) {
|
||||||
|
const current = counts.get(user.workspaceID) ?? 0
|
||||||
|
counts.set(user.workspaceID, current + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = users
|
||||||
|
.map((user) => {
|
||||||
|
const workspace = spendMap.get(user.workspaceID) ?? new Map<number, number>()
|
||||||
|
const ppuWorkspace = ppuMap.get(user.workspaceID) ?? new Map<number, number>()
|
||||||
|
const count = counts.get(user.workspaceID) ?? 1
|
||||||
|
const amount = (totals.get(user.workspaceID) ?? 0) / count
|
||||||
|
const ppuAmount = (ppuTotals.get(user.workspaceID) ?? 0) / count
|
||||||
|
const monthStart = user.subscribed ? startOfMonth(user.subscribed) : null
|
||||||
|
const modelRows = (modelMap.get(user.workspaceID) ?? []).sort((a, b) => b.amount - a.amount).slice(0, 3)
|
||||||
|
const modelTotal = totals.get(user.workspaceID) ?? 0
|
||||||
|
const modelCells = modelRows.map((row) => ({
|
||||||
|
model: row.model,
|
||||||
|
percent: modelTotal > 0 ? `${((row.amount / modelTotal) * 100).toFixed(1)}%` : "0.0%",
|
||||||
|
}))
|
||||||
|
const modelData = [0, 1, 2].map((index) => modelCells[index] ?? { model: "-", percent: "-" })
|
||||||
|
const weekly = Object.fromEntries(
|
||||||
|
weeks.map((item) => {
|
||||||
|
const value = (workspace.get(item) ?? 0) / count
|
||||||
|
const beforeMonth = monthStart ? isoWeekStart(item) < monthStart : false
|
||||||
|
return [formatWeek(item), beforeMonth ? "-" : formatMicroCents(value)]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const ppuWeekly = Object.fromEntries(
|
||||||
|
weeks.map((item) => {
|
||||||
|
const value = (ppuWorkspace.get(item) ?? 0) / count
|
||||||
|
const beforeMonth = monthStart ? isoWeekStart(item) < monthStart : false
|
||||||
|
return [formatWeek(item), beforeMonth ? "-" : formatMicroCents(value)]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const tokenWorkspace = tokenMap.get(user.workspaceID) ?? new Map()
|
||||||
|
const weeklyTokens = Object.fromEntries(
|
||||||
|
weeks.map((item) => {
|
||||||
|
const t = tokenWorkspace.get(item) ?? { input: 0, cacheRead: 0, output: 0 }
|
||||||
|
const beforeMonth = monthStart ? isoWeekStart(item) < monthStart : false
|
||||||
|
return [
|
||||||
|
formatWeek(item),
|
||||||
|
beforeMonth
|
||||||
|
? { input: "-", cacheRead: "-", output: "-" }
|
||||||
|
: {
|
||||||
|
input: Math.round(t.input / count),
|
||||||
|
cacheRead: Math.round(t.cacheRead / count),
|
||||||
|
output: Math.round(t.output / count),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
workspaceID: user.workspaceID,
|
||||||
|
useBalance: user.subscription?.useBalance ?? false,
|
||||||
|
subscribed: formatDate(user.subscribed),
|
||||||
|
subscribedAt: user.subscribed?.getTime() ?? 0,
|
||||||
|
amount,
|
||||||
|
ppuAmount,
|
||||||
|
models: modelData,
|
||||||
|
weekly,
|
||||||
|
ppuWeekly,
|
||||||
|
weeklyTokens,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.subscribedAt - b.subscribedAt)
|
||||||
|
|
||||||
|
console.log(`Black ${plan} subscribers: ${rows.length}`)
|
||||||
|
const header = [
|
||||||
|
"workspaceID",
|
||||||
|
"subscribed",
|
||||||
|
"useCredit",
|
||||||
|
"subTotal",
|
||||||
|
"ppuTotal",
|
||||||
|
"model1",
|
||||||
|
"model1%",
|
||||||
|
"model2",
|
||||||
|
"model2%",
|
||||||
|
"model3",
|
||||||
|
"model3%",
|
||||||
|
...weeks.flatMap((item) => [
|
||||||
|
formatWeek(item) + " sub",
|
||||||
|
formatWeek(item) + " ppu",
|
||||||
|
formatWeek(item) + " input",
|
||||||
|
formatWeek(item) + " cache",
|
||||||
|
formatWeek(item) + " output",
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
const lines = [header.map(csvCell).join(",")]
|
||||||
|
for (const row of rows) {
|
||||||
|
const model1 = row.models[0]
|
||||||
|
const model2 = row.models[1]
|
||||||
|
const model3 = row.models[2]
|
||||||
|
const cells = [
|
||||||
|
row.workspaceID,
|
||||||
|
row.subscribed ?? "",
|
||||||
|
row.useBalance ? "yes" : "no",
|
||||||
|
formatMicroCents(row.amount),
|
||||||
|
formatMicroCents(row.ppuAmount),
|
||||||
|
model1.model,
|
||||||
|
model1.percent,
|
||||||
|
model2.model,
|
||||||
|
model2.percent,
|
||||||
|
model3.model,
|
||||||
|
model3.percent,
|
||||||
|
...weeks.flatMap((item) => {
|
||||||
|
const t = row.weeklyTokens[formatWeek(item)] ?? { input: "-", cacheRead: "-", output: "-" }
|
||||||
|
return [
|
||||||
|
row.weekly[formatWeek(item)] ?? "",
|
||||||
|
row.ppuWeekly[formatWeek(item)] ?? "",
|
||||||
|
String(t.input),
|
||||||
|
String(t.cacheRead),
|
||||||
|
String(t.output),
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
lines.push(cells.map(csvCell).join(","))
|
||||||
|
}
|
||||||
|
const output = `${lines.join("\n")}\n`
|
||||||
|
const file = Bun.file(`black-stats-${plan}.csv`)
|
||||||
|
await file.write(output)
|
||||||
|
console.log(`Wrote ${lines.length - 1} rows to ${file.name}`)
|
||||||
|
const total = rows.reduce((sum, row) => sum + row.amount, 0)
|
||||||
|
const average = rows.length === 0 ? 0 : total / rows.length
|
||||||
|
console.log(`Average spending per user: ${formatMicroCents(average)}`)
|
||||||
|
|
||||||
|
function formatMicroCents(value: number) {
|
||||||
|
return `$${(value / 100000000).toFixed(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: Date | null | undefined) {
|
||||||
|
if (!value) return null
|
||||||
|
return value.toISOString().split("T")[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWeek(value: number) {
|
||||||
|
return formatDate(isoWeekStart(value)) ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfMonth(value: Date) {
|
||||||
|
return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoWeekStart(value: number) {
|
||||||
|
const year = Math.floor(value / 100)
|
||||||
|
const weekNumber = value % 100
|
||||||
|
const jan4 = new Date(Date.UTC(year, 0, 4))
|
||||||
|
const day = jan4.getUTCDay() || 7
|
||||||
|
const weekStart = new Date(Date.UTC(year, 0, 4 - (day - 1)))
|
||||||
|
weekStart.setUTCDate(weekStart.getUTCDate() + (weekNumber - 1) * 7)
|
||||||
|
return weekStart
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumber(value: unknown) {
|
||||||
|
if (typeof value === "number") return value
|
||||||
|
if (typeof value === "bigint") return Number(value)
|
||||||
|
if (typeof value === "string") return Number(value)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function csvCell(value: string | number) {
|
||||||
|
const text = String(value)
|
||||||
|
if (!/[",\n]/.test(text)) return text
|
||||||
|
return `"${text.replace(/"/g, '""')}"`
|
||||||
|
}
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
#!/usr/bin/env bun
|
|
||||||
|
|
||||||
import { $ } from "bun"
|
|
||||||
import path from "path"
|
|
||||||
import { BlackData } from "../src/black"
|
|
||||||
|
|
||||||
const stage = process.argv[2]
|
|
||||||
if (!stage) throw new Error("Stage is required")
|
|
||||||
|
|
||||||
const root = path.resolve(process.cwd(), "..", "..", "..")
|
|
||||||
|
|
||||||
// read the secret
|
|
||||||
const ret = await $`bun sst secret list`.cwd(root).text()
|
|
||||||
const lines = ret.split("\n")
|
|
||||||
const value = lines.find((line) => line.startsWith("ZEN_BLACK_LIMITS"))?.split("=")[1]
|
|
||||||
if (!value) throw new Error("ZEN_BLACK_LIMITS not found")
|
|
||||||
|
|
||||||
// validate value
|
|
||||||
BlackData.validate(JSON.parse(value))
|
|
||||||
|
|
||||||
// update the secret
|
|
||||||
await $`bun sst secret set ZEN_BLACK_LIMITS ${value} --stage ${stage}`
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { LiteData } from "../src/lite"
|
import { Subscription } from "../src/subscription"
|
||||||
|
|
||||||
const stage = process.argv[2]
|
const stage = process.argv[2]
|
||||||
if (!stage) throw new Error("Stage is required")
|
if (!stage) throw new Error("Stage is required")
|
||||||
|
|
@ -12,11 +12,11 @@ const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||||
// read the secret
|
// read the secret
|
||||||
const ret = await $`bun sst secret list`.cwd(root).text()
|
const ret = await $`bun sst secret list`.cwd(root).text()
|
||||||
const lines = ret.split("\n")
|
const lines = ret.split("\n")
|
||||||
const value = lines.find((line) => line.startsWith("ZEN_LITE_LIMITS"))?.split("=")[1]
|
const value = lines.find((line) => line.startsWith("ZEN_LIMITS"))?.split("=")[1]
|
||||||
if (!value) throw new Error("ZEN_LITE_LIMITS not found")
|
if (!value) throw new Error("ZEN_LIMITS not found")
|
||||||
|
|
||||||
// validate value
|
// validate value
|
||||||
LiteData.validate(JSON.parse(value))
|
Subscription.validate(JSON.parse(value))
|
||||||
|
|
||||||
// update the secret
|
// update the secret
|
||||||
await $`bun sst secret set ZEN_LITE_LIMITS ${value} --stage ${stage}`
|
await $`bun sst secret set ZEN_LIMITS ${value} --stage ${stage}`
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
#!/usr/bin/env bun
|
|
||||||
|
|
||||||
import { $ } from "bun"
|
|
||||||
import path from "path"
|
|
||||||
import os from "os"
|
|
||||||
import { BlackData } from "../src/black"
|
|
||||||
|
|
||||||
const root = path.resolve(process.cwd(), "..", "..", "..")
|
|
||||||
const secrets = await $`bun sst secret list`.cwd(root).text()
|
|
||||||
|
|
||||||
// read value
|
|
||||||
const lines = secrets.split("\n")
|
|
||||||
const oldValue = lines.find((line) => line.startsWith("ZEN_BLACK_LIMITS"))?.split("=")[1] ?? "{}"
|
|
||||||
if (!oldValue) throw new Error("ZEN_BLACK_LIMITS not found")
|
|
||||||
|
|
||||||
// store the prettified json to a temp file
|
|
||||||
const filename = `black-${Date.now()}.json`
|
|
||||||
const tempFile = Bun.file(path.join(os.tmpdir(), filename))
|
|
||||||
await tempFile.write(JSON.stringify(JSON.parse(oldValue), null, 2))
|
|
||||||
console.log("tempFile", tempFile.name)
|
|
||||||
|
|
||||||
// open temp file in vim and read the file on close
|
|
||||||
await $`vim ${tempFile.name}`
|
|
||||||
const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
|
|
||||||
BlackData.validate(JSON.parse(newValue))
|
|
||||||
|
|
||||||
// update the secret
|
|
||||||
await $`bun sst secret set ZEN_BLACK_LIMITS ${newValue}`
|
|
||||||
|
|
@ -3,18 +3,18 @@
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import { LiteData } from "../src/lite"
|
import { Subscription } from "../src/subscription"
|
||||||
|
|
||||||
const root = path.resolve(process.cwd(), "..", "..", "..")
|
const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||||
const secrets = await $`bun sst secret list`.cwd(root).text()
|
const secrets = await $`bun sst secret list`.cwd(root).text()
|
||||||
|
|
||||||
// read value
|
// read value
|
||||||
const lines = secrets.split("\n")
|
const lines = secrets.split("\n")
|
||||||
const oldValue = lines.find((line) => line.startsWith("ZEN_LITE_LIMITS"))?.split("=")[1] ?? "{}"
|
const oldValue = lines.find((line) => line.startsWith("ZEN_LIMITS"))?.split("=")[1] ?? "{}"
|
||||||
if (!oldValue) throw new Error("ZEN_LITE_LIMITS not found")
|
if (!oldValue) throw new Error("ZEN_LIMITS not found")
|
||||||
|
|
||||||
// store the prettified json to a temp file
|
// store the prettified json to a temp file
|
||||||
const filename = `lite-${Date.now()}.json`
|
const filename = `limits-${Date.now()}.json`
|
||||||
const tempFile = Bun.file(path.join(os.tmpdir(), filename))
|
const tempFile = Bun.file(path.join(os.tmpdir(), filename))
|
||||||
await tempFile.write(JSON.stringify(JSON.parse(oldValue), null, 2))
|
await tempFile.write(JSON.stringify(JSON.parse(oldValue), null, 2))
|
||||||
console.log("tempFile", tempFile.name)
|
console.log("tempFile", tempFile.name)
|
||||||
|
|
@ -22,7 +22,7 @@ console.log("tempFile", tempFile.name)
|
||||||
// open temp file in vim and read the file on close
|
// open temp file in vim and read the file on close
|
||||||
await $`vim ${tempFile.name}`
|
await $`vim ${tempFile.name}`
|
||||||
const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
|
const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
|
||||||
LiteData.validate(JSON.parse(newValue))
|
Subscription.validate(JSON.parse(newValue))
|
||||||
|
|
||||||
// update the secret
|
// update the secret
|
||||||
await $`bun sst secret set ZEN_LITE_LIMITS ${newValue}`
|
await $`bun sst secret set ZEN_LIMITS ${newValue}`
|
||||||
|
|
@ -2,37 +2,15 @@ import { z } from "zod"
|
||||||
import { fn } from "./util/fn"
|
import { fn } from "./util/fn"
|
||||||
import { Resource } from "@opencode-ai/console-resource"
|
import { Resource } from "@opencode-ai/console-resource"
|
||||||
import { BlackPlans } from "./schema/billing.sql"
|
import { BlackPlans } from "./schema/billing.sql"
|
||||||
|
import { Subscription } from "./subscription"
|
||||||
|
|
||||||
export namespace BlackData {
|
export namespace BlackData {
|
||||||
const Schema = z.object({
|
|
||||||
"200": z.object({
|
|
||||||
fixedLimit: z.number().int(),
|
|
||||||
rollingLimit: z.number().int(),
|
|
||||||
rollingWindow: z.number().int(),
|
|
||||||
}),
|
|
||||||
"100": z.object({
|
|
||||||
fixedLimit: z.number().int(),
|
|
||||||
rollingLimit: z.number().int(),
|
|
||||||
rollingWindow: z.number().int(),
|
|
||||||
}),
|
|
||||||
"20": z.object({
|
|
||||||
fixedLimit: z.number().int(),
|
|
||||||
rollingLimit: z.number().int(),
|
|
||||||
rollingWindow: z.number().int(),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const validate = fn(Schema, (input) => {
|
|
||||||
return input
|
|
||||||
})
|
|
||||||
|
|
||||||
export const getLimits = fn(
|
export const getLimits = fn(
|
||||||
z.object({
|
z.object({
|
||||||
plan: z.enum(BlackPlans),
|
plan: z.enum(BlackPlans),
|
||||||
}),
|
}),
|
||||||
({ plan }) => {
|
({ plan }) => {
|
||||||
const json = JSON.parse(Resource.ZEN_BLACK_LIMITS.value)
|
return Subscription.getLimits()["black"][plan]
|
||||||
return Schema.parse(json)[plan]
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,11 @@
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { fn } from "./util/fn"
|
import { fn } from "./util/fn"
|
||||||
import { Resource } from "@opencode-ai/console-resource"
|
import { Resource } from "@opencode-ai/console-resource"
|
||||||
|
import { Subscription } from "./subscription"
|
||||||
|
|
||||||
export namespace LiteData {
|
export namespace LiteData {
|
||||||
const Schema = z.object({
|
|
||||||
rollingLimit: z.number().int(),
|
|
||||||
rollingWindow: z.number().int(),
|
|
||||||
weeklyLimit: z.number().int(),
|
|
||||||
monthlyLimit: z.number().int(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const validate = fn(Schema, (input) => {
|
|
||||||
return input
|
|
||||||
})
|
|
||||||
|
|
||||||
export const getLimits = fn(z.void(), () => {
|
export const getLimits = fn(z.void(), () => {
|
||||||
const json = JSON.parse(Resource.ZEN_LITE_LIMITS.value)
|
return Subscription.getLimits()["lite"]
|
||||||
return Schema.parse(json)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product)
|
export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product)
|
||||||
|
|
|
||||||
|
|
@ -9,24 +9,7 @@ import { Resource } from "@opencode-ai/console-resource"
|
||||||
|
|
||||||
export namespace ZenData {
|
export namespace ZenData {
|
||||||
const FormatSchema = z.enum(["anthropic", "google", "openai", "oa-compat"])
|
const FormatSchema = z.enum(["anthropic", "google", "openai", "oa-compat"])
|
||||||
const TrialSchema = z.object({
|
|
||||||
provider: z.string(),
|
|
||||||
limits: z.array(
|
|
||||||
z.object({
|
|
||||||
limit: z.number(),
|
|
||||||
client: z.enum(["cli", "desktop"]).optional(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
const RateLimitSchema = z.object({
|
|
||||||
period: z.enum(["day", "rolling"]),
|
|
||||||
value: z.number().int(),
|
|
||||||
checkHeader: z.string().optional(),
|
|
||||||
fallbackValue: z.number().int().optional(),
|
|
||||||
})
|
|
||||||
export type Format = z.infer<typeof FormatSchema>
|
export type Format = z.infer<typeof FormatSchema>
|
||||||
export type Trial = z.infer<typeof TrialSchema>
|
|
||||||
export type RateLimit = z.infer<typeof RateLimitSchema>
|
|
||||||
|
|
||||||
const ModelCostSchema = z.object({
|
const ModelCostSchema = z.object({
|
||||||
input: z.number(),
|
input: z.number(),
|
||||||
|
|
@ -43,8 +26,7 @@ export namespace ZenData {
|
||||||
allowAnonymous: z.boolean().optional(),
|
allowAnonymous: z.boolean().optional(),
|
||||||
byokProvider: z.enum(["openai", "anthropic", "google"]).optional(),
|
byokProvider: z.enum(["openai", "anthropic", "google"]).optional(),
|
||||||
stickyProvider: z.enum(["strict", "prefer"]).optional(),
|
stickyProvider: z.enum(["strict", "prefer"]).optional(),
|
||||||
trial: TrialSchema.optional(),
|
trialProvider: z.string().optional(),
|
||||||
rateLimit: RateLimitSchema.optional(),
|
|
||||||
fallbackProvider: z.string().optional(),
|
fallbackProvider: z.string().optional(),
|
||||||
providers: z.array(
|
providers: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
|
|
@ -63,19 +45,12 @@ export namespace ZenData {
|
||||||
format: FormatSchema.optional(),
|
format: FormatSchema.optional(),
|
||||||
headerMappings: z.record(z.string(), z.string()).optional(),
|
headerMappings: z.record(z.string(), z.string()).optional(),
|
||||||
payloadModifier: z.record(z.string(), z.any()).optional(),
|
payloadModifier: z.record(z.string(), z.any()).optional(),
|
||||||
family: z.string().optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const ProviderFamilySchema = z.object({
|
|
||||||
headers: z.record(z.string(), z.string()).optional(),
|
|
||||||
responseModifier: z.record(z.string(), z.string()).optional(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const ModelsSchema = z.object({
|
const ModelsSchema = z.object({
|
||||||
models: z.record(z.string(), z.union([ModelSchema, z.array(ModelSchema.extend({ formatFilter: FormatSchema }))])),
|
models: z.record(z.string(), z.union([ModelSchema, z.array(ModelSchema.extend({ formatFilter: FormatSchema }))])),
|
||||||
liteModels: z.record(z.string(), ModelSchema),
|
liteModels: z.record(z.string(), ModelSchema),
|
||||||
providers: z.record(z.string(), ProviderSchema),
|
providers: z.record(z.string(), ProviderSchema),
|
||||||
providerFamilies: z.record(z.string(), ProviderFamilySchema),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const validate = fn(ModelsSchema, (input) => {
|
export const validate = fn(ModelsSchema, (input) => {
|
||||||
|
|
@ -115,15 +90,10 @@ export namespace ZenData {
|
||||||
Resource.ZEN_MODELS29.value +
|
Resource.ZEN_MODELS29.value +
|
||||||
Resource.ZEN_MODELS30.value,
|
Resource.ZEN_MODELS30.value,
|
||||||
)
|
)
|
||||||
const { models, liteModels, providers, providerFamilies } = ModelsSchema.parse(json)
|
const { models, liteModels, providers } = ModelsSchema.parse(json)
|
||||||
return {
|
return {
|
||||||
models: modelList === "lite" ? liteModels : models,
|
models: modelList === "lite" ? liteModels : models,
|
||||||
providers: Object.fromEntries(
|
providers,
|
||||||
Object.entries(providers).map(([id, provider]) => [
|
|
||||||
id,
|
|
||||||
{ ...provider, ...(provider.family ? providerFamilies[provider.family] : {}) },
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,54 @@ import { z } from "zod"
|
||||||
import { fn } from "./util/fn"
|
import { fn } from "./util/fn"
|
||||||
import { centsToMicroCents } from "./util/price"
|
import { centsToMicroCents } from "./util/price"
|
||||||
import { getWeekBounds, getMonthlyBounds } from "./util/date"
|
import { getWeekBounds, getMonthlyBounds } from "./util/date"
|
||||||
|
import { Resource } from "@opencode-ai/console-resource"
|
||||||
|
|
||||||
export namespace Subscription {
|
export namespace Subscription {
|
||||||
|
const LimitsSchema = z.object({
|
||||||
|
free: z.object({
|
||||||
|
promoTokens: z.number().int(),
|
||||||
|
dailyRequests: z.number().int(),
|
||||||
|
checkHeader: z.string(),
|
||||||
|
fallbackValue: z.number().int(),
|
||||||
|
}),
|
||||||
|
lite: z.object({
|
||||||
|
rollingLimit: z.number().int(),
|
||||||
|
rollingWindow: z.number().int(),
|
||||||
|
weeklyLimit: z.number().int(),
|
||||||
|
monthlyLimit: z.number().int(),
|
||||||
|
}),
|
||||||
|
black: z.object({
|
||||||
|
"20": z.object({
|
||||||
|
fixedLimit: z.number().int(),
|
||||||
|
rollingLimit: z.number().int(),
|
||||||
|
rollingWindow: z.number().int(),
|
||||||
|
}),
|
||||||
|
"100": z.object({
|
||||||
|
fixedLimit: z.number().int(),
|
||||||
|
rollingLimit: z.number().int(),
|
||||||
|
rollingWindow: z.number().int(),
|
||||||
|
}),
|
||||||
|
"200": z.object({
|
||||||
|
fixedLimit: z.number().int(),
|
||||||
|
rollingLimit: z.number().int(),
|
||||||
|
rollingWindow: z.number().int(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const validate = fn(LimitsSchema, (input) => {
|
||||||
|
return input
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getLimits = fn(z.void(), () => {
|
||||||
|
const json = JSON.parse(Resource.ZEN_LIMITS.value)
|
||||||
|
return LimitsSchema.parse(json)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getFreeLimits = fn(z.void(), () => {
|
||||||
|
return getLimits()["free"]
|
||||||
|
})
|
||||||
|
|
||||||
export const analyzeRollingUsage = fn(
|
export const analyzeRollingUsage = fn(
|
||||||
z.object({
|
z.object({
|
||||||
limit: z.number().int(),
|
limit: z.number().int(),
|
||||||
|
|
|
||||||
6
packages/console/core/sst-env.d.ts
vendored
6
packages/console/core/sst-env.d.ts
vendored
|
|
@ -119,10 +119,6 @@ declare module "sst" {
|
||||||
"type": "sst.cloudflare.StaticSite"
|
"type": "sst.cloudflare.StaticSite"
|
||||||
"url": string
|
"url": string
|
||||||
}
|
}
|
||||||
"ZEN_BLACK_LIMITS": {
|
|
||||||
"type": "sst.sst.Secret"
|
|
||||||
"value": string
|
|
||||||
}
|
|
||||||
"ZEN_BLACK_PRICE": {
|
"ZEN_BLACK_PRICE": {
|
||||||
"plan100": string
|
"plan100": string
|
||||||
"plan20": string
|
"plan20": string
|
||||||
|
|
@ -130,7 +126,7 @@ declare module "sst" {
|
||||||
"product": string
|
"product": string
|
||||||
"type": "sst.sst.Linkable"
|
"type": "sst.sst.Linkable"
|
||||||
}
|
}
|
||||||
"ZEN_LITE_LIMITS": {
|
"ZEN_LIMITS": {
|
||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
6
packages/console/function/sst-env.d.ts
vendored
6
packages/console/function/sst-env.d.ts
vendored
|
|
@ -119,10 +119,6 @@ declare module "sst" {
|
||||||
"type": "sst.cloudflare.StaticSite"
|
"type": "sst.cloudflare.StaticSite"
|
||||||
"url": string
|
"url": string
|
||||||
}
|
}
|
||||||
"ZEN_BLACK_LIMITS": {
|
|
||||||
"type": "sst.sst.Secret"
|
|
||||||
"value": string
|
|
||||||
}
|
|
||||||
"ZEN_BLACK_PRICE": {
|
"ZEN_BLACK_PRICE": {
|
||||||
"plan100": string
|
"plan100": string
|
||||||
"plan20": string
|
"plan20": string
|
||||||
|
|
@ -130,7 +126,7 @@ declare module "sst" {
|
||||||
"product": string
|
"product": string
|
||||||
"type": "sst.sst.Linkable"
|
"type": "sst.sst.Linkable"
|
||||||
}
|
}
|
||||||
"ZEN_LITE_LIMITS": {
|
"ZEN_LIMITS": {
|
||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
|
|
|
||||||
6
packages/console/resource/sst-env.d.ts
vendored
6
packages/console/resource/sst-env.d.ts
vendored
|
|
@ -119,10 +119,6 @@ declare module "sst" {
|
||||||
"type": "sst.cloudflare.StaticSite"
|
"type": "sst.cloudflare.StaticSite"
|
||||||
"url": string
|
"url": string
|
||||||
}
|
}
|
||||||
"ZEN_BLACK_LIMITS": {
|
|
||||||
"type": "sst.sst.Secret"
|
|
||||||
"value": string
|
|
||||||
}
|
|
||||||
"ZEN_BLACK_PRICE": {
|
"ZEN_BLACK_PRICE": {
|
||||||
"plan100": string
|
"plan100": string
|
||||||
"plan20": string
|
"plan20": string
|
||||||
|
|
@ -130,7 +126,7 @@ declare module "sst" {
|
||||||
"product": string
|
"product": string
|
||||||
"type": "sst.sst.Linkable"
|
"type": "sst.sst.Linkable"
|
||||||
}
|
}
|
||||||
"ZEN_LITE_LIMITS": {
|
"ZEN_LIMITS": {
|
||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue