mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 09:10:47 -04:00
Compare commits
326 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b3d027e3b | |||
| ce209e22a2 | |||
| 33b2795cc8 | |||
| 10bd044c55 | |||
| c09bcfe531 | |||
| 83227be0ca | |||
| 8ee47a0533 | |||
| a546e88f37 | |||
| e998c9e9cb | |||
| 889087c966 | |||
| 7f3b64c7c4 | |||
| e60a6e3a82 | |||
| 135c8f0e99 | |||
| f02504bb80 | |||
| 40834fdf2f | |||
| fc0588954b | |||
| 75960e3bf3 | |||
| f14ac472a3 | |||
| 9ed93715ef | |||
| b34ca44abe | |||
| 40ba8f3570 | |||
| e543acf923 | |||
| d183568644 | |||
| f27eb8f09e | |||
| ad0545335a | |||
| cfbbae7323 | |||
| 940f971ca0 | |||
| 78ca49a1bc | |||
| 1d54b0e540 | |||
| 7e971d8302 | |||
| 54b3b3fe05 | |||
| 9d012b0621 | |||
| fbb0a93e12 | |||
| e2e7a8d722 | |||
| ce7923adaf | |||
| a26d53151b | |||
| 5eaef6b758 | |||
| c5c38cad9c | |||
| 9918f389e7 | |||
| dd8c424806 | |||
| 078d8a07cf | |||
| 1ee712e549 | |||
| 55315bdffa | |||
| 882b8e1e75 | |||
| 95edbc0ae6 | |||
| 11cd4fb639 | |||
| 9c16bd1e30 | |||
| 5e9d5c734e | |||
| b382d1a467 | |||
| 23f31475e7 | |||
| c0eab9e442 | |||
| 8a1e85d0c8 | |||
| 2793502db2 | |||
| 9f7bd0246c | |||
| a6a4350d10 | |||
| 471b9f4dc4 | |||
| 24fb9b1296 | |||
| 3573019916 | |||
| fc5b353144 | |||
| 1dd257b76a | |||
| 5fa1673341 | |||
| daaa1c7e26 | |||
| 1fae784b81 | |||
| 81b7b58a5e | |||
| 866188a643 | |||
| e6fd57165e | |||
| a5d99e7a3c | |||
| a92c75e5f4 | |||
| 826fd3350c | |||
| 23a2d01282 | |||
| 5181f9b4e1 | |||
| f52ae28432 | |||
| 36119ff173 | |||
| bb90f3bbf9 | |||
| 05cdb7c107 | |||
| b493dabfe6 | |||
| c4816f944e | |||
| 211136e3a8 | |||
| cf0a53c501 | |||
| 2899984819 | |||
| eafbe5c57c | |||
| 7b98f544ff | |||
| b5aba5807c | |||
| d5c4c26b4b | |||
| a35b8a95c2 | |||
| 56fa267e09 | |||
| 2b73a08916 | |||
| b0190116a7 | |||
| 11c0ad24aa | |||
| 4cfe8a8bf8 | |||
| 754951bbbd | |||
| cded68a2e2 | |||
| 0068ccec35 | |||
| 89e8994fd1 | |||
| 5980b0a5ee | |||
| 89029a20ef | |||
| ce69bd97b9 | |||
| 999d8651aa | |||
| ed0f022502 | |||
| b1307d5c2a | |||
| dc16013b4f | |||
| e7686dbd64 | |||
| 47f553f9ba | |||
| d11268ece7 | |||
| 650a13a690 | |||
| 54435325b6 | |||
| 11fa257549 | |||
| 6af8ab0df2 | |||
| 984f5ed6eb | |||
| c2061c6bbf | |||
| b708e8431e | |||
| 9b6c397171 | |||
| 9b0659d4f9 | |||
| f83cecaaf6 | |||
| aa05b9abe5 | |||
| 68834cfcc3 | |||
| 5621373bc2 | |||
| 88582566bf | |||
| d6e1362fee | |||
| b275b8580d | |||
| 467be08e67 | |||
| bbb422d125 | |||
| 38d4d03ba8 | |||
| b1f076558c | |||
| 992435aaf8 | |||
| 2f73e73e9d | |||
| 4c30a78cd9 | |||
| a8c78fc005 | |||
| fcb473ff64 | |||
| 797953c88d | |||
| ce0cfb0ea5 | |||
| 13dfe569ef | |||
| c491161c0c | |||
| fde3d9133b | |||
| 0d582f9d3f | |||
| 1a59133168 | |||
| 803d9eb7ad | |||
| a27d3c1623 | |||
| 551216a452 | |||
| 38cd3979f2 | |||
| 3fe602cda3 | |||
| 3a4b49095c | |||
| ac5b395c5d | |||
| 8fbbca5f4b | |||
| 2415820ecd | |||
| 20103eb97b | |||
| 10c4ab9a3d | |||
| 7e39c9b950 | |||
| cc063d4c32 | |||
| 3707e4a49c | |||
| cb425ac927 | |||
| 0f80c827ed | |||
| fffc496f41 | |||
| 06ae43920b | |||
| e78d75a003 | |||
| ec3ac0c4b0 | |||
| c57c5315c1 | |||
| a726530735 | |||
| d9950598d0 | |||
| 81f0885879 | |||
| 65b2a10e97 | |||
| 7605acff65 | |||
| e7f8f7fa3b | |||
| 72d7cb717d | |||
| f0caeb9b25 | |||
| 76a141090e | |||
| 4bd5a158a5 | |||
| dfaae14544 | |||
| 79e9baf55a | |||
| 9ee89f7868 | |||
| 67dbb3cf18 | |||
| 4260c40efa | |||
| 0bedea52b1 | |||
| fbbab9d6c8 | |||
| cccb907a9b | |||
| ee7339f2c6 | |||
| c51f3e35ca | |||
| 7b3bb9a761 | |||
| dc38f22bd8 | |||
| 220e3e9a2b | |||
| f135c0b5ee | |||
| ebe6ea580d | |||
| ee708040f6 | |||
| 61c4815a37 | |||
| 01bb54a94d | |||
| f592c3846b | |||
| c026e25088 | |||
| 8ba73bed23 | |||
| 4f8986aa48 | |||
| 9c87a144e8 | |||
| 5b9fa32255 | |||
| f13778215a | |||
| 326471a25c | |||
| 6405e3a7b1 | |||
| 8afb625bab | |||
| c59df636cc | |||
| 94878d76f8 | |||
| 5022895e2b | |||
| 54046e0b98 | |||
| d2cb1613ac | |||
| 266fb93422 | |||
| 51d8219c46 | |||
| d6af5a686c | |||
| 39342b0e75 | |||
| 54078c4cae | |||
| c0bfccc15e | |||
| 53dc7b1649 | |||
| 635970b0a1 | |||
| 059b32c212 | |||
| 2704ad9110 | |||
| 06d247c709 | |||
| 974fa1b8b1 | |||
| fb02744460 | |||
| 79732ab175 | |||
| f6dbb2f3e0 | |||
| fdd5b77bfd | |||
| cde105e7a8 | |||
| 1291e82bb4 | |||
| 19d15d9ff7 | |||
| 4e27804160 | |||
| bae80af1b4 | |||
| f9aa3d77cd | |||
| 5d47ea0918 | |||
| c03fa36257 | |||
| 1089fa0415 | |||
| 715786bbf9 | |||
| 218eca7c2b | |||
| 30fc791480 | |||
| e2d161dfdd | |||
| 23d48a7cf1 | |||
| cb18f2ef40 | |||
| dbe2ff52b2 | |||
| 9db40996cc | |||
| 9f201d6370 | |||
| 0e86466f99 | |||
| 32548bcb4a | |||
| 86c54c5acc | |||
| ae584332b3 | |||
| 1694c5bfe1 | |||
| cdfbb26c00 | |||
| 610c036ef1 | |||
| 2638e2acfa | |||
| 49bbea5aed | |||
| 5fccdc9fc7 | |||
| 664b2c36e8 | |||
| 964474a1b1 | |||
| ab15fc1575 | |||
| 99d392a4fb | |||
| ae9a696607 | |||
| bd51a0d35b | |||
| 8c191b10c2 | |||
| cb6a9253fe | |||
| 23f97ac49d | |||
| 021ab50fb1 | |||
| 3fe906f517 | |||
| a8d8a35cd3 | |||
| 9b77430d0d | |||
| 1045a43603 | |||
| 26af77cd1e | |||
| 25a9de301a | |||
| 162da4bd97 | |||
| 18563363da | |||
| d18891523d | |||
| bda7a488d4 | |||
| bde80e24e5 | |||
| 0114a3f317 | |||
| a4d4a3f545 | |||
| e8b4eb8972 | |||
| 7c18b95826 | |||
| 381afd6e10 | |||
| a8d5b14d1a | |||
| 39b4f861f3 | |||
| 13f89d5aa5 | |||
| f29cb81c7d | |||
| d034a61635 | |||
| 2eca96c80f | |||
| 057208e022 | |||
| 6db0f9855c | |||
| e0894d7b63 | |||
| cd3a58a4c2 | |||
| a5614f988f | |||
| 0f58efe030 | |||
| 4abb464345 | |||
| d1d3d420bf | |||
| 1d68cd288c | |||
| 0b8f8bc196 | |||
| 4d30ad1e7c | |||
| c90640e0e1 | |||
| 36b51cad33 | |||
| 776e61d1ec | |||
| 28aebb2772 | |||
| 6494f48136 | |||
| 15fae6cb60 | |||
| aacf1d20d3 | |||
| bcf7817127 | |||
| abf79ae24c | |||
| 922633ea9d | |||
| 49b40e3c90 | |||
| df3276fc87 | |||
| f8f986536b | |||
| 785635caef | |||
| ec27518eca | |||
| 8ee4ada38e | |||
| ab7b1d78bf | |||
| 2f44d1900e | |||
| cb535eef9d | |||
| d3ec6f75f4 | |||
| 9a8b2ae0b1 | |||
| eadb0e25da | |||
| ddd30ef304 | |||
| 2abf1100ee | |||
| bd2e34f3bd | |||
| a45c3a0049 | |||
| 52d1ee70a0 | |||
| 0a9fcab56f | |||
| 62fae6d182 | |||
| 3a5be7ad33 | |||
| f1e88d35ba | |||
| b737e87d9a | |||
| bd6e81f30b | |||
| f080147363 | |||
| 0051b605ae | |||
| 56e0e5ce65 | |||
| d065d5a8ec | |||
| cf79208055 | |||
| f276a8db42 |
@@ -0,0 +1,27 @@
|
|||||||
|
"on":
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- opencode-remote-voice
|
||||||
|
name: Deploy to apn-relay
|
||||||
|
jobs:
|
||||||
|
porter-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Set Github tag
|
||||||
|
id: vars
|
||||||
|
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||||
|
- name: Setup porter
|
||||||
|
uses: porter-dev/setup-porter@v0.1.0
|
||||||
|
- name: Deploy stack
|
||||||
|
timeout-minutes: 30
|
||||||
|
run: porter apply
|
||||||
|
env:
|
||||||
|
PORTER_APP_NAME: apn-relay
|
||||||
|
PORTER_CLUSTER: "5534"
|
||||||
|
PORTER_DEPLOYMENT_TARGET_ID: d60e67f5-b0a6-4275-8ed6-3cebaf092147
|
||||||
|
PORTER_HOST: https://dashboard.porter.run
|
||||||
|
PORTER_PROJECT: "18525"
|
||||||
|
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
|
||||||
|
PORTER_TOKEN: ${{ secrets.PORTER_APP_18525_975734319 }}
|
||||||
@@ -594,7 +594,6 @@ OPENCODE_DISABLE_CLAUDE_CODE
|
|||||||
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT
|
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT
|
||||||
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS
|
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS
|
||||||
OPENCODE_DISABLE_DEFAULT_PLUGINS
|
OPENCODE_DISABLE_DEFAULT_PLUGINS
|
||||||
OPENCODE_DISABLE_FILETIME_CHECK
|
|
||||||
OPENCODE_DISABLE_LSP_DOWNLOAD
|
OPENCODE_DISABLE_LSP_DOWNLOAD
|
||||||
OPENCODE_DISABLE_MODELS_FETCH
|
OPENCODE_DISABLE_MODELS_FETCH
|
||||||
OPENCODE_DISABLE_PRUNE
|
OPENCODE_DISABLE_PRUNE
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"provider": {
|
"provider": {},
|
||||||
"opencode": {
|
|
||||||
"options": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"permission": {
|
"permission": {
|
||||||
"edit": {
|
"edit": {
|
||||||
"packages/opencode/migration/*": "deny",
|
"packages/opencode/migration/*": "deny",
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
|
# OpenCode Monorepo Agent Guide
|
||||||
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
|
|
||||||
- The default branch in this repo is `dev`.
|
|
||||||
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
|
||||||
- Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility.
|
|
||||||
|
|
||||||
## Style Guide
|
This file is for coding agents working in `/Users/ryanvogel/dev/opencode`.
|
||||||
|
|
||||||
### General Principles
|
## Scope And Precedence
|
||||||
|
|
||||||
- Keep things in one function unless composable or reusable
|
- Keep things in one function unless composable or reusable
|
||||||
- Avoid `try`/`catch` where possible
|
- Avoid `try`/`catch` where possible
|
||||||
@@ -14,6 +10,7 @@
|
|||||||
- Use Bun APIs when possible, like `Bun.file()`
|
- Use Bun APIs when possible, like `Bun.file()`
|
||||||
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
|
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
|
||||||
- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
|
- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
|
||||||
|
- In `src/config`, follow the existing self-export pattern at the top of the file (for example `export * as ConfigAgent from "./agent"`) when adding a new config module.
|
||||||
|
|
||||||
Reduce total variable count by inlining when a value is only used once.
|
Reduce total variable count by inlining when a value is only used once.
|
||||||
|
|
||||||
@@ -55,48 +52,48 @@ else foo = 2
|
|||||||
|
|
||||||
### Control Flow
|
### Control Flow
|
||||||
|
|
||||||
Avoid `else` statements. Prefer early returns.
|
- Prefer early returns over nested `else` blocks.
|
||||||
|
- Keep functions focused; split only when it improves reuse or readability.
|
||||||
|
|
||||||
```ts
|
### Error Handling
|
||||||
// Good
|
|
||||||
function foo() {
|
|
||||||
if (condition) return 1
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bad
|
- Fail with actionable messages.
|
||||||
function foo() {
|
- Avoid swallowing errors silently.
|
||||||
if (condition) return 1
|
- Log enough context to debug production issues (IDs, env, status), but never secrets.
|
||||||
else return 2
|
- In UI code, degrade gracefully for missing capabilities.
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Schema Definitions (Drizzle)
|
### Data / DB
|
||||||
|
|
||||||
Use snake_case for field names so column names don't need to be redefined as strings.
|
- For Drizzle schema, use snake_case fields and columns.
|
||||||
|
- Keep migration and schema changes minimal and explicit.
|
||||||
|
- Follow package-specific DB guidance in `packages/opencode/AGENTS.md`.
|
||||||
|
|
||||||
```ts
|
### Testing Philosophy
|
||||||
// Good
|
|
||||||
const table = sqliteTable("session", {
|
|
||||||
id: text().primaryKey(),
|
|
||||||
project_id: text().notNull(),
|
|
||||||
created_at: integer().notNull(),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Bad
|
- Prefer testing real behavior over mocks.
|
||||||
const table = sqliteTable("session", {
|
- Add regression tests for bug fixes where practical.
|
||||||
id: text("id").primaryKey(),
|
- Keep fixtures small and focused.
|
||||||
projectID: text("project_id").notNull(),
|
|
||||||
createdAt: integer("created_at").notNull(),
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
## Agent Workflow Tips
|
||||||
|
|
||||||
- Avoid mocks as much as possible
|
- Read existing code paths before introducing new abstractions.
|
||||||
- Test actual implementation, do not duplicate logic into tests
|
- Match local patterns first; do not impose a new style per file.
|
||||||
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
|
- If a package has its own `AGENTS.md`, review it before editing.
|
||||||
|
- For OpenCode Effect services, follow `packages/opencode/AGENTS.md` strictly.
|
||||||
|
|
||||||
## Type Checking
|
## Known Operational Notes
|
||||||
|
|
||||||
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
|
- `packages/app/AGENTS.md` says: never restart app/server processes during that package's debugging workflow.
|
||||||
|
- `packages/app/AGENTS.md` also documents local backend+web split for UI work.
|
||||||
|
- `packages/opencode/AGENTS.md` contains mandatory Effect and database conventions.
|
||||||
|
|
||||||
|
## Regeneration / Special Scripts
|
||||||
|
|
||||||
|
- Regenerate JS SDK with: `./packages/sdk/js/script/build.ts`
|
||||||
|
|
||||||
|
## Quick Checklist Before Finishing
|
||||||
|
|
||||||
|
- Ran relevant package checks.
|
||||||
|
- Updated docs/config when behavior changed.
|
||||||
|
- Avoided committing unrelated files.
|
||||||
|
- Kept edits minimal and aligned with local conventions.
|
||||||
|
|||||||
@@ -236,7 +236,6 @@ new sst.cloudflare.x.SolidStart("Console", {
|
|||||||
SALESFORCE_INSTANCE_URL,
|
SALESFORCE_INSTANCE_URL,
|
||||||
ZEN_BLACK_PRICE,
|
ZEN_BLACK_PRICE,
|
||||||
ZEN_LITE_PRICE,
|
ZEN_LITE_PRICE,
|
||||||
new sst.Secret("ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES"),
|
|
||||||
new sst.Secret("ZEN_LIMITS"),
|
new sst.Secret("ZEN_LIMITS"),
|
||||||
new sst.Secret("ZEN_SESSION_SECRET"),
|
new sst.Secret("ZEN_SESSION_SECRET"),
|
||||||
...ZEN_MODELS,
|
...ZEN_MODELS,
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-tYAb5Mo39UW1VEejYuo0jW0jzH2OyY/HrqgiZL3rmjY=",
|
"x86_64-linux": "sha256-i9TxYwWkJAR+kW6pbvhgQbRW9UYPtdrPQAGic4zPoa4=",
|
||||||
"aarch64-linux": "sha256-3zGKV5UwokXpmY0nT1mry3IhNf2EQYLKT7ac+/trmQA=",
|
"aarch64-linux": "sha256-RYc/OYlETXUwkWBRDas+/P4cBW6zde4FqxxnMARu5vs=",
|
||||||
"aarch64-darwin": "sha256-oKXAut7eu/eW5a43OT8+aFuH1F1tuIldTs+7PUXSCv4=",
|
"aarch64-darwin": "sha256-jIhUOIRIQEa2WT62TVIedmRIhl/edhK8sbiAFvU3yCM=",
|
||||||
"x86_64-darwin": "sha256-Az+9X1scOEhw3aOO8laKJoZjiuz3qlLTIk1bx25P/z4="
|
"x86_64-darwin": "sha256-xLGzaX7OofFlZzVgpORJR5QXD2u+54hp+t3cCfUtO84="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-17
@@ -7,6 +7,7 @@
|
|||||||
sysctl,
|
sysctl,
|
||||||
makeBinaryWrapper,
|
makeBinaryWrapper,
|
||||||
models-dev,
|
models-dev,
|
||||||
|
ripgrep,
|
||||||
installShellFiles,
|
installShellFiles,
|
||||||
versionCheckHook,
|
versionCheckHook,
|
||||||
writableTmpDirAsHomeHook,
|
writableTmpDirAsHomeHook,
|
||||||
@@ -51,25 +52,25 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
|||||||
runHook postBuild
|
runHook postBuild
|
||||||
'';
|
'';
|
||||||
|
|
||||||
installPhase =
|
installPhase = ''
|
||||||
''
|
runHook preInstall
|
||||||
runHook preInstall
|
|
||||||
|
|
||||||
install -Dm755 dist/opencode-*/bin/opencode $out/bin/opencode
|
install -Dm755 dist/opencode-*/bin/opencode $out/bin/opencode
|
||||||
install -Dm644 schema.json $out/share/opencode/schema.json
|
install -Dm644 schema.json $out/share/opencode/schema.json
|
||||||
''
|
|
||||||
# bun runs sysctl to detect if dunning on rosetta2
|
wrapProgram $out/bin/opencode \
|
||||||
+ lib.optionalString stdenvNoCC.hostPlatform.isDarwin ''
|
--prefix PATH : ${
|
||||||
wrapProgram $out/bin/opencode \
|
lib.makeBinPath (
|
||||||
--prefix PATH : ${
|
[
|
||||||
lib.makeBinPath [
|
ripgrep
|
||||||
sysctl
|
|
||||||
]
|
]
|
||||||
}
|
# bun runs sysctl to detect if dunning on rosetta2
|
||||||
''
|
++ lib.optional stdenvNoCC.hostPlatform.isDarwin sysctl
|
||||||
+ ''
|
)
|
||||||
runHook postInstall
|
}
|
||||||
'';
|
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
|
||||||
postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) ''
|
postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) ''
|
||||||
# trick yargs into also generating zsh completions
|
# trick yargs into also generating zsh completions
|
||||||
|
|||||||
+4
-2
@@ -7,7 +7,7 @@
|
|||||||
"packageManager": "bun@1.3.11",
|
"packageManager": "bun@1.3.11",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"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-electron dev",
|
||||||
"dev:web": "bun --cwd packages/app dev",
|
"dev:web": "bun --cwd packages/app dev",
|
||||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||||
@@ -34,6 +34,8 @@
|
|||||||
"@types/cross-spawn": "6.0.6",
|
"@types/cross-spawn": "6.0.6",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
|
"@opentui/core": "0.1.99",
|
||||||
|
"@opentui/solid": "0.1.99",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
"@types/luxon": "3.7.1",
|
"@types/luxon": "3.7.1",
|
||||||
@@ -51,7 +53,7 @@
|
|||||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||||
"effect": "4.0.0-beta.48",
|
"effect": "4.0.0-beta.48",
|
||||||
"ai": "6.0.158",
|
"ai": "6.0.168",
|
||||||
"cross-spawn": "7.0.6",
|
"cross-spawn": "7.0.6",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
PORT=8787
|
||||||
|
|
||||||
|
DATABASE_HOST=
|
||||||
|
DATABASE_USERNAME=
|
||||||
|
DATABASE_PASSWORD=
|
||||||
|
DATABASE_NAME=main
|
||||||
|
|
||||||
|
APNS_TEAM_ID=
|
||||||
|
APNS_KEY_ID=
|
||||||
|
APNS_PRIVATE_KEY=
|
||||||
|
APNS_DEFAULT_BUNDLE_ID=com.anomalyco.mobilevoice
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# apn-relay Agent Guide
|
||||||
|
|
||||||
|
This file defines package-specific guidance for agents working in `packages/apn-relay`.
|
||||||
|
|
||||||
|
## Scope And Precedence
|
||||||
|
|
||||||
|
- Follow root `AGENTS.md` first.
|
||||||
|
- This file provides stricter package-level conventions for relay service work.
|
||||||
|
- If future local guides are added, closest guide wins.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
- Minimal APNs relay service (Hono + Bun + PlanetScale via Drizzle).
|
||||||
|
- Core routes:
|
||||||
|
- `GET /health`
|
||||||
|
- `GET /`
|
||||||
|
- `POST /v1/device/register`
|
||||||
|
- `POST /v1/device/unregister`
|
||||||
|
- `POST /v1/event`
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Run all commands from `packages/apn-relay`.
|
||||||
|
|
||||||
|
- Install deps: `bun install`
|
||||||
|
- Start relay locally: `bun run dev`
|
||||||
|
- Typecheck: `bun run typecheck`
|
||||||
|
- DB connectivity check: `bun run db:check`
|
||||||
|
|
||||||
|
## Build / Test Expectations
|
||||||
|
|
||||||
|
- There is no dedicated package test script currently.
|
||||||
|
- Required validation for behavior changes:
|
||||||
|
- `bun run typecheck`
|
||||||
|
- `bun run db:check` when DB/env changes are involved
|
||||||
|
- manual endpoint verification against `/health`, `/v1/device/register`, `/v1/event`
|
||||||
|
|
||||||
|
## Single-Test Guidance
|
||||||
|
|
||||||
|
- No single-test command exists for this package today.
|
||||||
|
- For focused checks, run endpoint-level manual tests against a local dev server.
|
||||||
|
|
||||||
|
## Code Style Guidelines
|
||||||
|
|
||||||
|
### Formatting / Structure
|
||||||
|
|
||||||
|
- Keep handlers compact and explicit.
|
||||||
|
- Prefer small local helpers for repeated route logic.
|
||||||
|
- Avoid broad refactors when a targeted fix is enough.
|
||||||
|
|
||||||
|
### Types / Validation
|
||||||
|
|
||||||
|
- Validate request bodies with `zod` at route boundaries.
|
||||||
|
- Keep payload and DB row shapes explicit and close to usage.
|
||||||
|
- Avoid `any`; narrow unknown input immediately after parsing.
|
||||||
|
|
||||||
|
### Naming
|
||||||
|
|
||||||
|
- Follow existing concise naming in this package (`reg`, `unreg`, `evt`, `row`, `key`).
|
||||||
|
- For DB columns, keep snake_case alignment with schema.
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- Return clear JSON errors for invalid input.
|
||||||
|
- Keep handler failures observable via `app.onError` and structured logs.
|
||||||
|
- Do not leak secrets in responses or logs.
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
- Log delivery lifecycle at key checkpoints:
|
||||||
|
- registration/unregistration attempts
|
||||||
|
- event fanout start/end
|
||||||
|
- APNs send failures and retries
|
||||||
|
- Mask sensitive values; prefer token suffixes and metadata.
|
||||||
|
|
||||||
|
### APNs Environment Rules
|
||||||
|
|
||||||
|
- Keep APNs env explicit per registration (`sandbox` / `production`).
|
||||||
|
- For `BadEnvironmentKeyInToken`, retry once with flipped env and persist correction.
|
||||||
|
- Avoid infinite retry loops; one retry max per delivery attempt.
|
||||||
|
|
||||||
|
## Database Conventions
|
||||||
|
|
||||||
|
- Schema is in `src/schema.sql.ts`.
|
||||||
|
- Keep table/column names snake_case.
|
||||||
|
- Maintain index naming consistency with existing schema.
|
||||||
|
- For upserts, update only fields required by current behavior.
|
||||||
|
|
||||||
|
## API Behavior Expectations
|
||||||
|
|
||||||
|
- `register`/`unregister` must be idempotent.
|
||||||
|
- `event` should return success envelope even when no devices are registered.
|
||||||
|
- Delivery logs should capture per-attempt result and error payload.
|
||||||
|
|
||||||
|
## Operational Notes
|
||||||
|
|
||||||
|
- Ensure `APNS_PRIVATE_KEY` supports escaped newline format (`\n`) and raw multiline.
|
||||||
|
- Validate that `APNS_DEFAULT_BUNDLE_ID` matches mobile app bundle identifier.
|
||||||
|
- Avoid coupling route behavior to deployment platform specifics.
|
||||||
|
|
||||||
|
## Before Finishing
|
||||||
|
|
||||||
|
- Run `bun run typecheck`.
|
||||||
|
- If DB/env behavior changed, run `bun run db:check`.
|
||||||
|
- Manually exercise affected endpoints.
|
||||||
|
- Confirm logs are useful and secret-safe.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
FROM oven/bun:1.3.11-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json ./
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY drizzle.config.ts ./
|
||||||
|
RUN bun install --production
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
EXPOSE 8787
|
||||||
|
|
||||||
|
CMD ["bun", "run", "src/index.ts"]
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# APN Relay
|
||||||
|
|
||||||
|
Minimal APNs relay for OpenCode mobile background notifications.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- Registers iOS device tokens for a shared secret.
|
||||||
|
- Receives OpenCode event posts (`complete`, `permission`, `error`).
|
||||||
|
- Sends APNs notifications to mapped devices.
|
||||||
|
- Stores delivery rows in PlanetScale.
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
- `GET /health`
|
||||||
|
- `GET /` (simple dashboard)
|
||||||
|
- `POST /v1/device/register`
|
||||||
|
- `POST /v1/device/unregister`
|
||||||
|
- `POST /v1/event`
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
Use `.env.example` as a starting point.
|
||||||
|
|
||||||
|
- `DATABASE_HOST`
|
||||||
|
- `DATABASE_USERNAME`
|
||||||
|
- `DATABASE_PASSWORD`
|
||||||
|
- `APNS_TEAM_ID`
|
||||||
|
- `APNS_KEY_ID`
|
||||||
|
- `APNS_PRIVATE_KEY`
|
||||||
|
- `APNS_DEFAULT_BUNDLE_ID`
|
||||||
|
|
||||||
|
## Run locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
bun run src/index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
Build from this directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t apn-relay .
|
||||||
|
docker run --rm -p 8787:8787 --env-file .env apn-relay
|
||||||
|
```
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from "drizzle-kit"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
out: "./migration",
|
||||||
|
strict: true,
|
||||||
|
schema: ["./src/**/*.sql.ts"],
|
||||||
|
dialect: "mysql",
|
||||||
|
dbCredentials: {
|
||||||
|
host: process.env.DATABASE_HOST ?? "",
|
||||||
|
user: process.env.DATABASE_USERNAME ?? "",
|
||||||
|
password: process.env.DATABASE_PASSWORD ?? "",
|
||||||
|
database: process.env.DATABASE_NAME ?? "main",
|
||||||
|
ssl: {
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
|
"name": "@opencode-ai/apn-relay",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun run src/index.ts",
|
||||||
|
"db:check": "bun run --env-file .env src/check.ts",
|
||||||
|
"typecheck": "tsgo --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@planetscale/database": "1.19.0",
|
||||||
|
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||||
|
"hono": "4.10.7",
|
||||||
|
"jose": "6.0.11",
|
||||||
|
"zod": "4.1.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tsconfig/bun": "1.0.9",
|
||||||
|
"@types/bun": "1.3.11",
|
||||||
|
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||||
|
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||||
|
"typescript": "5.8.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { connect } from "node:http2"
|
||||||
|
import { SignJWT, importPKCS8 } from "jose"
|
||||||
|
import { env } from "./env"
|
||||||
|
|
||||||
|
export type PushEnv = "sandbox" | "production"
|
||||||
|
|
||||||
|
type PushInput = {
|
||||||
|
token: string
|
||||||
|
bundle: string
|
||||||
|
env: PushEnv
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
data: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
type PushResult = {
|
||||||
|
ok: boolean
|
||||||
|
code: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenSuffix(input: string) {
|
||||||
|
return input.length > 8 ? input.slice(-8) : input
|
||||||
|
}
|
||||||
|
|
||||||
|
let jwt = ""
|
||||||
|
let exp = 0
|
||||||
|
let pk: Awaited<ReturnType<typeof importPKCS8>> | undefined
|
||||||
|
|
||||||
|
function host(input: PushEnv) {
|
||||||
|
if (input === "sandbox") return "api.sandbox.push.apple.com"
|
||||||
|
return "api.push.apple.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
function key() {
|
||||||
|
if (env.APNS_PRIVATE_KEY.includes("\\n")) return env.APNS_PRIVATE_KEY.replace(/\\n/g, "\n")
|
||||||
|
return env.APNS_PRIVATE_KEY
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sign() {
|
||||||
|
if (!pk) pk = await importPKCS8(key(), "ES256")
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
if (jwt && now < exp) return jwt
|
||||||
|
jwt = await new SignJWT({})
|
||||||
|
.setProtectedHeader({ alg: "ES256", kid: env.APNS_KEY_ID })
|
||||||
|
.setIssuer(env.APNS_TEAM_ID)
|
||||||
|
.setIssuedAt(now)
|
||||||
|
.sign(pk)
|
||||||
|
exp = now + 50 * 60
|
||||||
|
return jwt
|
||||||
|
}
|
||||||
|
|
||||||
|
function post(input: {
|
||||||
|
host: string
|
||||||
|
token: string
|
||||||
|
auth: string
|
||||||
|
bundle: string
|
||||||
|
payload: string
|
||||||
|
}): Promise<{ code: number; body: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const cli = connect(`https://${input.host}`)
|
||||||
|
let done = false
|
||||||
|
let code = 0
|
||||||
|
let body = ""
|
||||||
|
|
||||||
|
const stop = (fn: () => void) => {
|
||||||
|
if (done) return
|
||||||
|
done = true
|
||||||
|
fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
cli.on("error", (err) => {
|
||||||
|
stop(() => reject(err))
|
||||||
|
cli.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
const req = cli.request({
|
||||||
|
":method": "POST",
|
||||||
|
":path": `/3/device/${input.token}`,
|
||||||
|
authorization: `bearer ${input.auth}`,
|
||||||
|
"apns-topic": input.bundle,
|
||||||
|
"apns-push-type": "alert",
|
||||||
|
"apns-priority": "10",
|
||||||
|
"content-type": "application/json",
|
||||||
|
})
|
||||||
|
|
||||||
|
req.setEncoding("utf8")
|
||||||
|
req.on("response", (headers) => {
|
||||||
|
code = Number(headers[":status"] ?? 0)
|
||||||
|
})
|
||||||
|
req.on("data", (chunk) => {
|
||||||
|
body += chunk
|
||||||
|
})
|
||||||
|
req.on("end", () => {
|
||||||
|
stop(() => resolve({ code, body }))
|
||||||
|
cli.close()
|
||||||
|
})
|
||||||
|
req.on("error", (err) => {
|
||||||
|
stop(() => reject(err))
|
||||||
|
cli.close()
|
||||||
|
})
|
||||||
|
req.end(input.payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function send(input: PushInput): Promise<PushResult> {
|
||||||
|
const apnsHost = host(input.env)
|
||||||
|
const suffix = tokenSuffix(input.token)
|
||||||
|
|
||||||
|
console.log("[ APN RELAY ] push:start", {
|
||||||
|
env: input.env,
|
||||||
|
host: apnsHost,
|
||||||
|
bundle: input.bundle,
|
||||||
|
tokenSuffix: suffix,
|
||||||
|
})
|
||||||
|
|
||||||
|
const auth = await sign().catch((err) => {
|
||||||
|
return `error:${String(err)}`
|
||||||
|
})
|
||||||
|
if (auth.startsWith("error:")) {
|
||||||
|
console.log("[ APN RELAY ] push:auth-failed", {
|
||||||
|
env: input.env,
|
||||||
|
host: apnsHost,
|
||||||
|
bundle: input.bundle,
|
||||||
|
tokenSuffix: suffix,
|
||||||
|
error: auth,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: 0,
|
||||||
|
error: auth,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
aps: {
|
||||||
|
alert: {
|
||||||
|
title: input.title,
|
||||||
|
body: input.body,
|
||||||
|
},
|
||||||
|
sound: "alert.wav",
|
||||||
|
},
|
||||||
|
...input.data,
|
||||||
|
})
|
||||||
|
|
||||||
|
const out = await post({
|
||||||
|
host: apnsHost,
|
||||||
|
token: input.token,
|
||||||
|
auth,
|
||||||
|
bundle: input.bundle,
|
||||||
|
payload,
|
||||||
|
}).catch((err) => ({
|
||||||
|
code: 0,
|
||||||
|
body: String(err),
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (out.code === 200) {
|
||||||
|
console.log("[ APN RELAY ] push:sent", {
|
||||||
|
env: input.env,
|
||||||
|
host: apnsHost,
|
||||||
|
bundle: input.bundle,
|
||||||
|
tokenSuffix: suffix,
|
||||||
|
code: out.code,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
code: 200,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[ APN RELAY ] push:failed", {
|
||||||
|
env: input.env,
|
||||||
|
host: apnsHost,
|
||||||
|
bundle: input.bundle,
|
||||||
|
tokenSuffix: suffix,
|
||||||
|
code: out.code,
|
||||||
|
error: out.body,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: out.code,
|
||||||
|
error: out.body,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { sql } from "drizzle-orm"
|
||||||
|
import { db } from "./db"
|
||||||
|
import { env } from "./env"
|
||||||
|
import { delivery_log, device_registration } from "./schema.sql"
|
||||||
|
import { setup } from "./setup"
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log(`[apn-relay] DB host: ${env.DATABASE_HOST}`)
|
||||||
|
|
||||||
|
await db.execute(sql`SELECT 1`)
|
||||||
|
console.log("[apn-relay] DB connection OK")
|
||||||
|
|
||||||
|
await setup()
|
||||||
|
console.log("[apn-relay] Setup migration OK")
|
||||||
|
|
||||||
|
const [a] = await db.select({ value: sql<number>`count(*)` }).from(device_registration)
|
||||||
|
const [b] = await db.select({ value: sql<number>`count(*)` }).from(delivery_log)
|
||||||
|
|
||||||
|
console.log(`[apn-relay] device_registration rows: ${Number(a?.value ?? 0)}`)
|
||||||
|
console.log(`[apn-relay] delivery_log rows: ${Number(b?.value ?? 0)}`)
|
||||||
|
console.log("[apn-relay] DB check passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((err) => {
|
||||||
|
console.error("[apn-relay] DB check failed")
|
||||||
|
console.error(err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Client } from "@planetscale/database"
|
||||||
|
import { drizzle } from "drizzle-orm/planetscale-serverless"
|
||||||
|
import { env } from "./env"
|
||||||
|
|
||||||
|
const client = new Client({
|
||||||
|
host: env.DATABASE_HOST,
|
||||||
|
username: env.DATABASE_USERNAME,
|
||||||
|
password: env.DATABASE_PASSWORD,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const db = drizzle({ client })
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
const bad = new Set(["undefined", "null"])
|
||||||
|
const txt = z
|
||||||
|
.string()
|
||||||
|
.transform((input) => input.trim())
|
||||||
|
.refine((input) => input.length > 0 && !bad.has(input.toLowerCase()))
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
PORT: z.coerce.number().int().positive().default(8787),
|
||||||
|
DATABASE_HOST: txt,
|
||||||
|
DATABASE_USERNAME: txt,
|
||||||
|
DATABASE_PASSWORD: txt,
|
||||||
|
APNS_TEAM_ID: txt,
|
||||||
|
APNS_KEY_ID: txt,
|
||||||
|
APNS_PRIVATE_KEY: txt,
|
||||||
|
APNS_DEFAULT_BUNDLE_ID: txt,
|
||||||
|
})
|
||||||
|
|
||||||
|
const req = [
|
||||||
|
"DATABASE_HOST",
|
||||||
|
"DATABASE_USERNAME",
|
||||||
|
"DATABASE_PASSWORD",
|
||||||
|
"APNS_TEAM_ID",
|
||||||
|
"APNS_KEY_ID",
|
||||||
|
"APNS_PRIVATE_KEY",
|
||||||
|
"APNS_DEFAULT_BUNDLE_ID",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const out = schema.safeParse(process.env)
|
||||||
|
|
||||||
|
if (!out.success) {
|
||||||
|
const miss = req.filter((key) => !process.env[key]?.trim())
|
||||||
|
const bad = out.error.issues
|
||||||
|
.map((item) => item.path[0])
|
||||||
|
.filter((key): key is string => typeof key === "string")
|
||||||
|
.filter((key) => !miss.includes(key as (typeof req)[number]))
|
||||||
|
|
||||||
|
console.error("[apn-relay] Invalid startup configuration")
|
||||||
|
if (miss.length) console.error(`[apn-relay] Missing required env vars: ${miss.join(", ")}`)
|
||||||
|
if (bad.length) console.error(`[apn-relay] Invalid env vars: ${Array.from(new Set(bad)).join(", ")}`)
|
||||||
|
console.error("[apn-relay] Check .env.example and restart")
|
||||||
|
|
||||||
|
throw new Error("Startup configuration invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
export const env = out.data
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createHash } from "node:crypto"
|
||||||
|
|
||||||
|
export function hash(input: string) {
|
||||||
|
return createHash("sha256").update(input).digest("hex")
|
||||||
|
}
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
import { randomUUID } from "node:crypto"
|
||||||
|
import { and, desc, eq, sql } from "drizzle-orm"
|
||||||
|
import { Hono } from "hono"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { send } from "./apns"
|
||||||
|
import { db } from "./db"
|
||||||
|
import { env } from "./env"
|
||||||
|
import { hash } from "./hash"
|
||||||
|
import { delivery_log, device_registration } from "./schema.sql"
|
||||||
|
import { setup } from "./setup"
|
||||||
|
|
||||||
|
function bad(input?: string) {
|
||||||
|
if (!input) return false
|
||||||
|
return input.includes("BadEnvironmentKeyInToken")
|
||||||
|
}
|
||||||
|
|
||||||
|
function flip(input: "sandbox" | "production") {
|
||||||
|
if (input === "sandbox") return "production"
|
||||||
|
return "sandbox"
|
||||||
|
}
|
||||||
|
|
||||||
|
function tail(input: string) {
|
||||||
|
return input.slice(-8)
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(input: unknown) {
|
||||||
|
return String(input ?? "")
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(input: number) {
|
||||||
|
return new Date(input).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const reg = z.object({
|
||||||
|
secret: z.string().min(1),
|
||||||
|
deviceToken: z.string().min(1),
|
||||||
|
bundleId: z.string().min(1).optional(),
|
||||||
|
apnsEnv: z.enum(["sandbox", "production"]).default("production"),
|
||||||
|
})
|
||||||
|
|
||||||
|
const unreg = z.object({
|
||||||
|
secret: z.string().min(1),
|
||||||
|
deviceToken: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
const evt = z.object({
|
||||||
|
secret: z.string().min(1),
|
||||||
|
serverID: z.string().min(1).optional(),
|
||||||
|
eventType: z.enum(["complete", "permission", "error"]),
|
||||||
|
sessionID: z.string().min(1),
|
||||||
|
title: z.string().min(1).optional(),
|
||||||
|
body: z.string().min(1).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
function title(input: z.infer<typeof evt>["eventType"]) {
|
||||||
|
if (input === "complete") return "Session complete"
|
||||||
|
if (input === "permission") return "Action needed"
|
||||||
|
return "Session error"
|
||||||
|
}
|
||||||
|
|
||||||
|
function body(input: z.infer<typeof evt>["eventType"]) {
|
||||||
|
if (input === "complete") return "OpenCode finished your session."
|
||||||
|
if (input === "permission") return "OpenCode needs your permission decision."
|
||||||
|
return "OpenCode reported an error for your session."
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = new Hono()
|
||||||
|
|
||||||
|
app.onError((err, c) => {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: err.message,
|
||||||
|
},
|
||||||
|
500,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.notFound((c) => {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: "Not found",
|
||||||
|
},
|
||||||
|
404,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/health", async (c) => {
|
||||||
|
const [a] = await db.select({ value: sql<number>`count(*)` }).from(device_registration)
|
||||||
|
const [b] = await db.select({ value: sql<number>`count(*)` }).from(delivery_log)
|
||||||
|
return c.json({
|
||||||
|
ok: true,
|
||||||
|
devices: Number(a?.value ?? 0),
|
||||||
|
deliveries: Number(b?.value ?? 0),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/", async (c) => {
|
||||||
|
const [a] = await db.select({ value: sql<number>`count(*)` }).from(device_registration)
|
||||||
|
const [b] = await db.select({ value: sql<number>`count(*)` }).from(delivery_log)
|
||||||
|
const devices = await db.select().from(device_registration).orderBy(desc(device_registration.updated_at)).limit(100)
|
||||||
|
const byBundle = await db
|
||||||
|
.select({
|
||||||
|
bundle: device_registration.bundle_id,
|
||||||
|
env: device_registration.apns_env,
|
||||||
|
value: sql<number>`count(*)`,
|
||||||
|
})
|
||||||
|
.from(device_registration)
|
||||||
|
.groupBy(device_registration.bundle_id, device_registration.apns_env)
|
||||||
|
.orderBy(desc(sql<number>`count(*)`))
|
||||||
|
const rows = await db.select().from(delivery_log).orderBy(desc(delivery_log.created_at)).limit(20)
|
||||||
|
|
||||||
|
const html = `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>APN Relay</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: ui-sans-serif, system-ui, sans-serif; margin: 24px; color: #111827; }
|
||||||
|
h1 { margin: 0 0 12px 0; }
|
||||||
|
h2 { margin: 22px 0 10px 0; font-size: 16px; }
|
||||||
|
.stats { display: flex; gap: 16px; margin: 0 0 18px 0; }
|
||||||
|
.card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 10px 12px; min-width: 160px; }
|
||||||
|
.muted { color: #6b7280; font-size: 12px; }
|
||||||
|
.small { font-size: 11px; color: #6b7280; }
|
||||||
|
table { border-collapse: collapse; width: 100%; }
|
||||||
|
th, td { border: 1px solid #e5e7eb; text-align: left; padding: 8px; font-size: 12px; }
|
||||||
|
th { background: #f9fafb; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>APN Relay</h1>
|
||||||
|
<p class="muted">MVP dashboard</p>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="card">
|
||||||
|
<div class="muted">Registered devices</div>
|
||||||
|
<div>${Number(a?.value ?? 0)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="muted">Delivery log rows</div>
|
||||||
|
<div>${Number(b?.value ?? 0)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2>Registered devices</h2>
|
||||||
|
<p class="small">Most recent 100 registrations. Token values are masked to suffix only.</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>updated</th>
|
||||||
|
<th>created</th>
|
||||||
|
<th>token suffix</th>
|
||||||
|
<th>env</th>
|
||||||
|
<th>bundle</th>
|
||||||
|
<th>secret hash</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${
|
||||||
|
devices.length
|
||||||
|
? devices
|
||||||
|
.map(
|
||||||
|
(row) => `<tr>
|
||||||
|
<td>${esc(fmt(row.updated_at))}</td>
|
||||||
|
<td>${esc(fmt(row.created_at))}</td>
|
||||||
|
<td>${esc(tail(row.device_token))}</td>
|
||||||
|
<td>${esc(row.apns_env)}</td>
|
||||||
|
<td>${esc(row.bundle_id)}</td>
|
||||||
|
<td>${esc(`${row.secret_hash.slice(0, 12)}…`)}</td>
|
||||||
|
</tr>`,
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
: `<tr><td colspan="6" class="muted">No devices registered.</td></tr>`
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<h2>Bundle breakdown</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>bundle</th>
|
||||||
|
<th>env</th>
|
||||||
|
<th>count</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${
|
||||||
|
byBundle.length
|
||||||
|
? byBundle
|
||||||
|
.map(
|
||||||
|
(row) => `<tr>
|
||||||
|
<td>${esc(row.bundle)}</td>
|
||||||
|
<td>${esc(row.env)}</td>
|
||||||
|
<td>${esc(Number(row.value ?? 0))}</td>
|
||||||
|
</tr>`,
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
: `<tr><td colspan="3" class="muted">No device data.</td></tr>`
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<h2>Recent deliveries</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>time</th>
|
||||||
|
<th>event</th>
|
||||||
|
<th>session</th>
|
||||||
|
<th>status</th>
|
||||||
|
<th>error</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${rows
|
||||||
|
.map(
|
||||||
|
(row) => `<tr>
|
||||||
|
<td>${esc(fmt(row.created_at))}</td>
|
||||||
|
<td>${esc(row.event_type)}</td>
|
||||||
|
<td>${esc(row.session_id)}</td>
|
||||||
|
<td>${esc(row.status)}</td>
|
||||||
|
<td>${esc(row.error ?? "")}</td>
|
||||||
|
</tr>`,
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
return c.html(html)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/v1/device/register", async (c) => {
|
||||||
|
const raw = await c.req.json().catch(() => undefined)
|
||||||
|
const check = reg.safeParse(raw)
|
||||||
|
if (!check.success) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: "Invalid request body",
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const key = hash(check.data.secret)
|
||||||
|
const row = {
|
||||||
|
id: randomUUID(),
|
||||||
|
secret_hash: key,
|
||||||
|
device_token: check.data.deviceToken,
|
||||||
|
bundle_id: check.data.bundleId ?? env.APNS_DEFAULT_BUNDLE_ID,
|
||||||
|
apns_env: check.data.apnsEnv,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[relay] register", {
|
||||||
|
token: tail(row.device_token),
|
||||||
|
env: row.apns_env,
|
||||||
|
bundle: row.bundle_id,
|
||||||
|
secretHash: `${key.slice(0, 12)}...`,
|
||||||
|
})
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(device_registration)
|
||||||
|
.values(row)
|
||||||
|
.onDuplicateKeyUpdate({
|
||||||
|
set: {
|
||||||
|
bundle_id: row.bundle_id,
|
||||||
|
apns_env: row.apns_env,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return c.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/v1/device/unregister", async (c) => {
|
||||||
|
const raw = await c.req.json().catch(() => undefined)
|
||||||
|
const check = unreg.safeParse(raw)
|
||||||
|
if (!check.success) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: "Invalid request body",
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = hash(check.data.secret)
|
||||||
|
|
||||||
|
console.log("[relay] unregister", {
|
||||||
|
token: tail(check.data.deviceToken),
|
||||||
|
secretHash: `${key.slice(0, 12)}...`,
|
||||||
|
})
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(device_registration)
|
||||||
|
.where(and(eq(device_registration.secret_hash, key), eq(device_registration.device_token, check.data.deviceToken)))
|
||||||
|
|
||||||
|
return c.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/v1/event", async (c) => {
|
||||||
|
const raw = await c.req.json().catch(() => undefined)
|
||||||
|
const check = evt.safeParse(raw)
|
||||||
|
if (!check.success) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: "Invalid request body",
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = hash(check.data.secret)
|
||||||
|
const list = await db.select().from(device_registration).where(eq(device_registration.secret_hash, key))
|
||||||
|
console.log("[relay] event", {
|
||||||
|
type: check.data.eventType,
|
||||||
|
serverID: check.data.serverID,
|
||||||
|
session: check.data.sessionID,
|
||||||
|
secretHash: `${key.slice(0, 12)}...`,
|
||||||
|
devices: list.length,
|
||||||
|
})
|
||||||
|
if (!list.length) {
|
||||||
|
const [total] = await db.select({ value: sql<number>`count(*)` }).from(device_registration)
|
||||||
|
console.log("[relay] event:no-matching-devices", {
|
||||||
|
type: check.data.eventType,
|
||||||
|
serverID: check.data.serverID,
|
||||||
|
session: check.data.sessionID,
|
||||||
|
secretHash: `${key.slice(0, 12)}...`,
|
||||||
|
totalDevices: Number(total?.value ?? 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
ok: true,
|
||||||
|
sent: 0,
|
||||||
|
failed: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = await Promise.all(
|
||||||
|
list.map(async (row) => {
|
||||||
|
const env = row.apns_env === "sandbox" ? "sandbox" : "production"
|
||||||
|
const payload = {
|
||||||
|
token: row.device_token,
|
||||||
|
bundle: row.bundle_id,
|
||||||
|
title: check.data.title ?? title(check.data.eventType),
|
||||||
|
body: check.data.body ?? body(check.data.eventType),
|
||||||
|
data: {
|
||||||
|
serverID: check.data.serverID,
|
||||||
|
eventType: check.data.eventType,
|
||||||
|
sessionID: check.data.sessionID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const first = await send({ ...payload, env })
|
||||||
|
if (first.ok || !bad(first.error)) {
|
||||||
|
if (!first.ok) {
|
||||||
|
console.log("[relay] send:error", {
|
||||||
|
token: tail(row.device_token),
|
||||||
|
env,
|
||||||
|
error: first.error,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return first
|
||||||
|
}
|
||||||
|
|
||||||
|
const alt = flip(env)
|
||||||
|
console.log("[relay] send:retry-env", {
|
||||||
|
token: tail(row.device_token),
|
||||||
|
from: env,
|
||||||
|
to: alt,
|
||||||
|
})
|
||||||
|
const second = await send({ ...payload, env: alt })
|
||||||
|
if (!second.ok) {
|
||||||
|
console.log("[relay] send:error", {
|
||||||
|
token: tail(row.device_token),
|
||||||
|
env: alt,
|
||||||
|
error: second.error,
|
||||||
|
})
|
||||||
|
return second
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(device_registration)
|
||||||
|
.set({ apns_env: alt, updated_at: Date.now() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(device_registration.secret_hash, row.secret_hash),
|
||||||
|
eq(device_registration.device_token, row.device_token),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log("[relay] send:env-updated", {
|
||||||
|
token: tail(row.device_token),
|
||||||
|
env: alt,
|
||||||
|
})
|
||||||
|
return second
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
await db.insert(delivery_log).values(
|
||||||
|
out.map((item) => ({
|
||||||
|
id: randomUUID(),
|
||||||
|
secret_hash: key,
|
||||||
|
event_type: check.data.eventType,
|
||||||
|
session_id: check.data.sessionID,
|
||||||
|
status: item.ok ? "sent" : "failed",
|
||||||
|
error: item.error,
|
||||||
|
created_at: now,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const sent = out.filter((item) => item.ok).length
|
||||||
|
console.log("[relay] event:done", {
|
||||||
|
type: check.data.eventType,
|
||||||
|
session: check.data.sessionID,
|
||||||
|
sent,
|
||||||
|
failed: out.length - sent,
|
||||||
|
})
|
||||||
|
return c.json({
|
||||||
|
ok: true,
|
||||||
|
sent,
|
||||||
|
failed: out.length - sent,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await setup()
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
Bun.serve({
|
||||||
|
port: env.PORT,
|
||||||
|
fetch: app.fetch,
|
||||||
|
})
|
||||||
|
console.log(`apn-relay listening on http://0.0.0.0:${env.PORT}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { app }
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { bigint, index, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
|
||||||
|
|
||||||
|
export const device_registration = mysqlTable(
|
||||||
|
"device_registration",
|
||||||
|
{
|
||||||
|
id: varchar("id", { length: 36 }).primaryKey(),
|
||||||
|
secret_hash: varchar("secret_hash", { length: 64 }).notNull(),
|
||||||
|
device_token: varchar("device_token", { length: 255 }).notNull(),
|
||||||
|
bundle_id: varchar("bundle_id", { length: 255 }).notNull(),
|
||||||
|
apns_env: varchar("apns_env", { length: 16 }).notNull().default("production"),
|
||||||
|
created_at: bigint("created_at", { mode: "number" }).notNull(),
|
||||||
|
updated_at: bigint("updated_at", { mode: "number" }).notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("device_registration_secret_token_idx").on(table.secret_hash, table.device_token),
|
||||||
|
index("device_registration_secret_hash_idx").on(table.secret_hash),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
export const delivery_log = mysqlTable(
|
||||||
|
"delivery_log",
|
||||||
|
{
|
||||||
|
id: varchar("id", { length: 36 }).primaryKey(),
|
||||||
|
secret_hash: varchar("secret_hash", { length: 64 }).notNull(),
|
||||||
|
event_type: varchar("event_type", { length: 32 }).notNull(),
|
||||||
|
session_id: varchar("session_id", { length: 255 }).notNull(),
|
||||||
|
status: varchar("status", { length: 16 }).notNull(),
|
||||||
|
error: varchar("error", { length: 1024 }),
|
||||||
|
created_at: bigint("created_at", { mode: "number" }).notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("delivery_log_secret_hash_idx").on(table.secret_hash),
|
||||||
|
index("delivery_log_created_at_idx").on(table.created_at),
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { sql } from "drizzle-orm"
|
||||||
|
import { db } from "./db"
|
||||||
|
|
||||||
|
export async function setup() {
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS device_registration (
|
||||||
|
id varchar(36) NOT NULL,
|
||||||
|
secret_hash varchar(64) NOT NULL,
|
||||||
|
device_token varchar(255) NOT NULL,
|
||||||
|
bundle_id varchar(255) NOT NULL,
|
||||||
|
apns_env varchar(16) NOT NULL DEFAULT 'production',
|
||||||
|
created_at bigint NOT NULL,
|
||||||
|
updated_at bigint NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY device_registration_secret_token_idx (secret_hash, device_token),
|
||||||
|
KEY device_registration_secret_hash_idx (secret_hash)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS delivery_log (
|
||||||
|
id varchar(36) NOT NULL,
|
||||||
|
secret_hash varchar(64) NOT NULL,
|
||||||
|
event_type varchar(32) NOT NULL,
|
||||||
|
session_id varchar(255) NOT NULL,
|
||||||
|
status varchar(16) NOT NULL,
|
||||||
|
error varchar(1024) NULL,
|
||||||
|
created_at bigint NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY delivery_log_secret_hash_idx (secret_hash),
|
||||||
|
KEY delivery_log_created_at_idx (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"extends": "@tsconfig/bun/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||||
|
"noUncheckedIndexedAccess": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.4.6",
|
"version": "1.14.18",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
Binary file not shown.
@@ -8,14 +8,20 @@ import { SettingsGeneral } from "./settings-general"
|
|||||||
import { SettingsKeybinds } from "./settings-keybinds"
|
import { SettingsKeybinds } from "./settings-keybinds"
|
||||||
import { SettingsProviders } from "./settings-providers"
|
import { SettingsProviders } from "./settings-providers"
|
||||||
import { SettingsModels } from "./settings-models"
|
import { SettingsModels } from "./settings-models"
|
||||||
|
import { SettingsPair } from "./settings-pair"
|
||||||
|
|
||||||
export const DialogSettings: Component = () => {
|
export const DialogSettings: Component<{ defaultTab?: string }> = (props) => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog size="x-large" transition>
|
<Dialog size="x-large" transition>
|
||||||
<Tabs orientation="vertical" variant="settings" defaultValue="general" class="h-full settings-dialog">
|
<Tabs
|
||||||
|
orientation="vertical"
|
||||||
|
variant="settings"
|
||||||
|
defaultValue={props.defaultTab ?? "general"}
|
||||||
|
class="h-full settings-dialog"
|
||||||
|
>
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<div class="flex flex-col justify-between h-full w-full">
|
<div class="flex flex-col justify-between h-full w-full">
|
||||||
<div class="flex flex-col gap-3 w-full pt-3">
|
<div class="flex flex-col gap-3 w-full pt-3">
|
||||||
@@ -45,6 +51,10 @@ export const DialogSettings: Component = () => {
|
|||||||
<Icon name="models" />
|
<Icon name="models" />
|
||||||
{language.t("settings.models.title")}
|
{language.t("settings.models.title")}
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="pair">
|
||||||
|
<Icon name="link" />
|
||||||
|
{language.t("settings.pair.title")}
|
||||||
|
</Tabs.Trigger>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,6 +77,9 @@ export const DialogSettings: Component = () => {
|
|||||||
<Tabs.Content value="models" class="no-scrollbar">
|
<Tabs.Content value="models" class="no-scrollbar">
|
||||||
<SettingsModels />
|
<SettingsModels />
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
<Tabs.Content value="pair" class="no-scrollbar">
|
||||||
|
<SettingsPair />
|
||||||
|
</Tabs.Content>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Spinner } from "@opencode-ai/ui/spinner"
|
|||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
import { getFilename } from "@opencode-ai/shared/util/path"
|
import { getFilename } from "@opencode-ai/shared/util/path"
|
||||||
import { createEffect, createMemo, For, Show } from "solid-js"
|
import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { Portal } from "solid-js/web"
|
import { Portal } from "solid-js/web"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
@@ -16,6 +16,7 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useServer } from "@/context/server"
|
import { useServer } from "@/context/server"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useTerminal } from "@/context/terminal"
|
import { useTerminal } from "@/context/terminal"
|
||||||
import { focusTerminalById } from "@/pages/session/helpers"
|
import { focusTerminalById } from "@/pages/session/helpers"
|
||||||
@@ -134,6 +135,7 @@ export function SessionHeader() {
|
|||||||
const server = useServer()
|
const server = useServer()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const settings = useSettings()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const terminal = useTerminal()
|
const terminal = useTerminal()
|
||||||
const { params, view } = useSessionLayout()
|
const { params, view } = useSessionLayout()
|
||||||
@@ -151,6 +153,11 @@ export function SessionHeader() {
|
|||||||
})
|
})
|
||||||
const hotkey = createMemo(() => command.keybind("file.open"))
|
const hotkey = createMemo(() => command.keybind("file.open"))
|
||||||
const os = createMemo(() => detectOS(platform))
|
const os = createMemo(() => detectOS(platform))
|
||||||
|
const isDesktopBeta = platform.platform === "desktop" && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"
|
||||||
|
const search = createMemo(() => !isDesktopBeta || settings.general.showSearch())
|
||||||
|
const tree = createMemo(() => !isDesktopBeta || settings.general.showFileTree())
|
||||||
|
const term = createMemo(() => !isDesktopBeta || settings.general.showTerminal())
|
||||||
|
const status = createMemo(() => !isDesktopBeta || settings.general.showStatus())
|
||||||
|
|
||||||
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
||||||
finder: true,
|
finder: true,
|
||||||
@@ -262,12 +269,16 @@ export function SessionHeader() {
|
|||||||
.catch((err: unknown) => showRequestError(language, err))
|
.catch((err: unknown) => showRequestError(language, err))
|
||||||
}
|
}
|
||||||
|
|
||||||
const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
|
const [centerMount, setCenterMount] = createSignal<HTMLElement | null>(null)
|
||||||
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
|
const [rightMount, setRightMount] = createSignal<HTMLElement | null>(null)
|
||||||
|
onMount(() => {
|
||||||
|
setCenterMount(document.getElementById("opencode-titlebar-center"))
|
||||||
|
setRightMount(document.getElementById("opencode-titlebar-right"))
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Show when={centerMount()}>
|
<Show when={search() && centerMount()}>
|
||||||
{(mount) => (
|
{(mount) => (
|
||||||
<Portal mount={mount()}>
|
<Portal mount={mount()}>
|
||||||
<Button
|
<Button
|
||||||
@@ -415,24 +426,28 @@ export function SessionHeader() {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
|
<Show when={status()}>
|
||||||
<StatusPopover />
|
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
|
||||||
</Tooltip>
|
<StatusPopover />
|
||||||
<TooltipKeybind
|
</Tooltip>
|
||||||
title={language.t("command.terminal.toggle")}
|
</Show>
|
||||||
keybind={command.keybind("terminal.toggle")}
|
<Show when={term()}>
|
||||||
>
|
<TooltipKeybind
|
||||||
<Button
|
title={language.t("command.terminal.toggle")}
|
||||||
variant="ghost"
|
keybind={command.keybind("terminal.toggle")}
|
||||||
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
|
|
||||||
onClick={toggleTerminal}
|
|
||||||
aria-label={language.t("command.terminal.toggle")}
|
|
||||||
aria-expanded={view().terminal.opened()}
|
|
||||||
aria-controls="terminal-panel"
|
|
||||||
>
|
>
|
||||||
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
|
<Button
|
||||||
</Button>
|
variant="ghost"
|
||||||
</TooltipKeybind>
|
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
|
||||||
|
onClick={toggleTerminal}
|
||||||
|
aria-label={language.t("command.terminal.toggle")}
|
||||||
|
aria-expanded={view().terminal.opened()}
|
||||||
|
aria-controls="terminal-panel"
|
||||||
|
>
|
||||||
|
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
|
||||||
|
</Button>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<div class="hidden md:flex items-center gap-1 shrink-0">
|
<div class="hidden md:flex items-center gap-1 shrink-0">
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
@@ -451,30 +466,32 @@ export function SessionHeader() {
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
|
|
||||||
<TooltipKeybind
|
<Show when={tree()}>
|
||||||
title={language.t("command.fileTree.toggle")}
|
<TooltipKeybind
|
||||||
keybind={command.keybind("fileTree.toggle")}
|
title={language.t("command.fileTree.toggle")}
|
||||||
>
|
keybind={command.keybind("fileTree.toggle")}
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
class="titlebar-icon w-8 h-6 p-0 box-border"
|
|
||||||
onClick={() => layout.fileTree.toggle()}
|
|
||||||
aria-label={language.t("command.fileTree.toggle")}
|
|
||||||
aria-expanded={layout.fileTree.opened()}
|
|
||||||
aria-controls="file-tree-panel"
|
|
||||||
>
|
>
|
||||||
<div class="relative flex items-center justify-center size-4">
|
<Button
|
||||||
<Icon
|
variant="ghost"
|
||||||
size="small"
|
class="titlebar-icon w-8 h-6 p-0 box-border"
|
||||||
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
|
onClick={() => layout.fileTree.toggle()}
|
||||||
classList={{
|
aria-label={language.t("command.fileTree.toggle")}
|
||||||
"text-icon-strong": layout.fileTree.opened(),
|
aria-expanded={layout.fileTree.opened()}
|
||||||
"text-icon-weak": !layout.fileTree.opened(),
|
aria-controls="file-tree-panel"
|
||||||
}}
|
>
|
||||||
/>
|
<div class="relative flex items-center justify-center size-4">
|
||||||
</div>
|
<Icon
|
||||||
</Button>
|
size="small"
|
||||||
</TooltipKeybind>
|
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
|
||||||
|
classList={{
|
||||||
|
"text-icon-strong": layout.fileTree.opened(),
|
||||||
|
"text-icon-weak": !layout.fileTree.opened(),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ import {
|
|||||||
sansDefault,
|
sansDefault,
|
||||||
sansFontFamily,
|
sansFontFamily,
|
||||||
sansInput,
|
sansInput,
|
||||||
|
terminalDefault,
|
||||||
|
terminalFontFamily,
|
||||||
|
terminalInput,
|
||||||
useSettings,
|
useSettings,
|
||||||
} from "@/context/settings"
|
} from "@/context/settings"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
@@ -106,6 +109,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
|
|
||||||
permission.disableAutoAccept(params.id, value)
|
permission.disableAutoAccept(params.id, value)
|
||||||
}
|
}
|
||||||
|
const desktop = createMemo(() => platform.platform === "desktop")
|
||||||
|
|
||||||
const check = () => {
|
const check = () => {
|
||||||
if (!platform.checkUpdate) return
|
if (!platform.checkUpdate) return
|
||||||
@@ -180,6 +184,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
const soundOptions = [noneSound, ...SOUND_OPTIONS]
|
const soundOptions = [noneSound, ...SOUND_OPTIONS]
|
||||||
const mono = () => monoInput(settings.appearance.font())
|
const mono = () => monoInput(settings.appearance.font())
|
||||||
const sans = () => sansInput(settings.appearance.uiFont())
|
const sans = () => sansInput(settings.appearance.uiFont())
|
||||||
|
const terminal = () => terminalInput(settings.appearance.terminalFont())
|
||||||
|
|
||||||
const soundSelectProps = (
|
const soundSelectProps = (
|
||||||
enabled: () => boolean,
|
enabled: () => boolean,
|
||||||
@@ -279,6 +284,74 @@ export const SettingsGeneral: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const AdvancedSection = () => (
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.advanced")}</h3>
|
||||||
|
|
||||||
|
<SettingsList>
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.showFileTree.title")}
|
||||||
|
description={language.t("settings.general.row.showFileTree.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-show-file-tree">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.showFileTree()}
|
||||||
|
onChange={(checked) => settings.general.setShowFileTree(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.showNavigation.title")}
|
||||||
|
description={language.t("settings.general.row.showNavigation.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-show-navigation">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.showNavigation()}
|
||||||
|
onChange={(checked) => settings.general.setShowNavigation(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.showSearch.title")}
|
||||||
|
description={language.t("settings.general.row.showSearch.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-show-search">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.showSearch()}
|
||||||
|
onChange={(checked) => settings.general.setShowSearch(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.showTerminal.title")}
|
||||||
|
description={language.t("settings.general.row.showTerminal.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-show-terminal">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.showTerminal()}
|
||||||
|
onChange={(checked) => settings.general.setShowTerminal(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.showStatus.title")}
|
||||||
|
description={language.t("settings.general.row.showStatus.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-show-status">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.showStatus()}
|
||||||
|
onChange={(checked) => settings.general.setShowStatus(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
</SettingsList>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
const AppearanceSection = () => (
|
const AppearanceSection = () => (
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
|
||||||
@@ -382,6 +455,29 @@ export const SettingsGeneral: Component = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.terminalFont.title")}
|
||||||
|
description={language.t("settings.general.row.terminalFont.description")}
|
||||||
|
>
|
||||||
|
<div class="w-full sm:w-[220px]">
|
||||||
|
<TextField
|
||||||
|
data-action="settings-terminal-font"
|
||||||
|
label={language.t("settings.general.row.terminalFont.title")}
|
||||||
|
hideLabel
|
||||||
|
type="text"
|
||||||
|
value={terminal()}
|
||||||
|
onChange={(value) => settings.appearance.setTerminalFont(value)}
|
||||||
|
placeholder={terminalDefault}
|
||||||
|
spellcheck={false}
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
class="text-12-regular"
|
||||||
|
style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
</SettingsList>
|
</SettingsList>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -527,6 +623,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
console.log(import.meta.env)
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||||
@@ -609,6 +706,10 @@ export const SettingsGeneral: Component = () => {
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
<Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"}>
|
||||||
|
<AdvancedSection />
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { type Component, createResource, Show } from "solid-js"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
|
import { useServer } from "@/context/server"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { SettingsList } from "./settings-list"
|
||||||
|
|
||||||
|
type PairResult =
|
||||||
|
| { enabled: false }
|
||||||
|
| {
|
||||||
|
enabled: true
|
||||||
|
hosts: string[]
|
||||||
|
relayURL?: string
|
||||||
|
serverID?: string
|
||||||
|
relaySecretHash?: string
|
||||||
|
link: string
|
||||||
|
qr: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsPair: Component = () => {
|
||||||
|
const language = useLanguage()
|
||||||
|
const globalSDK = useGlobalSDK()
|
||||||
|
const server = useServer()
|
||||||
|
const platform = usePlatform()
|
||||||
|
|
||||||
|
const [data] = createResource(async () => {
|
||||||
|
const url = `${globalSDK.url}/experimental/push/pair`
|
||||||
|
console.debug("[settings-pair] fetching pair data", {
|
||||||
|
serverUrl: globalSDK.url,
|
||||||
|
serverName: server.name,
|
||||||
|
serverKey: server.key,
|
||||||
|
})
|
||||||
|
const f = platform.fetch ?? fetch
|
||||||
|
const res = await f(url)
|
||||||
|
if (!res.ok) {
|
||||||
|
console.debug("[settings-pair] pair endpoint returned non-ok", {
|
||||||
|
status: res.status,
|
||||||
|
serverUrl: globalSDK.url,
|
||||||
|
})
|
||||||
|
return { enabled: false as const }
|
||||||
|
}
|
||||||
|
const result = (await res.json()) as PairResult
|
||||||
|
console.debug("[settings-pair] pair data received", {
|
||||||
|
enabled: result.enabled,
|
||||||
|
serverUrl: globalSDK.url,
|
||||||
|
serverName: server.name,
|
||||||
|
...(result.enabled
|
||||||
|
? {
|
||||||
|
relayURL: result.relayURL,
|
||||||
|
serverID: result.serverID,
|
||||||
|
relaySecretHash: result.relaySecretHash,
|
||||||
|
hostCount: result.hosts.length,
|
||||||
|
hosts: result.hosts,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-6 py-4 px-5">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h2 class="text-16-semibold text-text-strong">{language.t("settings.pair.title")}</h2>
|
||||||
|
<p class="text-13-regular text-text-weak">{language.t("settings.pair.description")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={data.loading}>
|
||||||
|
<SettingsList>
|
||||||
|
<div class="flex items-center justify-center py-12">
|
||||||
|
<span class="text-14-regular text-text-weak">{language.t("settings.pair.loading")}</span>
|
||||||
|
</div>
|
||||||
|
</SettingsList>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={data.error}>
|
||||||
|
<SettingsList>
|
||||||
|
<div class="flex flex-col items-center justify-center py-12 gap-3 text-center">
|
||||||
|
<Icon name="warning" size="large" />
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-14-medium text-text-strong">{language.t("settings.pair.error.title")}</span>
|
||||||
|
<span class="text-13-regular text-text-weak max-w-md">
|
||||||
|
{language.t("settings.pair.error.description")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsList>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!data.loading && !data.error && data()}>
|
||||||
|
{(result) => (
|
||||||
|
<Show
|
||||||
|
when={result().enabled && result()}
|
||||||
|
fallback={
|
||||||
|
<SettingsList>
|
||||||
|
<div class="flex flex-col items-center justify-center py-12 gap-3 text-center">
|
||||||
|
<Icon name="link" size="large" />
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-14-medium text-text-strong">{language.t("settings.pair.disabled.title")}</span>
|
||||||
|
<span class="text-13-regular text-text-weak max-w-md">
|
||||||
|
{language.t("settings.pair.disabled.description")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<code class="text-12-regular text-text-weak bg-surface-inset px-3 py-1.5 rounded mt-1">
|
||||||
|
opencode serve --relay-url <url> --relay-secret <secret>
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</SettingsList>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(pair) => {
|
||||||
|
const p = pair() as PairResult & { enabled: true }
|
||||||
|
return (
|
||||||
|
<SettingsList>
|
||||||
|
<div class="flex flex-col items-center py-8 gap-4">
|
||||||
|
<Show when={server.list.length > 1 || p.relayURL}>
|
||||||
|
<div class="flex flex-col gap-1.5 w-full max-w-sm text-left">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-12-medium text-text-weak shrink-0">
|
||||||
|
{language.t("settings.pair.server.label")}
|
||||||
|
</span>
|
||||||
|
<code class="text-12-regular text-text-default bg-surface-inset px-2 py-0.5 rounded truncate">
|
||||||
|
{server.name}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<Show when={p.relayURL}>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-12-medium text-text-weak shrink-0">
|
||||||
|
{language.t("settings.pair.relay.label")}
|
||||||
|
</span>
|
||||||
|
<code class="text-12-regular text-text-default bg-surface-inset px-2 py-0.5 rounded truncate">
|
||||||
|
{p.relayURL}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={p.relaySecretHash}>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-12-medium text-text-weak shrink-0">
|
||||||
|
{language.t("settings.pair.secret.label")}
|
||||||
|
</span>
|
||||||
|
<code class="text-12-regular text-text-default bg-surface-inset px-2 py-0.5 rounded truncate">
|
||||||
|
{p.relaySecretHash}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<img src={p.qr} alt="Pairing QR code" class="w-64 h-64" />
|
||||||
|
<div class="flex flex-col gap-1 text-center max-w-sm">
|
||||||
|
<span class="text-14-medium text-text-strong">
|
||||||
|
{language.t("settings.pair.instructions.title")}
|
||||||
|
</span>
|
||||||
|
<span class="text-13-regular text-text-weak">
|
||||||
|
{language.t("settings.pair.instructions.description")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsList>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useServer } from "@/context/server"
|
import { useServer } from "@/context/server"
|
||||||
import { monoFontFamily, useSettings } from "@/context/settings"
|
import { terminalFontFamily, useSettings } from "@/context/settings"
|
||||||
import type { LocalPTY } from "@/context/terminal"
|
import type { LocalPTY } from "@/context/terminal"
|
||||||
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
||||||
import { terminalWriter } from "@/utils/terminal-writer"
|
import { terminalWriter } from "@/utils/terminal-writer"
|
||||||
@@ -300,7 +300,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const font = monoFontFamily(settings.appearance.font())
|
const font = terminalFontFamily(settings.appearance.terminalFont())
|
||||||
if (!term) return
|
if (!term) return
|
||||||
setOptionIfSupported(term, "fontFamily", font)
|
setOptionIfSupported(term, "fontFamily", font)
|
||||||
scheduleFit()
|
scheduleFit()
|
||||||
@@ -360,7 +360,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
cols: restoreSize?.cols,
|
cols: restoreSize?.cols,
|
||||||
rows: restoreSize?.rows,
|
rows: restoreSize?.rows,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: monoFontFamily(settings.appearance.font()),
|
fontFamily: terminalFontFamily(settings.appearance.terminalFont()),
|
||||||
allowTransparency: false,
|
allowTransparency: false,
|
||||||
convertEol: false,
|
convertEol: false,
|
||||||
theme: terminalColors(),
|
theme: terminalColors(),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useLayout } from "@/context/layout"
|
|||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
||||||
|
|
||||||
type TauriDesktopWindow = {
|
type TauriDesktopWindow = {
|
||||||
@@ -40,6 +41,7 @@ export function Titlebar() {
|
|||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const settings = useSettings()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
@@ -78,6 +80,7 @@ export function Titlebar() {
|
|||||||
const canBack = createMemo(() => history.index > 0)
|
const canBack = createMemo(() => history.index > 0)
|
||||||
const canForward = createMemo(() => history.index < history.stack.length - 1)
|
const canForward = createMemo(() => history.index < history.stack.length - 1)
|
||||||
const hasProjects = createMemo(() => layout.projects.list().length > 0)
|
const hasProjects = createMemo(() => layout.projects.list().length > 0)
|
||||||
|
const nav = createMemo(() => import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" || settings.general.showNavigation())
|
||||||
|
|
||||||
const back = () => {
|
const back = () => {
|
||||||
const next = backPath(history)
|
const next = backPath(history)
|
||||||
@@ -255,13 +258,12 @@ export function Titlebar() {
|
|||||||
<div
|
<div
|
||||||
class="flex items-center shrink-0"
|
class="flex items-center shrink-0"
|
||||||
classList={{
|
classList={{
|
||||||
"translate-x-0": !layout.sidebar.opened(),
|
"-translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
|
||||||
"-translate-x-[36px]": layout.sidebar.opened(),
|
|
||||||
"duration-180 ease-out": !layout.sidebar.opened(),
|
"duration-180 ease-out": !layout.sidebar.opened(),
|
||||||
"duration-180 ease-in": layout.sidebar.opened(),
|
"duration-180 ease-in": layout.sidebar.opened(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={hasProjects()}>
|
<Show when={hasProjects() && nav()}>
|
||||||
<div class="flex items-center gap-0 transition-transform">
|
<div class="flex items-center gap-0 transition-transform">
|
||||||
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
|
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ function createGlobalSync() {
|
|||||||
|
|
||||||
const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
|
const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
|
||||||
const promise = queryClient
|
const promise = queryClient
|
||||||
.ensureQueryData({
|
.fetchQuery({
|
||||||
...loadSessionsQuery(directory),
|
...loadSessionsQuery(directory),
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
loadRootSessionsWithFallback({
|
loadRootSessionsWithFallback({
|
||||||
@@ -264,7 +264,6 @@ function createGlobalSync() {
|
|||||||
children.pin(directory)
|
children.pin(directory)
|
||||||
const promise = Promise.resolve().then(async () => {
|
const promise = Promise.resolve().then(async () => {
|
||||||
const child = children.ensureChild(directory)
|
const child = children.ensureChild(directory)
|
||||||
child[1]("bootstrapPromise", promise!)
|
|
||||||
const cache = children.vcsCache.get(directory)
|
const cache = children.vcsCache.get(directory)
|
||||||
if (!cache) return
|
if (!cache) return
|
||||||
const sdk = sdkFor(directory)
|
const sdk = sdkFor(directory)
|
||||||
|
|||||||
@@ -182,7 +182,6 @@ export function createChildStoreManager(input: {
|
|||||||
limit: 5,
|
limit: 5,
|
||||||
message: {},
|
message: {},
|
||||||
part: {},
|
part: {},
|
||||||
bootstrapPromise: Promise.resolve(),
|
|
||||||
})
|
})
|
||||||
children[directory] = child
|
children[directory] = child
|
||||||
disposers.set(directory, dispose)
|
disposers.set(directory, dispose)
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ export type State = {
|
|||||||
part: {
|
part: {
|
||||||
[messageID: string]: Part[]
|
[messageID: string]: Part[]
|
||||||
}
|
}
|
||||||
bootstrapPromise: Promise<void>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type VcsCache = {
|
export type VcsCache = {
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ export interface Settings {
|
|||||||
autoSave: boolean
|
autoSave: boolean
|
||||||
releaseNotes: boolean
|
releaseNotes: boolean
|
||||||
followup: "queue" | "steer"
|
followup: "queue" | "steer"
|
||||||
|
showFileTree: boolean
|
||||||
|
showNavigation: boolean
|
||||||
|
showSearch: boolean
|
||||||
|
showStatus: boolean
|
||||||
|
showTerminal: boolean
|
||||||
showReasoningSummaries: boolean
|
showReasoningSummaries: boolean
|
||||||
shellToolPartsExpanded: boolean
|
shellToolPartsExpanded: boolean
|
||||||
editToolPartsExpanded: boolean
|
editToolPartsExpanded: boolean
|
||||||
@@ -34,6 +39,7 @@ export interface Settings {
|
|||||||
fontSize: number
|
fontSize: number
|
||||||
mono: string
|
mono: string
|
||||||
sans: string
|
sans: string
|
||||||
|
terminal: string
|
||||||
}
|
}
|
||||||
keybinds: Record<string, string>
|
keybinds: Record<string, string>
|
||||||
permissions: {
|
permissions: {
|
||||||
@@ -45,13 +51,17 @@ export interface Settings {
|
|||||||
|
|
||||||
export const monoDefault = "System Mono"
|
export const monoDefault = "System Mono"
|
||||||
export const sansDefault = "System Sans"
|
export const sansDefault = "System Sans"
|
||||||
|
export const terminalDefault = "JetBrainsMono Nerd Font Mono"
|
||||||
|
|
||||||
const monoFallback =
|
const monoFallback =
|
||||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||||
const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
||||||
|
const terminalFallback =
|
||||||
|
'"JetBrainsMono Nerd Font Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||||
|
|
||||||
const monoBase = monoFallback
|
const monoBase = monoFallback
|
||||||
const sansBase = sansFallback
|
const sansBase = sansFallback
|
||||||
|
const terminalBase = terminalFallback
|
||||||
|
|
||||||
function input(font: string | undefined) {
|
function input(font: string | undefined) {
|
||||||
return font ?? ""
|
return font ?? ""
|
||||||
@@ -84,11 +94,24 @@ export function sansFontFamily(font: string | undefined) {
|
|||||||
return stack(font, sansBase)
|
return stack(font, sansBase)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function terminalInput(font: string | undefined) {
|
||||||
|
return input(font)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function terminalFontFamily(font: string | undefined) {
|
||||||
|
return stack(font, terminalBase)
|
||||||
|
}
|
||||||
|
|
||||||
const defaultSettings: Settings = {
|
const defaultSettings: Settings = {
|
||||||
general: {
|
general: {
|
||||||
autoSave: true,
|
autoSave: true,
|
||||||
releaseNotes: true,
|
releaseNotes: true,
|
||||||
followup: "steer",
|
followup: "steer",
|
||||||
|
showFileTree: false,
|
||||||
|
showNavigation: false,
|
||||||
|
showSearch: false,
|
||||||
|
showStatus: false,
|
||||||
|
showTerminal: false,
|
||||||
showReasoningSummaries: false,
|
showReasoningSummaries: false,
|
||||||
shellToolPartsExpanded: false,
|
shellToolPartsExpanded: false,
|
||||||
editToolPartsExpanded: false,
|
editToolPartsExpanded: false,
|
||||||
@@ -100,6 +123,7 @@ const defaultSettings: Settings = {
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
mono: "",
|
mono: "",
|
||||||
sans: "",
|
sans: "",
|
||||||
|
terminal: "",
|
||||||
},
|
},
|
||||||
keybinds: {},
|
keybinds: {},
|
||||||
permissions: {
|
permissions: {
|
||||||
@@ -162,6 +186,26 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
setFollowup(value: "queue" | "steer") {
|
setFollowup(value: "queue" | "steer") {
|
||||||
setStore("general", "followup", value === "queue" ? "steer" : value)
|
setStore("general", "followup", value === "queue" ? "steer" : value)
|
||||||
},
|
},
|
||||||
|
showFileTree: withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree),
|
||||||
|
setShowFileTree(value: boolean) {
|
||||||
|
setStore("general", "showFileTree", value)
|
||||||
|
},
|
||||||
|
showNavigation: withFallback(() => store.general?.showNavigation, defaultSettings.general.showNavigation),
|
||||||
|
setShowNavigation(value: boolean) {
|
||||||
|
setStore("general", "showNavigation", value)
|
||||||
|
},
|
||||||
|
showSearch: withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch),
|
||||||
|
setShowSearch(value: boolean) {
|
||||||
|
setStore("general", "showSearch", value)
|
||||||
|
},
|
||||||
|
showStatus: withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus),
|
||||||
|
setShowStatus(value: boolean) {
|
||||||
|
setStore("general", "showStatus", value)
|
||||||
|
},
|
||||||
|
showTerminal: withFallback(() => store.general?.showTerminal, defaultSettings.general.showTerminal),
|
||||||
|
setShowTerminal(value: boolean) {
|
||||||
|
setStore("general", "showTerminal", value)
|
||||||
|
},
|
||||||
showReasoningSummaries: withFallback(
|
showReasoningSummaries: withFallback(
|
||||||
() => store.general?.showReasoningSummaries,
|
() => store.general?.showReasoningSummaries,
|
||||||
defaultSettings.general.showReasoningSummaries,
|
defaultSettings.general.showReasoningSummaries,
|
||||||
@@ -203,6 +247,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
setUIFont(value: string) {
|
setUIFont(value: string) {
|
||||||
setStore("appearance", "sans", value.trim() ? value : "")
|
setStore("appearance", "sans", value.trim() ? value : "")
|
||||||
},
|
},
|
||||||
|
terminalFont: withFallback(() => store.appearance?.terminal, defaultSettings.appearance.terminal),
|
||||||
|
setTerminalFont(value: string) {
|
||||||
|
setStore("appearance", "terminal", value.trim() ? value : "")
|
||||||
|
},
|
||||||
},
|
},
|
||||||
keybinds: {
|
keybinds: {
|
||||||
get: (action: string) => store.keybinds?.[action],
|
get: (action: string) => store.keybinds?.[action],
|
||||||
|
|||||||
Vendored
+2
-4
@@ -1,16 +1,14 @@
|
|||||||
import "solid-js"
|
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_OPENCODE_SERVER_HOST: string
|
readonly VITE_OPENCODE_SERVER_HOST: string
|
||||||
readonly VITE_OPENCODE_SERVER_PORT: string
|
readonly VITE_OPENCODE_SERVER_PORT: string
|
||||||
readonly OPENCODE_CHANNEL?: "dev" | "beta" | "prod"
|
readonly VITE_OPENCODE_CHANNEL?: "dev" | "beta" | "prod"
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
readonly env: ImportMetaEnv
|
readonly env: ImportMetaEnv
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "solid-js" {
|
export declare module "solid-js" {
|
||||||
namespace JSX {
|
namespace JSX {
|
||||||
interface Directives {
|
interface Directives {
|
||||||
sortable: true
|
sortable: true
|
||||||
|
|||||||
@@ -565,7 +565,9 @@ export const dict = {
|
|||||||
"settings.general.row.theme.title": "السمة",
|
"settings.general.row.theme.title": "السمة",
|
||||||
"settings.general.row.theme.description": "تخصيص سمة OpenCode.",
|
"settings.general.row.theme.description": "تخصيص سمة OpenCode.",
|
||||||
"settings.general.row.font.title": "خط الكود",
|
"settings.general.row.font.title": "خط الكود",
|
||||||
"settings.general.row.font.description": "خصّص الخط المستخدم في كتل التعليمات البرمجية والطرفيات",
|
"settings.general.row.font.description": "خصّص الخط المستخدم في كتل التعليمات البرمجية",
|
||||||
|
"settings.general.row.terminalFont.title": "Terminal Font",
|
||||||
|
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
|
||||||
"settings.general.row.uiFont.title": "خط الواجهة",
|
"settings.general.row.uiFont.title": "خط الواجهة",
|
||||||
"settings.general.row.uiFont.description": "خصّص الخط المستخدم في الواجهة بأكملها",
|
"settings.general.row.uiFont.description": "خصّص الخط المستخدم في الواجهة بأكملها",
|
||||||
"settings.general.row.followup.title": "سلوك المتابعة",
|
"settings.general.row.followup.title": "سلوك المتابعة",
|
||||||
|
|||||||
@@ -572,7 +572,9 @@ export const dict = {
|
|||||||
"settings.general.row.theme.title": "Tema",
|
"settings.general.row.theme.title": "Tema",
|
||||||
"settings.general.row.theme.description": "Personalize como o OpenCode é tematizado.",
|
"settings.general.row.theme.description": "Personalize como o OpenCode é tematizado.",
|
||||||
"settings.general.row.font.title": "Fonte de código",
|
"settings.general.row.font.title": "Fonte de código",
|
||||||
"settings.general.row.font.description": "Personalize a fonte usada em blocos de código e terminais",
|
"settings.general.row.font.description": "Personalize a fonte usada em blocos de código",
|
||||||
|
"settings.general.row.terminalFont.title": "Terminal Font",
|
||||||
|
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
|
||||||
"settings.general.row.uiFont.title": "Fonte da interface",
|
"settings.general.row.uiFont.title": "Fonte da interface",
|
||||||
"settings.general.row.uiFont.description": "Personalize a fonte usada em toda a interface",
|
"settings.general.row.uiFont.description": "Personalize a fonte usada em toda a interface",
|
||||||
"settings.general.row.followup.title": "Comportamento de acompanhamento",
|
"settings.general.row.followup.title": "Comportamento de acompanhamento",
|
||||||
|
|||||||
@@ -637,7 +637,9 @@ export const dict = {
|
|||||||
"settings.general.row.theme.title": "Tema",
|
"settings.general.row.theme.title": "Tema",
|
||||||
"settings.general.row.theme.description": "Prilagodi temu OpenCode-a.",
|
"settings.general.row.theme.description": "Prilagodi temu OpenCode-a.",
|
||||||
"settings.general.row.font.title": "Font za kod",
|
"settings.general.row.font.title": "Font za kod",
|
||||||
"settings.general.row.font.description": "Prilagodi font koji se koristi u blokovima koda i terminalima",
|
"settings.general.row.font.description": "Prilagodi font koji se koristi u blokovima koda",
|
||||||
|
"settings.general.row.terminalFont.title": "Terminal Font",
|
||||||
|
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
|
||||||
"settings.general.row.uiFont.title": "UI font",
|
"settings.general.row.uiFont.title": "UI font",
|
||||||
"settings.general.row.uiFont.description": "Prilagodi font koji se koristi u cijelom interfejsu",
|
"settings.general.row.uiFont.description": "Prilagodi font koji se koristi u cijelom interfejsu",
|
||||||
"settings.general.row.followup.title": "Ponašanje nadovezivanja",
|
"settings.general.row.followup.title": "Ponašanje nadovezivanja",
|
||||||
|
|||||||
@@ -632,7 +632,9 @@ export const dict = {
|
|||||||
"settings.general.row.theme.title": "Tema",
|
"settings.general.row.theme.title": "Tema",
|
||||||
"settings.general.row.theme.description": "Tilpas hvordan OpenCode er temabestemt.",
|
"settings.general.row.theme.description": "Tilpas hvordan OpenCode er temabestemt.",
|
||||||
"settings.general.row.font.title": "Kode-skrifttype",
|
"settings.general.row.font.title": "Kode-skrifttype",
|
||||||
"settings.general.row.font.description": "Tilpas skrifttypen, der bruges i kodeblokke og terminaler",
|
"settings.general.row.font.description": "Tilpas skrifttypen, der bruges i kodeblokke",
|
||||||
|
"settings.general.row.terminalFont.title": "Terminal Font",
|
||||||
|
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
|
||||||
"settings.general.row.uiFont.title": "UI-skrifttype",
|
"settings.general.row.uiFont.title": "UI-skrifttype",
|
||||||
"settings.general.row.uiFont.description": "Tilpas skrifttypen, der bruges i hele brugerfladen",
|
"settings.general.row.uiFont.description": "Tilpas skrifttypen, der bruges i hele brugerfladen",
|
||||||
"settings.general.row.followup.title": "Opfølgningsadfærd",
|
"settings.general.row.followup.title": "Opfølgningsadfærd",
|
||||||
|
|||||||
@@ -582,7 +582,9 @@ export const dict = {
|
|||||||
"settings.general.row.theme.title": "Thema",
|
"settings.general.row.theme.title": "Thema",
|
||||||
"settings.general.row.theme.description": "Das Thema von OpenCode anpassen.",
|
"settings.general.row.theme.description": "Das Thema von OpenCode anpassen.",
|
||||||
"settings.general.row.font.title": "Code-Schriftart",
|
"settings.general.row.font.title": "Code-Schriftart",
|
||||||
"settings.general.row.font.description": "Die in Codeblöcken und Terminals verwendete Schriftart anpassen",
|
"settings.general.row.font.description": "Die in Codeblöcken verwendete Schriftart anpassen",
|
||||||
|
"settings.general.row.terminalFont.title": "Terminal Font",
|
||||||
|
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
|
||||||
"settings.general.row.uiFont.title": "UI-Schriftart",
|
"settings.general.row.uiFont.title": "UI-Schriftart",
|
||||||
"settings.general.row.uiFont.description": "Die im gesamten Interface verwendete Schriftart anpassen",
|
"settings.general.row.uiFont.description": "Die im gesamten Interface verwendete Schriftart anpassen",
|
||||||
"settings.general.row.followup.title": "Verhalten bei Folgefragen",
|
"settings.general.row.followup.title": "Verhalten bei Folgefragen",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const dict = {
|
|||||||
"command.provider.connect": "Connect provider",
|
"command.provider.connect": "Connect provider",
|
||||||
"command.server.switch": "Switch server",
|
"command.server.switch": "Switch server",
|
||||||
"command.settings.open": "Open settings",
|
"command.settings.open": "Open settings",
|
||||||
|
"command.pair.show": "Pair mobile device",
|
||||||
"command.session.previous": "Previous session",
|
"command.session.previous": "Previous session",
|
||||||
"command.session.next": "Next session",
|
"command.session.next": "Next session",
|
||||||
"command.session.previous.unseen": "Previous unread session",
|
"command.session.previous.unseen": "Previous unread session",
|
||||||
@@ -719,6 +720,7 @@ export const dict = {
|
|||||||
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
|
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
|
||||||
|
|
||||||
"settings.general.section.appearance": "Appearance",
|
"settings.general.section.appearance": "Appearance",
|
||||||
|
"settings.general.section.advanced": "Advanced",
|
||||||
"settings.general.section.notifications": "System notifications",
|
"settings.general.section.notifications": "System notifications",
|
||||||
"settings.general.section.updates": "Updates",
|
"settings.general.section.updates": "Updates",
|
||||||
"settings.general.section.sounds": "Sound effects",
|
"settings.general.section.sounds": "Sound effects",
|
||||||
@@ -734,13 +736,25 @@ export const dict = {
|
|||||||
"settings.general.row.theme.title": "Theme",
|
"settings.general.row.theme.title": "Theme",
|
||||||
"settings.general.row.theme.description": "Customise how OpenCode is themed.",
|
"settings.general.row.theme.description": "Customise how OpenCode is themed.",
|
||||||
"settings.general.row.font.title": "Code Font",
|
"settings.general.row.font.title": "Code Font",
|
||||||
"settings.general.row.font.description": "Customise the font used in code blocks and terminals",
|
"settings.general.row.font.description": "Customise the font used in code blocks",
|
||||||
|
"settings.general.row.terminalFont.title": "Terminal Font",
|
||||||
|
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
|
||||||
"settings.general.row.uiFont.title": "UI Font",
|
"settings.general.row.uiFont.title": "UI Font",
|
||||||
"settings.general.row.uiFont.description": "Customise the font used throughout the interface",
|
"settings.general.row.uiFont.description": "Customise the font used throughout the interface",
|
||||||
"settings.general.row.followup.title": "Follow-up behavior",
|
"settings.general.row.followup.title": "Follow-up behavior",
|
||||||
"settings.general.row.followup.description": "Choose whether follow-up prompts steer immediately or wait in a queue",
|
"settings.general.row.followup.description": "Choose whether follow-up prompts steer immediately or wait in a queue",
|
||||||
"settings.general.row.followup.option.queue": "Queue",
|
"settings.general.row.followup.option.queue": "Queue",
|
||||||
"settings.general.row.followup.option.steer": "Steer",
|
"settings.general.row.followup.option.steer": "Steer",
|
||||||
|
"settings.general.row.showFileTree.title": "File tree",
|
||||||
|
"settings.general.row.showFileTree.description": "Show the file tree toggle and panel in desktop sessions",
|
||||||
|
"settings.general.row.showNavigation.title": "Navigation controls",
|
||||||
|
"settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar",
|
||||||
|
"settings.general.row.showSearch.title": "Command palette",
|
||||||
|
"settings.general.row.showSearch.description": "Show the search and command palette button in the desktop title bar",
|
||||||
|
"settings.general.row.showTerminal.title": "Terminal",
|
||||||
|
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
|
||||||
|
"settings.general.row.showStatus.title": "Server status",
|
||||||
|
"settings.general.row.showStatus.description": "Show the server status button in the desktop title bar",
|
||||||
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
|
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
|
||||||
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
|
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
|
||||||
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
|
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
|
||||||
@@ -857,6 +871,20 @@ export const dict = {
|
|||||||
"settings.providers.tag.config": "Config",
|
"settings.providers.tag.config": "Config",
|
||||||
"settings.providers.tag.custom": "Custom",
|
"settings.providers.tag.custom": "Custom",
|
||||||
"settings.providers.tag.other": "Other",
|
"settings.providers.tag.other": "Other",
|
||||||
|
"settings.pair.title": "Pair",
|
||||||
|
"settings.pair.description": "Pair a mobile device for push notifications.",
|
||||||
|
"settings.pair.loading": "Loading pairing info...",
|
||||||
|
"settings.pair.error.title": "Could not load pairing info",
|
||||||
|
"settings.pair.error.description": "Check that the server is reachable and try again.",
|
||||||
|
"settings.pair.disabled.title": "Push relay is not enabled",
|
||||||
|
"settings.pair.disabled.description": "Start the server with push relay options to enable mobile pairing.",
|
||||||
|
"settings.pair.server.label": "Server",
|
||||||
|
"settings.pair.relay.label": "Relay",
|
||||||
|
"settings.pair.secret.label": "Secret",
|
||||||
|
"settings.pair.instructions.title": "Scan with the OpenCode Control app",
|
||||||
|
"settings.pair.instructions.description":
|
||||||
|
"Open the OpenCode Control app and scan this QR code to pair your device for push notifications.",
|
||||||
|
|
||||||
"settings.models.title": "Models",
|
"settings.models.title": "Models",
|
||||||
"settings.models.description": "Model settings will be configurable here.",
|
"settings.models.description": "Model settings will be configurable here.",
|
||||||
"settings.agents.title": "Agents",
|
"settings.agents.title": "Agents",
|
||||||
|
|||||||
@@ -640,7 +640,9 @@ export const dict = {
|
|||||||
"settings.general.row.theme.title": "Tema",
|
"settings.general.row.theme.title": "Tema",
|
||||||
"settings.general.row.theme.description": "Personaliza el tema de OpenCode.",
|
"settings.general.row.theme.description": "Personaliza el tema de OpenCode.",
|
||||||
"settings.general.row.font.title": "Fuente de código",
|
"settings.general.row.font.title": "Fuente de código",
|
||||||
"settings.general.row.font.description": "Personaliza la fuente usada en bloques de código y terminales",
|
"settings.general.row.font.description": "Personaliza la fuente usada en bloques de código",
|
||||||
|
"settings.general.row.terminalFont.title": "Terminal Font",
|
||||||
|
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
|
||||||
"settings.general.row.uiFont.title": "Fuente de la interfaz",
|
"settings.general.row.uiFont.title": "Fuente de la interfaz",
|
||||||
"settings.general.row.uiFont.description": "Personaliza la fuente usada en toda la interfaz",
|
"settings.general.row.uiFont.description": "Personaliza la fuente usada en toda la interfaz",
|
||||||
"settings.general.row.followup.title": "Comportamiento de seguimiento",
|
"settings.general.row.followup.title": "Comportamiento de seguimiento",
|
||||||
|
|||||||
@@ -579,7 +579,9 @@ export const dict = {
|
|||||||
"settings.general.row.theme.title": "Thème",
|
"settings.general.row.theme.title": "Thème",
|
||||||
"settings.general.row.theme.description": "Personnaliser le thème d'OpenCode.",
|
"settings.general.row.theme.description": "Personnaliser le thème d'OpenCode.",
|
||||||
"settings.general.row.font.title": "Police de code",
|
"settings.general.row.font.title": "Police de code",
|
||||||
"settings.general.row.font.description": "Personnaliser la police utilisée dans les blocs de code et les terminaux",
|
"settings.general.row.font.description": "Personnaliser la police utilisée dans les blocs de code",
|
||||||
|
"settings.general.row.terminalFont.title": "Terminal Font",
|
||||||
|
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
|
||||||
"settings.general.row.uiFont.title": "Police de l'interface",
|
"settings.general.row.uiFont.title": "Police de l'interface",
|
||||||
"settings.general.row.uiFont.description": "Personnaliser la police utilisée dans toute l'interface",
|
"settings.general.row.uiFont.description": "Personnaliser la police utilisée dans toute l'interface",
|
||||||
"settings.general.row.followup.title": "Comportement de suivi",
|
"settings.general.row.followup.title": "Comportement de suivi",
|
||||||
|
|||||||
@@ -569,7 +569,9 @@ export const dict = {
|
|||||||
"settings.general.row.theme.title": "テーマ",
|
"settings.general.row.theme.title": "テーマ",
|
||||||
"settings.general.row.theme.description": "OpenCodeのテーマをカスタマイズします。",
|
"settings.general.row.theme.description": "OpenCodeのテーマをカスタマイズします。",
|
||||||
"settings.general.row.font.title": "コードフォント",
|
"settings.general.row.font.title": "コードフォント",
|
||||||
"settings.general.row.font.description": "コードブロックとターミナルで使用するフォントをカスタマイズします",
|
"settings.general.row.font.description": "コードブロックで使用するフォントをカスタマイズします",
|
||||||
|
"settings.general.row.terminalFont.title": "Terminal Font",
|
||||||
|
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
|
||||||
"settings.general.row.uiFont.title": "UIフォント",
|
"settings.general.row.uiFont.title": "UIフォント",
|
||||||
"settings.general.row.uiFont.description": "インターフェース全体で使用するフォントをカスタマイズします",
|
"settings.general.row.uiFont.description": "インターフェース全体で使用するフォントをカスタマイズします",
|
||||||
"settings.general.row.followup.title": "フォローアップの動作",
|
"settings.general.row.followup.title": "フォローアップの動作",
|
||||||
|
|||||||
@@ -566,7 +566,9 @@ export const dict = {
|
|||||||
"settings.general.row.theme.title": "테마",
|
"settings.general.row.theme.title": "테마",
|
||||||
"settings.general.row.theme.description": "OpenCode 테마 사용자 지정",
|
"settings.general.row.theme.description": "OpenCode 테마 사용자 지정",
|
||||||
"settings.general.row.font.title": "코드 글꼴",
|
"settings.general.row.font.title": "코드 글꼴",
|
||||||
"settings.general.row.font.description": "코드 블록과 터미널에 사용되는 글꼴을 사용자 지정",
|
"settings.general.row.font.description": "코드 블록에 사용되는 글꼴을 사용자 지정",
|
||||||
|
"settings.general.row.terminalFont.title": "Terminal Font",
|
||||||
|
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
|
||||||
"settings.general.row.uiFont.title": "UI 글꼴",
|
"settings.general.row.uiFont.title": "UI 글꼴",
|
||||||
"settings.general.row.uiFont.description": "인터페이스 전반에 사용되는 글꼴을 사용자 지정",
|
"settings.general.row.uiFont.description": "인터페이스 전반에 사용되는 글꼴을 사용자 지정",
|
||||||
"settings.general.row.followup.title": "후속 조치 동작",
|
"settings.general.row.followup.title": "후속 조치 동작",
|
||||||
|
|||||||
@@ -640,7 +640,9 @@ export const dict = {
|
|||||||
"settings.general.row.theme.title": "Tema",
|
"settings.general.row.theme.title": "Tema",
|
||||||
"settings.general.row.theme.description": "Tilpass hvordan OpenCode er tematisert.",
|
"settings.general.row.theme.description": "Tilpass hvordan OpenCode er tematisert.",
|
||||||
"settings.general.row.font.title": "Kodefont",
|
"settings.general.row.font.title": "Kodefont",
|
||||||
"settings.general.row.font.description": "Tilpass skrifttypen som brukes i kodeblokker og terminaler",
|
"settings.general.row.font.description": "Tilpass skrifttypen som brukes i kodeblokker",
|
||||||
|
"settings.general.row.terminalFont.title": "Terminal Font",
|
||||||
|
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
|
||||||
"settings.general.row.uiFont.title": "UI-skrift",
|
"settings.general.row.uiFont.title": "UI-skrift",
|
||||||
"settings.general.row.uiFont.description": "Tilpass skrifttypen som brukes i hele grensesnittet",
|
"settings.general.row.uiFont.description": "Tilpass skrifttypen som brukes i hele grensesnittet",
|
||||||
"settings.general.row.followup.title": "Oppfølgingsadferd",
|
"settings.general.row.followup.title": "Oppfølgingsadferd",
|
||||||
|
|||||||
@@ -571,7 +571,9 @@ export const dict = {
|
|||||||
"settings.general.row.theme.title": "Motyw",
|
"settings.general.row.theme.title": "Motyw",
|
||||||
"settings.general.row.theme.description": "Dostosuj motyw OpenCode.",
|
"settings.general.row.theme.description": "Dostosuj motyw OpenCode.",
|
||||||
"settings.general.row.font.title": "Czcionka kodu",
|
"settings.general.row.font.title": "Czcionka kodu",
|
||||||
"settings.general.row.font.description": "Dostosuj czcionkę używaną w blokach kodu i terminalach",
|
"settings.general.row.font.description": "Dostosuj czcionkę używaną w blokach kodu",
|
||||||
|
"settings.general.row.terminalFont.title": "Terminal Font",
|
||||||
|
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
|
||||||
"settings.general.row.uiFont.title": "Czcionka interfejsu",
|
"settings.general.row.uiFont.title": "Czcionka interfejsu",
|
||||||
"settings.general.row.uiFont.description": "Dostosuj czcionkę używaną w całym interfejsie",
|
"settings.general.row.uiFont.description": "Dostosuj czcionkę używaną w całym interfejsie",
|
||||||
"settings.general.row.followup.title": "Zachowanie kontynuacji",
|
"settings.general.row.followup.title": "Zachowanie kontynuacji",
|
||||||
|
|||||||
@@ -637,7 +637,9 @@ export const dict = {
|
|||||||
"settings.general.row.theme.title": "Тема",
|
"settings.general.row.theme.title": "Тема",
|
||||||
"settings.general.row.theme.description": "Настройте оформление OpenCode.",
|
"settings.general.row.theme.description": "Настройте оформление OpenCode.",
|
||||||
"settings.general.row.font.title": "Шрифт кода",
|
"settings.general.row.font.title": "Шрифт кода",
|
||||||
"settings.general.row.font.description": "Настройте шрифт, используемый в блоках кода и терминалах",
|
"settings.general.row.font.description": "Настройте шрифт, используемый в блоках кода",
|
||||||
|
"settings.general.row.terminalFont.title": "Terminal Font",
|
||||||
|
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
|
||||||
"settings.general.row.uiFont.title": "Шрифт интерфейса",
|
"settings.general.row.uiFont.title": "Шрифт интерфейса",
|
||||||
"settings.general.row.uiFont.description": "Настройте шрифт, используемый во всем интерфейсе",
|
"settings.general.row.uiFont.description": "Настройте шрифт, используемый во всем интерфейсе",
|
||||||
"settings.general.row.followup.title": "Поведение уточняющих вопросов",
|
"settings.general.row.followup.title": "Поведение уточняющих вопросов",
|
||||||
|
|||||||
@@ -631,7 +631,9 @@ export const dict = {
|
|||||||
"settings.general.row.theme.title": "ธีม",
|
"settings.general.row.theme.title": "ธีม",
|
||||||
"settings.general.row.theme.description": "ปรับแต่งวิธีการที่ OpenCode มีธีม",
|
"settings.general.row.theme.description": "ปรับแต่งวิธีการที่ OpenCode มีธีม",
|
||||||
"settings.general.row.font.title": "ฟอนต์โค้ด",
|
"settings.general.row.font.title": "ฟอนต์โค้ด",
|
||||||
"settings.general.row.font.description": "ปรับแต่งฟอนต์ที่ใช้ในบล็อกโค้ดและเทอร์มินัล",
|
"settings.general.row.font.description": "ปรับแต่งฟอนต์ที่ใช้ในบล็อกโค้ด",
|
||||||
|
"settings.general.row.terminalFont.title": "Terminal Font",
|
||||||
|
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
|
||||||
"settings.general.row.uiFont.title": "ฟอนต์ UI",
|
"settings.general.row.uiFont.title": "ฟอนต์ UI",
|
||||||
"settings.general.row.uiFont.description": "ปรับแต่งฟอนต์ที่ใช้ทั่วทั้งอินเทอร์เฟซ",
|
"settings.general.row.uiFont.description": "ปรับแต่งฟอนต์ที่ใช้ทั่วทั้งอินเทอร์เฟซ",
|
||||||
"settings.general.row.followup.title": "พฤติกรรมการติดตามผล",
|
"settings.general.row.followup.title": "พฤติกรรมการติดตามผล",
|
||||||
|
|||||||
@@ -644,7 +644,9 @@ export const dict = {
|
|||||||
"settings.general.row.theme.title": "Tema",
|
"settings.general.row.theme.title": "Tema",
|
||||||
"settings.general.row.theme.description": "OpenCode'un temasını özelleştirin.",
|
"settings.general.row.theme.description": "OpenCode'un temasını özelleştirin.",
|
||||||
"settings.general.row.font.title": "Kod Yazı Tipi",
|
"settings.general.row.font.title": "Kod Yazı Tipi",
|
||||||
"settings.general.row.font.description": "Kod bloklarında ve terminallerde kullanılan yazı tipini özelleştirin",
|
"settings.general.row.font.description": "Kod bloklarında kullanılan yazı tipini özelleştirin",
|
||||||
|
"settings.general.row.terminalFont.title": "Terminal Font",
|
||||||
|
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
|
||||||
"settings.general.row.uiFont.title": "Arayüz Yazı Tipi",
|
"settings.general.row.uiFont.title": "Arayüz Yazı Tipi",
|
||||||
"settings.general.row.uiFont.description": "Arayüz genelinde kullanılan yazı tipini özelleştirin",
|
"settings.general.row.uiFont.description": "Arayüz genelinde kullanılan yazı tipini özelleştirin",
|
||||||
"settings.general.row.followup.title": "Takip davranışı",
|
"settings.general.row.followup.title": "Takip davranışı",
|
||||||
|
|||||||
@@ -631,7 +631,9 @@ export const dict = {
|
|||||||
"settings.general.row.theme.title": "主题",
|
"settings.general.row.theme.title": "主题",
|
||||||
"settings.general.row.theme.description": "自定义 OpenCode 的主题。",
|
"settings.general.row.theme.description": "自定义 OpenCode 的主题。",
|
||||||
"settings.general.row.font.title": "代码字体",
|
"settings.general.row.font.title": "代码字体",
|
||||||
"settings.general.row.font.description": "自定义代码块和终端使用的字体",
|
"settings.general.row.font.description": "自定义代码块使用的字体",
|
||||||
|
"settings.general.row.terminalFont.title": "Terminal Font",
|
||||||
|
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
|
||||||
"settings.general.row.uiFont.title": "界面字体",
|
"settings.general.row.uiFont.title": "界面字体",
|
||||||
"settings.general.row.uiFont.description": "自定义整个界面使用的字体",
|
"settings.general.row.uiFont.description": "自定义整个界面使用的字体",
|
||||||
"settings.general.row.followup.title": "跟进消息行为",
|
"settings.general.row.followup.title": "跟进消息行为",
|
||||||
|
|||||||
@@ -626,7 +626,9 @@ export const dict = {
|
|||||||
"settings.general.row.theme.title": "主題",
|
"settings.general.row.theme.title": "主題",
|
||||||
"settings.general.row.theme.description": "自訂 OpenCode 的主題。",
|
"settings.general.row.theme.description": "自訂 OpenCode 的主題。",
|
||||||
"settings.general.row.font.title": "程式碼字型",
|
"settings.general.row.font.title": "程式碼字型",
|
||||||
"settings.general.row.font.description": "自訂程式碼區塊和終端機使用的字型",
|
"settings.general.row.font.description": "自訂程式碼區塊使用的字型",
|
||||||
|
"settings.general.row.terminalFont.title": "Terminal Font",
|
||||||
|
"settings.general.row.terminalFont.description": "Customise the font used in the terminal",
|
||||||
"settings.general.row.uiFont.title": "介面字型",
|
"settings.general.row.uiFont.title": "介面字型",
|
||||||
"settings.general.row.uiFont.description": "自訂整個介面使用的字型",
|
"settings.general.row.uiFont.description": "自訂整個介面使用的字型",
|
||||||
"settings.general.row.followup.title": "後續追問行為",
|
"settings.general.row.followup.title": "後續追問行為",
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
@import "@opencode-ai/ui/styles/tailwind";
|
@import "@opencode-ai/ui/styles/tailwind";
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "JetBrainsMono Nerd Font Mono";
|
||||||
|
src: url("/assets/JetBrainsMonoNerdFontMono-Regular.woff2") format("woff2");
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
@keyframes session-progress-whip {
|
@keyframes session-progress-whip {
|
||||||
0% {
|
0% {
|
||||||
|
|||||||
+188
-183
@@ -13,7 +13,7 @@ import {
|
|||||||
type Accessor,
|
type Accessor,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { useNavigate, useParams } from "@solidjs/router"
|
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||||
import { useLayout, LocalProject } from "@/context/layout"
|
import { useLayout, LocalProject } from "@/context/layout"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
@@ -127,6 +127,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const initialDirectory = decode64(params.dir)
|
const initialDirectory = decode64(params.dir)
|
||||||
|
const location = useLocation()
|
||||||
const route = createMemo(() => {
|
const route = createMemo(() => {
|
||||||
const slug = params.dir
|
const slug = params.dir
|
||||||
if (!slug) return { slug, dir: "" }
|
if (!slug) return { slug, dir: "" }
|
||||||
@@ -1060,6 +1061,13 @@ export default function Layout(props: ParentProps) {
|
|||||||
keybind: "mod+comma",
|
keybind: "mod+comma",
|
||||||
onSelect: () => openSettings(),
|
onSelect: () => openSettings(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "pair.show",
|
||||||
|
title: language.t("command.pair.show"),
|
||||||
|
category: language.t("command.category.settings"),
|
||||||
|
slash: "pair",
|
||||||
|
onSelect: () => openSettings("pair"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "session.previous",
|
id: "session.previous",
|
||||||
title: language.t("command.session.previous"),
|
title: language.t("command.session.previous"),
|
||||||
@@ -1212,11 +1220,11 @@ export default function Layout(props: ParentProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSettings() {
|
function openSettings(defaultTab?: string) {
|
||||||
const run = ++dialogRun
|
const run = ++dialogRun
|
||||||
void import("@/components/dialog-settings").then((x) => {
|
void import("@/components/dialog-settings").then((x) => {
|
||||||
if (dialogDead || dialogRun !== run) return
|
if (dialogDead || dialogRun !== run) return
|
||||||
dialog.show(() => <x.DialogSettings />)
|
dialog.show(() => <x.DialogSettings defaultTab={defaultTab} />)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2102,196 +2110,198 @@ export default function Layout(props: ParentProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<>
|
{(project) => (
|
||||||
<div class="shrink-0 pl-1 py-1">
|
<>
|
||||||
<div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
|
<div class="shrink-0 pl-1 py-1">
|
||||||
<div class="flex flex-col min-w-0">
|
<div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
|
||||||
<InlineEditor
|
<div class="flex flex-col min-w-0">
|
||||||
id={`project:${projectId()}`}
|
<InlineEditor
|
||||||
value={projectName}
|
id={`project:${projectId()}`}
|
||||||
onSave={(next) => {
|
value={projectName}
|
||||||
const item = project()
|
onSave={(next) => {
|
||||||
if (!item) return
|
const item = project()
|
||||||
void renameProject(item, next)
|
if (!item) return
|
||||||
}}
|
void renameProject(item, next)
|
||||||
class="text-14-medium text-text-strong truncate"
|
}}
|
||||||
displayClass="text-14-medium text-text-strong truncate"
|
class="text-14-medium text-text-strong truncate"
|
||||||
stopPropagation
|
displayClass="text-14-medium text-text-strong truncate"
|
||||||
/>
|
stopPropagation
|
||||||
|
/>
|
||||||
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
gutter={2}
|
gutter={2}
|
||||||
value={worktree()}
|
value={worktree()}
|
||||||
class="shrink-0"
|
class="shrink-0"
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
"max-width": "640px",
|
"max-width": "640px",
|
||||||
transform: "translate3d(52px, 0, 0)",
|
transform: "translate3d(52px, 0, 0)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span class="text-12-regular text-text-base truncate select-text">
|
<span class="text-12-regular text-text-base truncate select-text">
|
||||||
{worktree().replace(homedir(), "~")}
|
{worktree().replace(homedir(), "~")}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DropdownMenu modal={!sidebarHovering()}>
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
as={IconButton}
|
||||||
|
icon="dot-grid"
|
||||||
|
variant="ghost"
|
||||||
|
data-action="project-menu"
|
||||||
|
data-project={slug()}
|
||||||
|
class="shrink-0 size-6 rounded-md transition-opacity data-[expanded]:bg-surface-base-active"
|
||||||
|
classList={{
|
||||||
|
"opacity-100": panelProps.mobile || merged(),
|
||||||
|
"opacity-0 group-hover/project:opacity-100 group-focus-within/project:opacity-100 data-[expanded]:opacity-100":
|
||||||
|
!panelProps.mobile && !merged(),
|
||||||
|
}}
|
||||||
|
aria-label={language.t("common.moreOptions")}
|
||||||
|
/>
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content class="mt-1">
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={() => {
|
||||||
|
const item = project()
|
||||||
|
if (!item) return
|
||||||
|
showEditProjectDialog(item)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
data-action="project-workspaces-toggle"
|
||||||
|
data-project={slug()}
|
||||||
|
disabled={!canToggle()}
|
||||||
|
onSelect={() => {
|
||||||
|
const item = project()
|
||||||
|
if (!item) return
|
||||||
|
toggleProjectWorkspaces(item)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemLabel>
|
||||||
|
{workspacesEnabled()
|
||||||
|
? language.t("sidebar.workspaces.disable")
|
||||||
|
: language.t("sidebar.workspaces.enable")}
|
||||||
|
</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
data-action="project-clear-notifications"
|
||||||
|
data-project={slug()}
|
||||||
|
disabled={unseenCount() === 0}
|
||||||
|
onSelect={clearNotifications}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemLabel>
|
||||||
|
{language.t("sidebar.project.clearNotifications")}
|
||||||
|
</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
<DropdownMenu.Item
|
||||||
|
data-action="project-close-menu"
|
||||||
|
data-project={slug()}
|
||||||
|
onSelect={() => {
|
||||||
|
const dir = worktree()
|
||||||
|
if (!dir) return
|
||||||
|
closeProject(dir)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DropdownMenu modal={!sidebarHovering()}>
|
|
||||||
<DropdownMenu.Trigger
|
|
||||||
as={IconButton}
|
|
||||||
icon="dot-grid"
|
|
||||||
variant="ghost"
|
|
||||||
data-action="project-menu"
|
|
||||||
data-project={slug()}
|
|
||||||
class="shrink-0 size-6 rounded-md transition-opacity data-[expanded]:bg-surface-base-active"
|
|
||||||
classList={{
|
|
||||||
"opacity-100": panelProps.mobile || merged(),
|
|
||||||
"opacity-0 group-hover/project:opacity-100 group-focus-within/project:opacity-100 data-[expanded]:opacity-100":
|
|
||||||
!panelProps.mobile && !merged(),
|
|
||||||
}}
|
|
||||||
aria-label={language.t("common.moreOptions")}
|
|
||||||
/>
|
|
||||||
<DropdownMenu.Portal>
|
|
||||||
<DropdownMenu.Content class="mt-1">
|
|
||||||
<DropdownMenu.Item
|
|
||||||
onSelect={() => {
|
|
||||||
const item = project()
|
|
||||||
if (!item) return
|
|
||||||
showEditProjectDialog(item)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
data-action="project-workspaces-toggle"
|
|
||||||
data-project={slug()}
|
|
||||||
disabled={!canToggle()}
|
|
||||||
onSelect={() => {
|
|
||||||
const item = project()
|
|
||||||
if (!item) return
|
|
||||||
toggleProjectWorkspaces(item)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemLabel>
|
|
||||||
{workspacesEnabled()
|
|
||||||
? language.t("sidebar.workspaces.disable")
|
|
||||||
: language.t("sidebar.workspaces.enable")}
|
|
||||||
</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
data-action="project-clear-notifications"
|
|
||||||
data-project={slug()}
|
|
||||||
disabled={unseenCount() === 0}
|
|
||||||
onSelect={clearNotifications}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemLabel>
|
|
||||||
{language.t("sidebar.project.clearNotifications")}
|
|
||||||
</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Separator />
|
|
||||||
<DropdownMenu.Item
|
|
||||||
data-action="project-close-menu"
|
|
||||||
data-project={slug()}
|
|
||||||
onSelect={() => {
|
|
||||||
const dir = worktree()
|
|
||||||
if (!dir) return
|
|
||||||
closeProject(dir)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Portal>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 min-h-0 flex flex-col">
|
<div class="flex-1 min-h-0 flex flex-col">
|
||||||
<Show
|
<Show
|
||||||
when={workspacesEnabled()}
|
when={workspacesEnabled()}
|
||||||
fallback={
|
fallback={
|
||||||
|
<>
|
||||||
|
<div class="shrink-0 py-4">
|
||||||
|
<Button
|
||||||
|
size="large"
|
||||||
|
icon="new-session"
|
||||||
|
class="w-full"
|
||||||
|
onClick={() => {
|
||||||
|
const dir = worktree()
|
||||||
|
if (!dir) return
|
||||||
|
navigateWithSidebarReset(`/${base64Encode(dir)}/session`)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{language.t("command.session.new")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-h-0">
|
||||||
|
<LocalWorkspace
|
||||||
|
ctx={workspaceSidebarCtx}
|
||||||
|
project={project()}
|
||||||
|
sortNow={sortNow}
|
||||||
|
mobile={panelProps.mobile}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
<>
|
<>
|
||||||
<div class="shrink-0 py-4">
|
<div class="shrink-0 py-4">
|
||||||
<Button
|
<Button
|
||||||
size="large"
|
size="large"
|
||||||
icon="new-session"
|
icon="plus-small"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const dir = worktree()
|
const item = project()
|
||||||
if (!dir) return
|
if (!item) return
|
||||||
navigateWithSidebarReset(`/${base64Encode(dir)}/session`)
|
void createWorkspace(item)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{language.t("command.session.new")}
|
{language.t("workspace.new")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-h-0">
|
<div class="relative flex-1 min-h-0">
|
||||||
<LocalWorkspace
|
<DragDropProvider
|
||||||
ctx={workspaceSidebarCtx}
|
onDragStart={handleWorkspaceDragStart}
|
||||||
project={project()!}
|
onDragEnd={handleWorkspaceDragEnd}
|
||||||
sortNow={sortNow}
|
onDragOver={handleWorkspaceDragOver}
|
||||||
mobile={panelProps.mobile}
|
collisionDetector={closestCenter}
|
||||||
/>
|
>
|
||||||
|
<DragDropSensors />
|
||||||
|
<ConstrainDragXAxis />
|
||||||
|
<div
|
||||||
|
ref={(el) => {
|
||||||
|
if (!panelProps.mobile) scrollContainerRef = el
|
||||||
|
}}
|
||||||
|
class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
|
||||||
|
>
|
||||||
|
<SortableProvider ids={workspaces()}>
|
||||||
|
<For each={workspaces()}>
|
||||||
|
{(directory) => (
|
||||||
|
<SortableWorkspace
|
||||||
|
ctx={workspaceSidebarCtx}
|
||||||
|
directory={directory}
|
||||||
|
project={project()}
|
||||||
|
sortNow={sortNow}
|
||||||
|
mobile={panelProps.mobile}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</SortableProvider>
|
||||||
|
</div>
|
||||||
|
<DragOverlay>
|
||||||
|
<WorkspaceDragOverlay
|
||||||
|
sidebarProject={sidebarProject}
|
||||||
|
activeWorkspace={() => store.activeWorkspace}
|
||||||
|
workspaceLabel={workspaceLabel}
|
||||||
|
/>
|
||||||
|
</DragOverlay>
|
||||||
|
</DragDropProvider>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
}
|
</Show>
|
||||||
>
|
</div>
|
||||||
<>
|
</>
|
||||||
<div class="shrink-0 py-4">
|
)}
|
||||||
<Button
|
|
||||||
size="large"
|
|
||||||
icon="plus-small"
|
|
||||||
class="w-full"
|
|
||||||
onClick={() => {
|
|
||||||
const item = project()
|
|
||||||
if (!item) return
|
|
||||||
void createWorkspace(item)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{language.t("workspace.new")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div class="relative flex-1 min-h-0">
|
|
||||||
<DragDropProvider
|
|
||||||
onDragStart={handleWorkspaceDragStart}
|
|
||||||
onDragEnd={handleWorkspaceDragEnd}
|
|
||||||
onDragOver={handleWorkspaceDragOver}
|
|
||||||
collisionDetector={closestCenter}
|
|
||||||
>
|
|
||||||
<DragDropSensors />
|
|
||||||
<ConstrainDragXAxis />
|
|
||||||
<div
|
|
||||||
ref={(el) => {
|
|
||||||
if (!panelProps.mobile) scrollContainerRef = el
|
|
||||||
}}
|
|
||||||
class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
|
|
||||||
>
|
|
||||||
<SortableProvider ids={workspaces()}>
|
|
||||||
<For each={workspaces()}>
|
|
||||||
{(directory) => (
|
|
||||||
<SortableWorkspace
|
|
||||||
ctx={workspaceSidebarCtx}
|
|
||||||
directory={directory}
|
|
||||||
project={project()!}
|
|
||||||
sortNow={sortNow}
|
|
||||||
mobile={panelProps.mobile}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</SortableProvider>
|
|
||||||
</div>
|
|
||||||
<DragOverlay>
|
|
||||||
<WorkspaceDragOverlay
|
|
||||||
sidebarProject={sidebarProject}
|
|
||||||
activeWorkspace={() => store.activeWorkspace}
|
|
||||||
workspaceLabel={workspaceLabel}
|
|
||||||
/>
|
|
||||||
</DragOverlay>
|
|
||||||
</DragDropProvider>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -2355,14 +2365,9 @@ export default function Layout(props: ParentProps) {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
const [loading] = createResource(
|
|
||||||
() => route()?.store?.[0]?.bootstrapPromise,
|
|
||||||
(p) => p,
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||||
{(autoselecting(), loading()) ?? ""}
|
{autoselecting() ?? ""}
|
||||||
<Titlebar />
|
<Titlebar />
|
||||||
<div class="flex-1 min-h-0 min-w-0 flex">
|
<div class="flex-1 min-h-0 min-w-0 flex">
|
||||||
<div class="flex-1 min-h-0 relative">
|
<div class="flex-1 min-h-0 relative">
|
||||||
|
|||||||
@@ -317,12 +317,11 @@ export const SortableWorkspace = (props: {
|
|||||||
})
|
})
|
||||||
const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local()))
|
const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local()))
|
||||||
const boot = createMemo(() => open() || active())
|
const boot = createMemo(() => open() || active())
|
||||||
const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false)
|
|
||||||
const count = createMemo(() => sessions()?.length ?? 0)
|
const count = createMemo(() => sessions()?.length ?? 0)
|
||||||
const hasMore = createMemo(() => workspaceStore.sessionTotal > count())
|
const hasMore = createMemo(() => workspaceStore.sessionTotal > count())
|
||||||
|
const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) }))
|
||||||
const busy = createMemo(() => props.ctx.isBusy(props.directory))
|
const busy = createMemo(() => props.ctx.isBusy(props.directory))
|
||||||
const wasBusy = createMemo((prev) => prev || busy(), false)
|
const loading = () => query.isLoading
|
||||||
const loading = createMemo(() => open() && !booted() && count() === 0 && !wasBusy())
|
|
||||||
const touch = createMediaQuery("(hover: none)")
|
const touch = createMediaQuery("(hover: none)")
|
||||||
const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id)))
|
const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id)))
|
||||||
const loadMore = async () => {
|
const loadMore = async () => {
|
||||||
@@ -427,7 +426,7 @@ export const SortableWorkspace = (props: {
|
|||||||
mobile={props.mobile}
|
mobile={props.mobile}
|
||||||
ctx={props.ctx}
|
ctx={props.ctx}
|
||||||
showNew={showNew}
|
showNew={showNew}
|
||||||
loading={loading}
|
loading={() => query.isLoading && count() === 0}
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
loadMore={loadMore}
|
loadMore={loadMore}
|
||||||
@@ -453,11 +452,10 @@ export const LocalWorkspace = (props: {
|
|||||||
})
|
})
|
||||||
const slug = createMemo(() => base64Encode(props.project.worktree))
|
const slug = createMemo(() => base64Encode(props.project.worktree))
|
||||||
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
|
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
|
||||||
const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
|
|
||||||
const count = createMemo(() => sessions()?.length ?? 0)
|
const count = createMemo(() => sessions()?.length ?? 0)
|
||||||
const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) }))
|
const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) }))
|
||||||
const loading = createMemo(() => query.isPending && count() === 0)
|
|
||||||
const hasMore = createMemo(() => workspace().store.sessionTotal > count())
|
const hasMore = createMemo(() => workspace().store.sessionTotal > count())
|
||||||
|
const loading = () => query.isLoading && count() === 0
|
||||||
const loadMore = async () => {
|
const loadMore = async () => {
|
||||||
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
|
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
|
||||||
await globalSync.project.loadSessions(props.project.worktree)
|
await globalSync.project.loadSessions(props.project.worktree)
|
||||||
@@ -473,7 +471,7 @@ export const LocalWorkspace = (props: {
|
|||||||
mobile={props.mobile}
|
mobile={props.mobile}
|
||||||
ctx={props.ctx}
|
ctx={props.ctx}
|
||||||
showNew={() => false}
|
showNew={() => false}
|
||||||
loading={() => query.isLoading}
|
loading={loading}
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
loadMore={loadMore}
|
loadMore={loadMore}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
|
||||||
import {
|
import {
|
||||||
batch,
|
batch,
|
||||||
onCleanup,
|
onCleanup,
|
||||||
@@ -324,6 +324,7 @@ export default function Page() {
|
|||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
@@ -518,26 +519,6 @@ export default function Page() {
|
|||||||
deferRender: false,
|
deferRender: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const [vcs, setVcs] = createStore<{
|
|
||||||
diff: {
|
|
||||||
git: VcsFileDiff[]
|
|
||||||
branch: VcsFileDiff[]
|
|
||||||
}
|
|
||||||
ready: {
|
|
||||||
git: boolean
|
|
||||||
branch: boolean
|
|
||||||
}
|
|
||||||
}>({
|
|
||||||
diff: {
|
|
||||||
git: [] as VcsFileDiff[],
|
|
||||||
branch: [] as VcsFileDiff[],
|
|
||||||
},
|
|
||||||
ready: {
|
|
||||||
git: false,
|
|
||||||
branch: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const [followup, setFollowup] = persisted(
|
const [followup, setFollowup] = persisted(
|
||||||
Persist.workspace(sdk.directory, "followup", ["followup.v1"]),
|
Persist.workspace(sdk.directory, "followup", ["followup.v1"]),
|
||||||
createStore<{
|
createStore<{
|
||||||
@@ -571,68 +552,6 @@ export default function Page() {
|
|||||||
let todoTimer: number | undefined
|
let todoTimer: number | undefined
|
||||||
let diffFrame: number | undefined
|
let diffFrame: number | undefined
|
||||||
let diffTimer: number | undefined
|
let diffTimer: number | undefined
|
||||||
const vcsTask = new Map<VcsMode, Promise<void>>()
|
|
||||||
const vcsRun = new Map<VcsMode, number>()
|
|
||||||
|
|
||||||
const bumpVcs = (mode: VcsMode) => {
|
|
||||||
const next = (vcsRun.get(mode) ?? 0) + 1
|
|
||||||
vcsRun.set(mode, next)
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetVcs = (mode?: VcsMode) => {
|
|
||||||
const list = mode ? [mode] : (["git", "branch"] as const)
|
|
||||||
list.forEach((item) => {
|
|
||||||
bumpVcs(item)
|
|
||||||
vcsTask.delete(item)
|
|
||||||
setVcs("diff", item, [])
|
|
||||||
setVcs("ready", item, false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadVcs = (mode: VcsMode, force = false) => {
|
|
||||||
if (sync.project?.vcs !== "git") return Promise.resolve()
|
|
||||||
if (!force && vcs.ready[mode]) return Promise.resolve()
|
|
||||||
|
|
||||||
if (force) {
|
|
||||||
if (vcsTask.has(mode)) bumpVcs(mode)
|
|
||||||
vcsTask.delete(mode)
|
|
||||||
setVcs("ready", mode, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const current = vcsTask.get(mode)
|
|
||||||
if (current) return current
|
|
||||||
|
|
||||||
const run = bumpVcs(mode)
|
|
||||||
|
|
||||||
const task = sdk.client.vcs
|
|
||||||
.diff({ mode })
|
|
||||||
.then((result) => {
|
|
||||||
if (vcsRun.get(mode) !== run) return
|
|
||||||
setVcs("diff", mode, list(result.data))
|
|
||||||
setVcs("ready", mode, true)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
if (vcsRun.get(mode) !== run) return
|
|
||||||
console.debug("[session-review] failed to load vcs diff", { mode, error })
|
|
||||||
setVcs("diff", mode, [])
|
|
||||||
setVcs("ready", mode, true)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (vcsTask.get(mode) === task) vcsTask.delete(mode)
|
|
||||||
})
|
|
||||||
|
|
||||||
vcsTask.set(mode, task)
|
|
||||||
return task
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshVcs = () => {
|
|
||||||
resetVcs()
|
|
||||||
const mode = untrack(vcsMode)
|
|
||||||
if (!mode) return
|
|
||||||
if (!untrack(wantsReview)) return
|
|
||||||
void loadVcs(mode, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
createComputed((prev) => {
|
createComputed((prev) => {
|
||||||
const open = desktopReviewOpen()
|
const open = desktopReviewOpen()
|
||||||
@@ -663,21 +582,52 @@ export default function Page() {
|
|||||||
list.push("turn")
|
list.push("turn")
|
||||||
return list
|
return list
|
||||||
})
|
})
|
||||||
|
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
|
||||||
|
const wantsReview = createMemo(() =>
|
||||||
|
isDesktop()
|
||||||
|
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
|
||||||
|
: store.mobileTab === "changes",
|
||||||
|
)
|
||||||
const vcsMode = createMemo<VcsMode | undefined>(() => {
|
const vcsMode = createMemo<VcsMode | undefined>(() => {
|
||||||
if (store.changes === "git" || store.changes === "branch") return store.changes
|
if (store.changes === "git" || store.changes === "branch") return store.changes
|
||||||
})
|
})
|
||||||
const reviewDiffs = createMemo(() => {
|
const vcsKey = createMemo(
|
||||||
if (store.changes === "git") return list(vcs.diff.git)
|
() => ["session-vcs", sdk.directory, sync.data.vcs?.branch ?? "", sync.data.vcs?.default_branch ?? ""] as const,
|
||||||
if (store.changes === "branch") return list(vcs.diff.branch)
|
)
|
||||||
|
const vcsQuery = createQuery(() => {
|
||||||
|
const mode = vcsMode()
|
||||||
|
const enabled = wantsReview() && sync.project?.vcs === "git"
|
||||||
|
|
||||||
|
return {
|
||||||
|
queryKey: [...vcsKey(), mode] as const,
|
||||||
|
enabled,
|
||||||
|
staleTime: Number.POSITIVE_INFINITY,
|
||||||
|
gcTime: 60 * 1000,
|
||||||
|
queryFn: mode
|
||||||
|
? () =>
|
||||||
|
sdk.client.vcs
|
||||||
|
.diff({ mode })
|
||||||
|
.then((result) => list(result.data))
|
||||||
|
.catch((error) => {
|
||||||
|
console.debug("[session-review] failed to load vcs diff", { mode, error })
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
: skipToken,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const refreshVcs = () => void queryClient.invalidateQueries({ queryKey: vcsKey() })
|
||||||
|
const reviewDiffs = () => {
|
||||||
|
if (store.changes === "git" || store.changes === "branch")
|
||||||
|
// avoids suspense
|
||||||
|
return vcsQuery.isFetched ? (vcsQuery.data ?? []) : []
|
||||||
return turnDiffs()
|
return turnDiffs()
|
||||||
})
|
}
|
||||||
const reviewCount = createMemo(() => reviewDiffs().length)
|
const reviewCount = () => reviewDiffs().length
|
||||||
const hasReview = createMemo(() => reviewCount() > 0)
|
const hasReview = () => reviewCount() > 0
|
||||||
const reviewReady = createMemo(() => {
|
const reviewReady = () => {
|
||||||
if (store.changes === "git") return vcs.ready.git
|
if (store.changes === "git" || store.changes === "branch") return !vcsQuery.isPending
|
||||||
if (store.changes === "branch") return vcs.ready.branch
|
|
||||||
return true
|
return true
|
||||||
})
|
}
|
||||||
|
|
||||||
const newSessionWorktree = createMemo(() => {
|
const newSessionWorktree = createMemo(() => {
|
||||||
if (store.newSessionWorktree === "create") return "create"
|
if (store.newSessionWorktree === "create") return "create"
|
||||||
@@ -897,27 +847,6 @@ export default function Page() {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => sdk.directory,
|
|
||||||
() => {
|
|
||||||
resetVcs()
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => [sync.data.vcs?.branch, sync.data.vcs?.default_branch] as const,
|
|
||||||
(next, prev) => {
|
|
||||||
if (prev === undefined || same(next, prev)) return
|
|
||||||
refreshVcs()
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const stopVcs = sdk.event.listen((evt) => {
|
const stopVcs = sdk.event.listen((evt) => {
|
||||||
if (evt.details.type !== "file.watcher.updated") return
|
if (evt.details.type !== "file.watcher.updated") return
|
||||||
const props =
|
const props =
|
||||||
@@ -1051,13 +980,6 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
|
|
||||||
const wantsReview = createMemo(() =>
|
|
||||||
isDesktop()
|
|
||||||
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
|
|
||||||
: store.mobileTab === "changes",
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const list = changesOptions()
|
const list = changesOptions()
|
||||||
if (list.includes(store.changes)) return
|
if (list.includes(store.changes)) return
|
||||||
@@ -1066,22 +988,12 @@ export default function Page() {
|
|||||||
setStore("changes", next)
|
setStore("changes", next)
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const mode = vcsMode()
|
|
||||||
if (!mode) return
|
|
||||||
if (!wantsReview()) return
|
|
||||||
void loadVcs(mode)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => sync.data.session_status[params.id ?? ""]?.type,
|
() => sync.data.session_status[params.id ?? ""]?.type,
|
||||||
(next, prev) => {
|
(next, prev) => {
|
||||||
const mode = vcsMode()
|
|
||||||
if (!mode) return
|
|
||||||
if (!wantsReview()) return
|
|
||||||
if (next !== "idle" || prev === undefined || prev === "idle") return
|
if (next !== "idle" || prev === undefined || prev === "idle") return
|
||||||
void loadVcs(mode, true)
|
refreshVcs()
|
||||||
},
|
},
|
||||||
{ defer: true },
|
{ defer: true },
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ import { useCommand } from "@/context/command"
|
|||||||
import { useFile, type SelectedLineRange } from "@/context/file"
|
import { useFile, type SelectedLineRange } from "@/context/file"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
||||||
import { FileTabContent } from "@/pages/session/file-tabs"
|
import { FileTabContent } from "@/pages/session/file-tabs"
|
||||||
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
|
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
|
||||||
@@ -39,6 +42,9 @@ export function SessionSidePanel(props: {
|
|||||||
size: Sizing
|
size: Sizing
|
||||||
}) {
|
}) {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
|
const platform = usePlatform()
|
||||||
|
const settings = useSettings()
|
||||||
|
const sync = useSync()
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
@@ -46,9 +52,15 @@ export function SessionSidePanel(props: {
|
|||||||
const { sessionKey, tabs, view } = useSessionLayout()
|
const { sessionKey, tabs, view } = useSessionLayout()
|
||||||
|
|
||||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||||
|
const shown = createMemo(
|
||||||
|
() =>
|
||||||
|
platform.platform !== "desktop" ||
|
||||||
|
import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" ||
|
||||||
|
settings.general.showFileTree(),
|
||||||
|
)
|
||||||
|
|
||||||
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
||||||
const fileOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
|
const fileOpen = createMemo(() => isDesktop() && shown() && layout.fileTree.opened())
|
||||||
const open = createMemo(() => reviewOpen() || fileOpen())
|
const open = createMemo(() => reviewOpen() || fileOpen())
|
||||||
const reviewTab = createMemo(() => isDesktop())
|
const reviewTab = createMemo(() => isDesktop())
|
||||||
const panelWidth = createMemo(() => {
|
const panelWidth = createMemo(() => {
|
||||||
@@ -341,98 +353,99 @@ export function SessionSidePanel(props: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<Show when={shown()}>
|
||||||
id="file-tree-panel"
|
|
||||||
aria-hidden={!fileOpen()}
|
|
||||||
inert={!fileOpen()}
|
|
||||||
class="relative min-w-0 h-full shrink-0 overflow-hidden"
|
|
||||||
classList={{
|
|
||||||
"pointer-events-none": !fileOpen(),
|
|
||||||
"transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
|
||||||
!props.size.active(),
|
|
||||||
}}
|
|
||||||
style={{ width: treeWidth() }}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="h-full flex flex-col overflow-hidden group/filetree"
|
id="file-tree-panel"
|
||||||
classList={{ "border-l border-border-weaker-base": reviewOpen() }}
|
aria-hidden={!fileOpen()}
|
||||||
|
inert={!fileOpen()}
|
||||||
|
class="relative min-w-0 h-full shrink-0 overflow-hidden"
|
||||||
|
classList={{
|
||||||
|
"pointer-events-none": !fileOpen(),
|
||||||
|
"transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||||
|
!props.size.active(),
|
||||||
|
}}
|
||||||
|
style={{ width: treeWidth() }}
|
||||||
>
|
>
|
||||||
<Tabs
|
<div
|
||||||
variant="pill"
|
class="h-full flex flex-col overflow-hidden group/filetree"
|
||||||
value={fileTreeTab()}
|
classList={{ "border-l border-border-weaker-base": reviewOpen() }}
|
||||||
onChange={setFileTreeTabValue}
|
|
||||||
class="h-full"
|
|
||||||
data-scope="filetree"
|
|
||||||
>
|
>
|
||||||
<Tabs.List>
|
<Tabs
|
||||||
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
|
variant="pill"
|
||||||
{props.reviewCount()}{" "}
|
value={fileTreeTab()}
|
||||||
{language.t(
|
onChange={setFileTreeTabValue}
|
||||||
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
|
class="h-full"
|
||||||
)}
|
data-scope="filetree"
|
||||||
</Tabs.Trigger>
|
>
|
||||||
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
|
<Tabs.List>
|
||||||
{language.t("session.files.all")}
|
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
|
||||||
</Tabs.Trigger>
|
{props.reviewCount()}{" "}
|
||||||
</Tabs.List>
|
{language.t(
|
||||||
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
|
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
|
||||||
<Switch>
|
)}
|
||||||
<Match when={props.hasReview() || !props.diffsReady()}>
|
</Tabs.Trigger>
|
||||||
<Show
|
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
|
||||||
when={props.diffsReady()}
|
{language.t("session.files.all")}
|
||||||
fallback={
|
</Tabs.Trigger>
|
||||||
<div class="px-2 py-2 text-12-regular text-text-weak">
|
</Tabs.List>
|
||||||
{language.t("common.loading")}
|
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
|
||||||
{language.t("common.loading.ellipsis")}
|
<Switch>
|
||||||
</div>
|
<Match when={props.hasReview() || !props.diffsReady()}>
|
||||||
}
|
<Show
|
||||||
>
|
when={props.diffsReady()}
|
||||||
|
fallback={
|
||||||
|
<div class="px-2 py-2 text-12-regular text-text-weak">
|
||||||
|
{language.t("common.loading")}
|
||||||
|
{language.t("common.loading.ellipsis")}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FileTree
|
||||||
|
path=""
|
||||||
|
class="pt-3"
|
||||||
|
allowed={diffFiles()}
|
||||||
|
kinds={kinds()}
|
||||||
|
draggable={false}
|
||||||
|
active={props.activeDiff}
|
||||||
|
onFileClick={(node) => props.focusReviewDiff(node.path)}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</Tabs.Content>
|
||||||
|
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
|
||||||
|
<Switch>
|
||||||
|
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
|
||||||
|
<Match when={true}>
|
||||||
<FileTree
|
<FileTree
|
||||||
path=""
|
path=""
|
||||||
class="pt-3"
|
class="pt-3"
|
||||||
allowed={diffFiles()}
|
modified={diffFiles()}
|
||||||
kinds={kinds()}
|
kinds={kinds()}
|
||||||
draggable={false}
|
onFileClick={(node) => openTab(file.tab(node.path))}
|
||||||
active={props.activeDiff}
|
|
||||||
onFileClick={(node) => props.focusReviewDiff(node.path)}
|
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Match>
|
||||||
</Match>
|
</Switch>
|
||||||
<Match when={true}>{empty(props.empty())}</Match>
|
</Tabs.Content>
|
||||||
</Switch>
|
</Tabs>
|
||||||
</Tabs.Content>
|
|
||||||
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
|
|
||||||
<Switch>
|
|
||||||
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
|
|
||||||
<Match when={true}>
|
|
||||||
<FileTree
|
|
||||||
path=""
|
|
||||||
class="pt-3"
|
|
||||||
modified={diffFiles()}
|
|
||||||
kinds={kinds()}
|
|
||||||
onFileClick={(node) => openTab(file.tab(node.path))}
|
|
||||||
/>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</Tabs.Content>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
<Show when={fileOpen()}>
|
|
||||||
<div onPointerDown={() => props.size.start()}>
|
|
||||||
<ResizeHandle
|
|
||||||
direction="horizontal"
|
|
||||||
edge="start"
|
|
||||||
size={layout.fileTree.width()}
|
|
||||||
min={200}
|
|
||||||
max={480}
|
|
||||||
onResize={(width) => {
|
|
||||||
props.size.touch()
|
|
||||||
layout.fileTree.resize(width)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
<Show when={fileOpen()}>
|
||||||
</div>
|
<div onPointerDown={() => props.size.start()}>
|
||||||
|
<ResizeHandle
|
||||||
|
direction="horizontal"
|
||||||
|
edge="start"
|
||||||
|
size={layout.fileTree.width()}
|
||||||
|
min={200}
|
||||||
|
max={480}
|
||||||
|
onResize={(width) => {
|
||||||
|
props.size.touch()
|
||||||
|
layout.fileTree.resize(width)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ 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 { usePermission } from "@/context/permission"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
import { usePrompt } from "@/context/prompt"
|
import { usePrompt } from "@/context/prompt"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useTerminal } from "@/context/terminal"
|
import { useTerminal } from "@/context/terminal"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
@@ -39,8 +41,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
|
const platform = usePlatform()
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
const settings = useSettings()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const terminal = useTerminal()
|
const terminal = useTerminal()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
@@ -66,6 +70,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
})
|
})
|
||||||
const activeFileTab = tabState.activeFileTab
|
const activeFileTab = tabState.activeFileTab
|
||||||
const closableTab = tabState.closableTab
|
const closableTab = tabState.closableTab
|
||||||
|
const shown = () =>
|
||||||
|
platform.platform !== "desktop" ||
|
||||||
|
import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" ||
|
||||||
|
settings.general.showFileTree()
|
||||||
|
|
||||||
const idle = { type: "idle" as const }
|
const idle = { type: "idle" as const }
|
||||||
const status = () => sync.data.session_status[params.id ?? ""] ?? idle
|
const status = () => sync.data.session_status[params.id ?? ""] ?? idle
|
||||||
@@ -457,12 +465,16 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
keybind: "mod+shift+r",
|
keybind: "mod+shift+r",
|
||||||
onSelect: () => view().reviewPanel.toggle(),
|
onSelect: () => view().reviewPanel.toggle(),
|
||||||
}),
|
}),
|
||||||
viewCommand({
|
...(shown()
|
||||||
id: "fileTree.toggle",
|
? [
|
||||||
title: language.t("command.fileTree.toggle"),
|
viewCommand({
|
||||||
keybind: "mod+\\",
|
id: "fileTree.toggle",
|
||||||
onSelect: () => layout.fileTree.toggle(),
|
title: language.t("command.fileTree.toggle"),
|
||||||
}),
|
keybind: "mod+\\",
|
||||||
|
onSelect: () => layout.fileTree.toggle(),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
viewCommand({
|
viewCommand({
|
||||||
id: "input.focus",
|
id: "input.focus",
|
||||||
title: language.t("command.input.focus"),
|
title: language.t("command.input.focus"),
|
||||||
|
|||||||
@@ -469,7 +469,7 @@ export function persisted<T>(
|
|||||||
state,
|
state,
|
||||||
setState,
|
setState,
|
||||||
init,
|
init,
|
||||||
Object.assign(() => ready() === true, {
|
Object.assign(() => (ready.loading ? false : ready.latest === true), {
|
||||||
promise: init instanceof Promise ? init : undefined,
|
promise: init instanceof Promise ? init : undefined,
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.4.6",
|
"version": "1.14.18",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const dict = {
|
|||||||
"nav.enterprise": "المؤسسات",
|
"nav.enterprise": "المؤسسات",
|
||||||
"nav.zen": "Zen",
|
"nav.zen": "Zen",
|
||||||
"nav.login": "تسجيل الدخول",
|
"nav.login": "تسجيل الدخول",
|
||||||
"nav.free": "مجانا",
|
"nav.free": "تحميل",
|
||||||
"nav.home": "الرئيسية",
|
"nav.home": "الرئيسية",
|
||||||
"nav.openMenu": "فتح القائمة",
|
"nav.openMenu": "فتح القائمة",
|
||||||
"nav.getStartedFree": "ابدأ مجانا",
|
"nav.getStartedFree": "ابدأ مجانا",
|
||||||
@@ -558,6 +558,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "الاستخدام الحالي لـ",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "الاستخدام الحالي لـ",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "هو $",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": "هو $",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "استرداد قسيمة",
|
||||||
|
"workspace.redeem.subtitle": "استرد رمز القسيمة للحصول على رصيد أو مزايا.",
|
||||||
|
"workspace.redeem.placeholder": "أدخل رمز القسيمة",
|
||||||
|
"workspace.redeem.redeem": "استرداد",
|
||||||
|
"workspace.redeem.redeeming": "جارٍ الاسترداد...",
|
||||||
|
"workspace.redeem.success": "تم استرداد القسيمة بنجاح.",
|
||||||
|
|
||||||
"workspace.reload.title": "إعادة الشحن التلقائي",
|
"workspace.reload.title": "إعادة الشحن التلقائي",
|
||||||
"workspace.reload.disabled.before": "إعادة الشحن التلقائي",
|
"workspace.reload.disabled.before": "إعادة الشحن التلقائي",
|
||||||
"workspace.reload.disabled.state": "معطّل",
|
"workspace.reload.disabled.state": "معطّل",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const dict = {
|
|||||||
"nav.enterprise": "Enterprise",
|
"nav.enterprise": "Enterprise",
|
||||||
"nav.zen": "Zen",
|
"nav.zen": "Zen",
|
||||||
"nav.login": "Entrar",
|
"nav.login": "Entrar",
|
||||||
"nav.free": "Grátis",
|
"nav.free": "Download",
|
||||||
"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",
|
||||||
@@ -567,6 +567,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "Uso atual para",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "Uso atual para",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "é $",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": "é $",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "Resgatar Cupom",
|
||||||
|
"workspace.redeem.subtitle": "Resgate um código de cupom para receber créditos ou vantagens.",
|
||||||
|
"workspace.redeem.placeholder": "Digite o código do cupom",
|
||||||
|
"workspace.redeem.redeem": "Resgatar",
|
||||||
|
"workspace.redeem.redeeming": "Resgatando...",
|
||||||
|
"workspace.redeem.success": "Cupom resgatado com sucesso.",
|
||||||
|
|
||||||
"workspace.reload.title": "Recarga Automática",
|
"workspace.reload.title": "Recarga Automática",
|
||||||
"workspace.reload.disabled.before": "A recarga automática está",
|
"workspace.reload.disabled.before": "A recarga automática está",
|
||||||
"workspace.reload.disabled.state": "desativada",
|
"workspace.reload.disabled.state": "desativada",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const dict = {
|
|||||||
"nav.enterprise": "Enterprise",
|
"nav.enterprise": "Enterprise",
|
||||||
"nav.zen": "Zen",
|
"nav.zen": "Zen",
|
||||||
"nav.login": "Log ind",
|
"nav.login": "Log ind",
|
||||||
"nav.free": "Gratis",
|
"nav.free": "Download",
|
||||||
"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",
|
||||||
@@ -563,6 +563,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "Nuværende brug for",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "Nuværende brug for",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "er $",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": "er $",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "Indløs kupon",
|
||||||
|
"workspace.redeem.subtitle": "Indløs en kuponkode for at få kreditter eller fordele.",
|
||||||
|
"workspace.redeem.placeholder": "Indtast kuponkode",
|
||||||
|
"workspace.redeem.redeem": "Indløs",
|
||||||
|
"workspace.redeem.redeeming": "Indløser...",
|
||||||
|
"workspace.redeem.success": "Kuponen blev indløst.",
|
||||||
|
|
||||||
"workspace.reload.title": "Automatisk genopfyldning",
|
"workspace.reload.title": "Automatisk genopfyldning",
|
||||||
"workspace.reload.disabled.before": "Automatisk genopfyldning er",
|
"workspace.reload.disabled.before": "Automatisk genopfyldning er",
|
||||||
"workspace.reload.disabled.state": "deaktiveret",
|
"workspace.reload.disabled.state": "deaktiveret",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const dict = {
|
|||||||
"nav.enterprise": "Enterprise",
|
"nav.enterprise": "Enterprise",
|
||||||
"nav.zen": "Zen",
|
"nav.zen": "Zen",
|
||||||
"nav.login": "Anmelden",
|
"nav.login": "Anmelden",
|
||||||
"nav.free": "Kostenlos",
|
"nav.free": "Download",
|
||||||
"nav.home": "Startseite",
|
"nav.home": "Startseite",
|
||||||
"nav.openMenu": "Menü öffnen",
|
"nav.openMenu": "Menü öffnen",
|
||||||
"nav.getStartedFree": "Kostenlos starten",
|
"nav.getStartedFree": "Kostenlos starten",
|
||||||
@@ -566,6 +566,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "Aktuelle Nutzung für",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "Aktuelle Nutzung für",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "ist $",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": "ist $",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "Gutschein einlösen",
|
||||||
|
"workspace.redeem.subtitle": "Löse einen Gutscheincode ein, um Guthaben oder Vorteile zu erhalten.",
|
||||||
|
"workspace.redeem.placeholder": "Gutscheincode eingeben",
|
||||||
|
"workspace.redeem.redeem": "Einlösen",
|
||||||
|
"workspace.redeem.redeeming": "Wird eingelöst...",
|
||||||
|
"workspace.redeem.success": "Gutschein erfolgreich eingelöst.",
|
||||||
|
|
||||||
"workspace.reload.title": "Auto-Reload",
|
"workspace.reload.title": "Auto-Reload",
|
||||||
"workspace.reload.disabled.before": "Auto-Reload ist",
|
"workspace.reload.disabled.before": "Auto-Reload ist",
|
||||||
"workspace.reload.disabled.state": "deaktiviert",
|
"workspace.reload.disabled.state": "deaktiviert",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const dict = {
|
|||||||
"nav.zen": "Zen",
|
"nav.zen": "Zen",
|
||||||
"nav.go": "Go",
|
"nav.go": "Go",
|
||||||
"nav.login": "Login",
|
"nav.login": "Login",
|
||||||
"nav.free": "Free",
|
"nav.free": "Download",
|
||||||
"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",
|
||||||
@@ -559,6 +559,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "Current usage for",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "Current usage for",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "is $",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": "is $",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "Redeem Coupon",
|
||||||
|
"workspace.redeem.subtitle": "Redeem a coupon code to claim credits or perks.",
|
||||||
|
"workspace.redeem.placeholder": "Enter coupon code",
|
||||||
|
"workspace.redeem.redeem": "Redeem",
|
||||||
|
"workspace.redeem.redeeming": "Redeeming...",
|
||||||
|
"workspace.redeem.success": "Coupon redeemed successfully.",
|
||||||
|
|
||||||
"workspace.reload.title": "Auto Reload",
|
"workspace.reload.title": "Auto Reload",
|
||||||
"workspace.reload.disabled.before": "Auto reload is",
|
"workspace.reload.disabled.before": "Auto reload is",
|
||||||
"workspace.reload.disabled.state": "disabled",
|
"workspace.reload.disabled.state": "disabled",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const dict = {
|
|||||||
"nav.enterprise": "Enterprise",
|
"nav.enterprise": "Enterprise",
|
||||||
"nav.zen": "Zen",
|
"nav.zen": "Zen",
|
||||||
"nav.login": "Iniciar sesión",
|
"nav.login": "Iniciar sesión",
|
||||||
"nav.free": "Gratis",
|
"nav.free": "Descargar",
|
||||||
"nav.home": "Inicio",
|
"nav.home": "Inicio",
|
||||||
"nav.openMenu": "Abrir menú",
|
"nav.openMenu": "Abrir menú",
|
||||||
"nav.getStartedFree": "Empezar gratis",
|
"nav.getStartedFree": "Empezar gratis",
|
||||||
@@ -567,6 +567,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "Uso actual para",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "Uso actual para",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "es $",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": "es $",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "Canjear cupón",
|
||||||
|
"workspace.redeem.subtitle": "Canjea un código de cupón para obtener crédito o beneficios.",
|
||||||
|
"workspace.redeem.placeholder": "Introduce el código del cupón",
|
||||||
|
"workspace.redeem.redeem": "Canjear",
|
||||||
|
"workspace.redeem.redeeming": "Canjeando...",
|
||||||
|
"workspace.redeem.success": "Cupón canjeado correctamente.",
|
||||||
|
|
||||||
"workspace.reload.title": "Auto Recarga",
|
"workspace.reload.title": "Auto Recarga",
|
||||||
"workspace.reload.disabled.before": "La auto recarga está",
|
"workspace.reload.disabled.before": "La auto recarga está",
|
||||||
"workspace.reload.disabled.state": "deshabilitada",
|
"workspace.reload.disabled.state": "deshabilitada",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const dict = {
|
|||||||
"nav.enterprise": "Entreprise",
|
"nav.enterprise": "Entreprise",
|
||||||
"nav.zen": "Zen",
|
"nav.zen": "Zen",
|
||||||
"nav.login": "Se connecter",
|
"nav.login": "Se connecter",
|
||||||
"nav.free": "Gratuit",
|
"nav.free": "Télécharger",
|
||||||
"nav.home": "Accueil",
|
"nav.home": "Accueil",
|
||||||
"nav.openMenu": "Ouvrir le menu",
|
"nav.openMenu": "Ouvrir le menu",
|
||||||
"nav.getStartedFree": "Commencer gratuitement",
|
"nav.getStartedFree": "Commencer gratuitement",
|
||||||
@@ -569,6 +569,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "L'utilisation actuelle pour",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "L'utilisation actuelle pour",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "est de",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": "est de",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "Utiliser un coupon",
|
||||||
|
"workspace.redeem.subtitle": "Utilisez un code promo pour obtenir du crédit ou des avantages.",
|
||||||
|
"workspace.redeem.placeholder": "Saisissez le code promo",
|
||||||
|
"workspace.redeem.redeem": "Utiliser",
|
||||||
|
"workspace.redeem.redeeming": "Utilisation...",
|
||||||
|
"workspace.redeem.success": "Coupon utilisé avec succès.",
|
||||||
|
|
||||||
"workspace.reload.title": "Rechargement automatique",
|
"workspace.reload.title": "Rechargement automatique",
|
||||||
"workspace.reload.disabled.before": "Le rechargement automatique est",
|
"workspace.reload.disabled.before": "Le rechargement automatique est",
|
||||||
"workspace.reload.disabled.state": "désactivé",
|
"workspace.reload.disabled.state": "désactivé",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const dict = {
|
|||||||
"nav.enterprise": "Enterprise",
|
"nav.enterprise": "Enterprise",
|
||||||
"nav.zen": "Zen",
|
"nav.zen": "Zen",
|
||||||
"nav.login": "Accedi",
|
"nav.login": "Accedi",
|
||||||
"nav.free": "Gratis",
|
"nav.free": "Scarica",
|
||||||
"nav.home": "Home",
|
"nav.home": "Home",
|
||||||
"nav.openMenu": "Apri menu",
|
"nav.openMenu": "Apri menu",
|
||||||
"nav.getStartedFree": "Inizia gratis",
|
"nav.getStartedFree": "Inizia gratis",
|
||||||
@@ -565,6 +565,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "Utilizzo attuale per",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "Utilizzo attuale per",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "è $",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": "è $",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "Riscatta Coupon",
|
||||||
|
"workspace.redeem.subtitle": "Riscatta un codice coupon per ottenere credito o vantaggi.",
|
||||||
|
"workspace.redeem.placeholder": "Inserisci il codice coupon",
|
||||||
|
"workspace.redeem.redeem": "Riscatta",
|
||||||
|
"workspace.redeem.redeeming": "Riscatto in corso...",
|
||||||
|
"workspace.redeem.success": "Coupon riscattato con successo.",
|
||||||
|
|
||||||
"workspace.reload.title": "Ricarica Auto",
|
"workspace.reload.title": "Ricarica Auto",
|
||||||
"workspace.reload.disabled.before": "La ricarica auto è",
|
"workspace.reload.disabled.before": "La ricarica auto è",
|
||||||
"workspace.reload.disabled.state": "disabilitata",
|
"workspace.reload.disabled.state": "disabilitata",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const dict = {
|
|||||||
"nav.enterprise": "エンタープライズ",
|
"nav.enterprise": "エンタープライズ",
|
||||||
"nav.zen": "Zen",
|
"nav.zen": "Zen",
|
||||||
"nav.login": "ログイン",
|
"nav.login": "ログイン",
|
||||||
"nav.free": "無料",
|
"nav.free": "ダウンロード",
|
||||||
"nav.home": "ホーム",
|
"nav.home": "ホーム",
|
||||||
"nav.openMenu": "メニューを開く",
|
"nav.openMenu": "メニューを開く",
|
||||||
"nav.getStartedFree": "無料ではじめる",
|
"nav.getStartedFree": "無料ではじめる",
|
||||||
@@ -564,6 +564,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "現在の使用状況(",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "現在の使用状況(",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": ")は $",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": ")は $",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "クーポンを利用",
|
||||||
|
"workspace.redeem.subtitle": "クーポンコードを利用して、クレジットや特典を受け取ります。",
|
||||||
|
"workspace.redeem.placeholder": "クーポンコードを入力",
|
||||||
|
"workspace.redeem.redeem": "利用する",
|
||||||
|
"workspace.redeem.redeeming": "利用中...",
|
||||||
|
"workspace.redeem.success": "クーポンを利用しました。",
|
||||||
|
|
||||||
"workspace.reload.title": "自動チャージ",
|
"workspace.reload.title": "自動チャージ",
|
||||||
"workspace.reload.disabled.before": "自動チャージは",
|
"workspace.reload.disabled.before": "自動チャージは",
|
||||||
"workspace.reload.disabled.state": "無効",
|
"workspace.reload.disabled.state": "無効",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const dict = {
|
|||||||
"nav.enterprise": "엔터프라이즈",
|
"nav.enterprise": "엔터프라이즈",
|
||||||
"nav.zen": "Zen",
|
"nav.zen": "Zen",
|
||||||
"nav.login": "로그인",
|
"nav.login": "로그인",
|
||||||
"nav.free": "무료",
|
"nav.free": "다운로드",
|
||||||
"nav.home": "홈",
|
"nav.home": "홈",
|
||||||
"nav.openMenu": "메뉴 열기",
|
"nav.openMenu": "메뉴 열기",
|
||||||
"nav.getStartedFree": "무료로 시작하기",
|
"nav.getStartedFree": "무료로 시작하기",
|
||||||
@@ -558,6 +558,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "현재",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "현재",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "사용량: $",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": "사용량: $",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "쿠폰 사용",
|
||||||
|
"workspace.redeem.subtitle": "쿠폰 코드를 사용해 크레딧이나 혜택을 받으세요.",
|
||||||
|
"workspace.redeem.placeholder": "쿠폰 코드를 입력하세요",
|
||||||
|
"workspace.redeem.redeem": "사용",
|
||||||
|
"workspace.redeem.redeeming": "사용 중...",
|
||||||
|
"workspace.redeem.success": "쿠폰을 성공적으로 사용했습니다.",
|
||||||
|
|
||||||
"workspace.reload.title": "자동 충전",
|
"workspace.reload.title": "자동 충전",
|
||||||
"workspace.reload.disabled.before": "자동 충전이",
|
"workspace.reload.disabled.before": "자동 충전이",
|
||||||
"workspace.reload.disabled.state": "비활성화",
|
"workspace.reload.disabled.state": "비활성화",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const dict = {
|
|||||||
"nav.enterprise": "Enterprise",
|
"nav.enterprise": "Enterprise",
|
||||||
"nav.zen": "Zen",
|
"nav.zen": "Zen",
|
||||||
"nav.login": "Logg inn",
|
"nav.login": "Logg inn",
|
||||||
"nav.free": "Gratis",
|
"nav.free": "Last ned",
|
||||||
"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",
|
||||||
@@ -564,6 +564,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "Gjeldende forbruk for",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "Gjeldende forbruk for",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "er $",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": "er $",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "Løs inn kupong",
|
||||||
|
"workspace.redeem.subtitle": "Løs inn en kupongkode for å få kreditt eller fordeler.",
|
||||||
|
"workspace.redeem.placeholder": "Skriv inn kupongkode",
|
||||||
|
"workspace.redeem.redeem": "Løs inn",
|
||||||
|
"workspace.redeem.redeeming": "Løser inn...",
|
||||||
|
"workspace.redeem.success": "Kupongen ble løst inn.",
|
||||||
|
|
||||||
"workspace.reload.title": "Auto-påfyll",
|
"workspace.reload.title": "Auto-påfyll",
|
||||||
"workspace.reload.disabled.before": "Auto-påfyll er",
|
"workspace.reload.disabled.before": "Auto-påfyll er",
|
||||||
"workspace.reload.disabled.state": "deaktivert",
|
"workspace.reload.disabled.state": "deaktivert",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const dict = {
|
|||||||
"nav.enterprise": "Enterprise",
|
"nav.enterprise": "Enterprise",
|
||||||
"nav.zen": "Zen",
|
"nav.zen": "Zen",
|
||||||
"nav.login": "Zaloguj się",
|
"nav.login": "Zaloguj się",
|
||||||
"nav.free": "Darmowe",
|
"nav.free": "Pobierz",
|
||||||
"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",
|
||||||
@@ -565,6 +565,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "Aktualne użycie za",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "Aktualne użycie za",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "wynosi $",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": "wynosi $",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "Zrealizuj kupon",
|
||||||
|
"workspace.redeem.subtitle": "Zrealizuj kod kuponu, aby otrzymać środki lub korzyści.",
|
||||||
|
"workspace.redeem.placeholder": "Wpisz kod kuponu",
|
||||||
|
"workspace.redeem.redeem": "Zrealizuj",
|
||||||
|
"workspace.redeem.redeeming": "Realizowanie...",
|
||||||
|
"workspace.redeem.success": "Kupon został zrealizowany.",
|
||||||
|
|
||||||
"workspace.reload.title": "Automatyczne doładowanie",
|
"workspace.reload.title": "Automatyczne doładowanie",
|
||||||
"workspace.reload.disabled.before": "Automatyczne doładowanie jest",
|
"workspace.reload.disabled.before": "Automatyczne doładowanie jest",
|
||||||
"workspace.reload.disabled.state": "wyłączone",
|
"workspace.reload.disabled.state": "wyłączone",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const dict = {
|
|||||||
"nav.enterprise": "Enterprise",
|
"nav.enterprise": "Enterprise",
|
||||||
"nav.zen": "Zen",
|
"nav.zen": "Zen",
|
||||||
"nav.login": "Войти",
|
"nav.login": "Войти",
|
||||||
"nav.free": "Бесплатно",
|
"nav.free": "Скачать",
|
||||||
"nav.home": "Главная",
|
"nav.home": "Главная",
|
||||||
"nav.openMenu": "Открыть меню",
|
"nav.openMenu": "Открыть меню",
|
||||||
"nav.getStartedFree": "Начать бесплатно",
|
"nav.getStartedFree": "Начать бесплатно",
|
||||||
@@ -571,6 +571,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "Текущее использование за",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "Текущее использование за",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "составляет $",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": "составляет $",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "Активировать купон",
|
||||||
|
"workspace.redeem.subtitle": "Активируйте код купона, чтобы получить кредит или бонусы.",
|
||||||
|
"workspace.redeem.placeholder": "Введите код купона",
|
||||||
|
"workspace.redeem.redeem": "Активировать",
|
||||||
|
"workspace.redeem.redeeming": "Активация...",
|
||||||
|
"workspace.redeem.success": "Купон успешно активирован.",
|
||||||
|
|
||||||
"workspace.reload.title": "Автопополнение",
|
"workspace.reload.title": "Автопополнение",
|
||||||
"workspace.reload.disabled.before": "Автопополнение",
|
"workspace.reload.disabled.before": "Автопополнение",
|
||||||
"workspace.reload.disabled.state": "отключено",
|
"workspace.reload.disabled.state": "отключено",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const dict = {
|
|||||||
"nav.enterprise": "องค์กร",
|
"nav.enterprise": "องค์กร",
|
||||||
"nav.zen": "Zen",
|
"nav.zen": "Zen",
|
||||||
"nav.login": "เข้าสู่ระบบ",
|
"nav.login": "เข้าสู่ระบบ",
|
||||||
"nav.free": "ฟรี",
|
"nav.free": "ดาวน์โหลด",
|
||||||
"nav.home": "หน้าหลัก",
|
"nav.home": "หน้าหลัก",
|
||||||
"nav.openMenu": "เปิดเมนู",
|
"nav.openMenu": "เปิดเมนู",
|
||||||
"nav.getStartedFree": "เริ่มต้นฟรี",
|
"nav.getStartedFree": "เริ่มต้นฟรี",
|
||||||
@@ -560,6 +560,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "การใช้งานปัจจุบันสำหรับ",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "การใช้งานปัจจุบันสำหรับ",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "คือ $",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": "คือ $",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "แลกคูปอง",
|
||||||
|
"workspace.redeem.subtitle": "แลกรหัสคูปองเพื่อรับเครดิตหรือสิทธิพิเศษ",
|
||||||
|
"workspace.redeem.placeholder": "กรอกรหัสคูปอง",
|
||||||
|
"workspace.redeem.redeem": "แลก",
|
||||||
|
"workspace.redeem.redeeming": "กำลังแลก...",
|
||||||
|
"workspace.redeem.success": "แลกคูปองสำเร็จ",
|
||||||
|
|
||||||
"workspace.reload.title": "โหลดซ้ำอัตโนมัติ",
|
"workspace.reload.title": "โหลดซ้ำอัตโนมัติ",
|
||||||
"workspace.reload.disabled.before": "การโหลดซ้ำอัตโนมัติ",
|
"workspace.reload.disabled.before": "การโหลดซ้ำอัตโนมัติ",
|
||||||
"workspace.reload.disabled.state": "ปิดใช้งานอยู่",
|
"workspace.reload.disabled.state": "ปิดใช้งานอยู่",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const dict = {
|
|||||||
"nav.enterprise": "Kurumsal",
|
"nav.enterprise": "Kurumsal",
|
||||||
"nav.zen": "Zen",
|
"nav.zen": "Zen",
|
||||||
"nav.login": "Giriş",
|
"nav.login": "Giriş",
|
||||||
"nav.free": "Ücretsiz",
|
"nav.free": "İndir",
|
||||||
"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",
|
||||||
@@ -567,6 +567,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "Şu anki kullanım",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "Şu anki kullanım",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "$",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": "$",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "Kupon Kullan",
|
||||||
|
"workspace.redeem.subtitle": "Kredi veya avantajlardan yararlanmak için bir kupon kodu kullanın.",
|
||||||
|
"workspace.redeem.placeholder": "Kupon kodunu girin",
|
||||||
|
"workspace.redeem.redeem": "Kullan",
|
||||||
|
"workspace.redeem.redeeming": "Kullanılıyor...",
|
||||||
|
"workspace.redeem.success": "Kupon başarıyla kullanıldı.",
|
||||||
|
|
||||||
"workspace.reload.title": "Otomatik Yeniden Yükleme",
|
"workspace.reload.title": "Otomatik Yeniden Yükleme",
|
||||||
"workspace.reload.disabled.before": "Otomatik yeniden yükleme:",
|
"workspace.reload.disabled.before": "Otomatik yeniden yükleme:",
|
||||||
"workspace.reload.disabled.state": "devre dışı",
|
"workspace.reload.disabled.state": "devre dışı",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const dict = {
|
|||||||
"nav.enterprise": "企业版",
|
"nav.enterprise": "企业版",
|
||||||
"nav.zen": "Zen",
|
"nav.zen": "Zen",
|
||||||
"nav.login": "登录",
|
"nav.login": "登录",
|
||||||
"nav.free": "免费",
|
"nav.free": "下载",
|
||||||
"nav.home": "首页",
|
"nav.home": "首页",
|
||||||
"nav.openMenu": "打开菜单",
|
"nav.openMenu": "打开菜单",
|
||||||
"nav.getStartedFree": "免费开始",
|
"nav.getStartedFree": "免费开始",
|
||||||
@@ -542,6 +542,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "当前",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "当前",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "的使用量为 $",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": "的使用量为 $",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "兑换优惠券",
|
||||||
|
"workspace.redeem.subtitle": "兑换优惠码以领取充值额度或权益。",
|
||||||
|
"workspace.redeem.placeholder": "输入优惠码",
|
||||||
|
"workspace.redeem.redeem": "兑换",
|
||||||
|
"workspace.redeem.redeeming": "兑换中...",
|
||||||
|
"workspace.redeem.success": "优惠券兑换成功。",
|
||||||
|
|
||||||
"workspace.reload.title": "自动充值",
|
"workspace.reload.title": "自动充值",
|
||||||
"workspace.reload.disabled.before": "自动充值已",
|
"workspace.reload.disabled.before": "自动充值已",
|
||||||
"workspace.reload.disabled.state": "禁用",
|
"workspace.reload.disabled.state": "禁用",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const dict = {
|
|||||||
"nav.enterprise": "企業",
|
"nav.enterprise": "企業",
|
||||||
"nav.zen": "Zen",
|
"nav.zen": "Zen",
|
||||||
"nav.login": "登入",
|
"nav.login": "登入",
|
||||||
"nav.free": "免費",
|
"nav.free": "下載",
|
||||||
"nav.home": "首頁",
|
"nav.home": "首頁",
|
||||||
"nav.openMenu": "開啟選單",
|
"nav.openMenu": "開啟選單",
|
||||||
"nav.getStartedFree": "免費開始使用",
|
"nav.getStartedFree": "免費開始使用",
|
||||||
@@ -542,6 +542,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "目前",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "目前",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "的使用量為 $",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": "的使用量為 $",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "兌換優惠券",
|
||||||
|
"workspace.redeem.subtitle": "兌換優惠碼以領取儲值額度或權益。",
|
||||||
|
"workspace.redeem.placeholder": "輸入優惠碼",
|
||||||
|
"workspace.redeem.redeem": "兌換",
|
||||||
|
"workspace.redeem.redeeming": "兌換中...",
|
||||||
|
"workspace.redeem.success": "優惠券兌換成功。",
|
||||||
|
|
||||||
"workspace.reload.title": "自動儲值",
|
"workspace.reload.title": "自動儲值",
|
||||||
"workspace.reload.disabled.before": "自動儲值已",
|
"workspace.reload.disabled.before": "自動儲值已",
|
||||||
"workspace.reload.disabled.state": "停用",
|
"workspace.reload.disabled.state": "停用",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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 { Resource } from "@opencode-ai/console-resource"
|
||||||
import { i18n } from "~/i18n"
|
import { i18n } from "~/i18n"
|
||||||
import { localeFromRequest } from "~/lib/language"
|
import { localeFromRequest } from "~/lib/language"
|
||||||
import { createLead } from "~/lib/salesforce"
|
import { createLead } from "~/lib/salesforce"
|
||||||
@@ -14,6 +15,64 @@ interface EnterpriseFormData {
|
|||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EMAIL_OCTOPUS_LIST_ID = "1b381e5e-39bd-11f1-ba4a-cdd4791f0c43"
|
||||||
|
|
||||||
|
function splitFullName(fullName: string) {
|
||||||
|
const parts = fullName
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((p) => p.length > 0)
|
||||||
|
if (parts.length === 0) return { firstName: "", lastName: "" }
|
||||||
|
if (parts.length === 1) return { firstName: parts[0], lastName: "" }
|
||||||
|
return { firstName: parts[0], lastName: parts.slice(1).join(" ") }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEmailOctopusApiKey() {
|
||||||
|
if (process.env.EMAILOCTOPUS_API_KEY) return process.env.EMAILOCTOPUS_API_KEY
|
||||||
|
try {
|
||||||
|
return Resource.EMAILOCTOPUS_API_KEY.value
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(email: string, fullName: string) {
|
||||||
|
const apiKey = getEmailOctopusApiKey()
|
||||||
|
if (!apiKey) {
|
||||||
|
console.warn("Skipping EmailOctopus subscribe: missing API key")
|
||||||
|
return Promise.resolve(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = splitFullName(fullName)
|
||||||
|
const fields: Record<string, string> = {}
|
||||||
|
if (name.firstName) fields.FirstName = name.firstName
|
||||||
|
if (name.lastName) fields.LastName = name.lastName
|
||||||
|
|
||||||
|
const payload: { email_address: string; fields?: Record<string, string> } = { email_address: email }
|
||||||
|
if (Object.keys(fields).length) payload.fields = fields
|
||||||
|
|
||||||
|
return fetch(`https://api.emailoctopus.com/lists/${EMAIL_OCTOPUS_LIST_ID}/contacts`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}).then(
|
||||||
|
(res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error("EmailOctopus subscribe failed:", res.status, res.statusText)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
console.error("Failed to subscribe enterprise email:", err)
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(event: APIEvent) {
|
export async function POST(event: APIEvent) {
|
||||||
const dict = i18n(localeFromRequest(event.request))
|
const dict = i18n(localeFromRequest(event.request))
|
||||||
try {
|
try {
|
||||||
@@ -41,7 +100,7 @@ ${body.role}<br>
|
|||||||
${body.company ? `${body.company}<br>` : ""}${body.email}<br>
|
${body.company ? `${body.company}<br>` : ""}${body.email}<br>
|
||||||
${body.phone ? `${body.phone}<br>` : ""}`.trim()
|
${body.phone ? `${body.phone}<br>` : ""}`.trim()
|
||||||
|
|
||||||
const [lead, mail] = await Promise.all([
|
const [lead, mail, octopus] = await Promise.all([
|
||||||
createLead({
|
createLead({
|
||||||
name: body.name,
|
name: body.name,
|
||||||
role: body.role,
|
role: body.role,
|
||||||
@@ -49,6 +108,9 @@ ${body.phone ? `${body.phone}<br>` : ""}`.trim()
|
|||||||
email: body.email,
|
email: body.email,
|
||||||
phone: body.phone,
|
phone: body.phone,
|
||||||
message: body.message,
|
message: body.message,
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error("Failed to create Salesforce lead:", err)
|
||||||
|
return false
|
||||||
}),
|
}),
|
||||||
AWS.sendEmail({
|
AWS.sendEmail({
|
||||||
to: "contact@anoma.ly",
|
to: "contact@anoma.ly",
|
||||||
@@ -62,9 +124,14 @@ ${body.phone ? `${body.phone}<br>` : ""}`.trim()
|
|||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
subscribe(body.email, body.name),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (!lead && !mail) {
|
if (!lead && !mail && !octopus) {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.warn("Enterprise inquiry accepted in dev mode without integrations", { email: body.email })
|
||||||
|
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
|
||||||
|
}
|
||||||
console.error("Enterprise inquiry delivery failed", { email: body.email })
|
console.error("Enterprise inquiry delivery failed", { email: body.email })
|
||||||
return Response.json({ error: dict["enterprise.form.error.internalServer"] }, { status: 500 })
|
return Response.json({ error: dict["enterprise.form.error.internalServer"] }, { status: 500 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Actor } from "@opencode-ai/console-core/actor.js"
|
|||||||
import { Resource } from "@opencode-ai/console-resource"
|
import { Resource } from "@opencode-ai/console-resource"
|
||||||
import { LiteData } from "@opencode-ai/console-core/lite.js"
|
import { LiteData } from "@opencode-ai/console-core/lite.js"
|
||||||
import { BlackData } from "@opencode-ai/console-core/black.js"
|
import { BlackData } from "@opencode-ai/console-core/black.js"
|
||||||
|
import { User } from "@opencode-ai/console-core/user.js"
|
||||||
|
|
||||||
export async function POST(input: APIEvent) {
|
export async function POST(input: APIEvent) {
|
||||||
const body = await Billing.stripe().webhooks.constructEventAsync(
|
const body = await Billing.stripe().webhooks.constructEventAsync(
|
||||||
@@ -109,6 +110,8 @@ export async function POST(input: APIEvent) {
|
|||||||
if (type === "lite") {
|
if (type === "lite") {
|
||||||
const workspaceID = body.data.object.metadata?.workspaceID
|
const workspaceID = body.data.object.metadata?.workspaceID
|
||||||
const userID = body.data.object.metadata?.userID
|
const userID = body.data.object.metadata?.userID
|
||||||
|
const userEmail = body.data.object.metadata?.userEmail
|
||||||
|
const coupon = body.data.object.metadata?.coupon
|
||||||
const customerID = body.data.object.customer as string
|
const customerID = body.data.object.customer as string
|
||||||
const invoiceID = body.data.object.latest_invoice as string
|
const invoiceID = body.data.object.latest_invoice as string
|
||||||
const subscriptionID = body.data.object.id as string
|
const subscriptionID = body.data.object.id as string
|
||||||
@@ -156,6 +159,10 @@ export async function POST(input: APIEvent) {
|
|||||||
id: Identifier.create("lite"),
|
id: Identifier.create("lite"),
|
||||||
userID: userID,
|
userID: userID,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (userEmail && coupon === LiteData.firstMonth100Coupon) {
|
||||||
|
await Billing.redeemCoupon(userEmail, "GOFREEMONTH")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { BillingSection } from "./billing-section"
|
|||||||
import { ReloadSection } from "./reload-section"
|
import { ReloadSection } from "./reload-section"
|
||||||
import { PaymentSection } from "./payment-section"
|
import { PaymentSection } from "./payment-section"
|
||||||
import { BlackSection } from "./black-section"
|
import { BlackSection } from "./black-section"
|
||||||
|
import { RedeemSection } from "./redeem-section"
|
||||||
import { createMemo, Show } from "solid-js"
|
import { createMemo, Show } from "solid-js"
|
||||||
import { createAsync, useParams } from "@solidjs/router"
|
import { createAsync, useParams } from "@solidjs/router"
|
||||||
import { queryBillingInfo, querySessionInfo } from "../../common"
|
import { queryBillingInfo, querySessionInfo } from "../../common"
|
||||||
@@ -21,6 +22,7 @@ export default function () {
|
|||||||
<BlackSection />
|
<BlackSection />
|
||||||
</Show>
|
</Show>
|
||||||
<BillingSection />
|
<BillingSection />
|
||||||
|
<RedeemSection />
|
||||||
<Show when={billingInfo()?.customerID}>
|
<Show when={billingInfo()?.customerID}>
|
||||||
<ReloadSection />
|
<ReloadSection />
|
||||||
<MonthlyLimitSection />
|
<MonthlyLimitSection />
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
.root {
|
||||||
|
[data-slot="redeem-container"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
min-width: 20rem;
|
||||||
|
width: fit-content;
|
||||||
|
|
||||||
|
@media (max-width: 30rem) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="redeem-form"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
|
||||||
|
[data-slot="input-row"] {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: stretch;
|
||||||
|
|
||||||
|
@media (max-width: 30rem) {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--color-text-disabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="form-error"] {
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="form-success"] {
|
||||||
|
color: var(--color-success, var(--color-accent));
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { json, action, useParams, useSubmission } from "@solidjs/router"
|
||||||
|
import { Show } from "solid-js"
|
||||||
|
import { withActor } from "~/context/auth.withActor"
|
||||||
|
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||||
|
import { User } from "@opencode-ai/console-core/user.js"
|
||||||
|
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||||
|
import { CouponType } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||||
|
import styles from "./redeem-section.module.css"
|
||||||
|
import { queryBillingInfo } from "../../common"
|
||||||
|
import { useI18n } from "~/context/i18n"
|
||||||
|
import { formError, localizeError } from "~/lib/form-error"
|
||||||
|
|
||||||
|
const redeem = action(async (form: FormData) => {
|
||||||
|
"use server"
|
||||||
|
const workspaceID = form.get("workspaceID") as string | null
|
||||||
|
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||||
|
const code = (form.get("code") as string | null)?.trim().toUpperCase()
|
||||||
|
if (!code) return { error: "Coupon code is required." }
|
||||||
|
if (!(CouponType as readonly string[]).includes(code)) return { error: "Invalid coupon code." }
|
||||||
|
|
||||||
|
return json(
|
||||||
|
await withActor(async () => {
|
||||||
|
const actor = Actor.assert("user")
|
||||||
|
const email = await User.getAuthEmail(actor.properties.userID)
|
||||||
|
if (!email) return { error: "No email on account." }
|
||||||
|
return Billing.redeemCoupon(email, code as (typeof CouponType)[number])
|
||||||
|
.then(() => ({ error: undefined, data: true }))
|
||||||
|
.catch((e) => ({ error: e.message as string }))
|
||||||
|
}, workspaceID),
|
||||||
|
{ revalidate: queryBillingInfo.key },
|
||||||
|
)
|
||||||
|
}, "billing.redeemCoupon")
|
||||||
|
|
||||||
|
export function RedeemSection() {
|
||||||
|
const params = useParams()
|
||||||
|
const i18n = useI18n()
|
||||||
|
const submission = useSubmission(redeem)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section class={styles.root}>
|
||||||
|
<div data-slot="section-title">
|
||||||
|
<h2>{i18n.t("workspace.redeem.title")}</h2>
|
||||||
|
<p>{i18n.t("workspace.redeem.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
<div data-slot="redeem-container">
|
||||||
|
<form action={redeem} method="post" data-slot="redeem-form">
|
||||||
|
<div data-slot="input-row">
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
data-component="input"
|
||||||
|
name="code"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder={i18n.t("workspace.redeem.placeholder")}
|
||||||
|
/>
|
||||||
|
<button type="submit" data-color="primary" disabled={submission.pending}>
|
||||||
|
{submission.pending ? i18n.t("workspace.redeem.redeeming") : i18n.t("workspace.redeem.redeem")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Show when={submission.result && (submission.result as any).error}>
|
||||||
|
{(err: any) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
|
||||||
|
</Show>
|
||||||
|
<Show when={submission.result && !(submission.result as any).error && (submission.result as any).data}>
|
||||||
|
<div data-slot="form-success">{i18n.t("workspace.redeem.success")}</div>
|
||||||
|
</Show>
|
||||||
|
<input type="hidden" name="workspaceID" value={params.id} />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ 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 { i18n, type Key } from "~/i18n"
|
||||||
import { localeFromRequest } from "~/lib/language"
|
import { localeFromRequest } from "~/lib/language"
|
||||||
|
import { createModelTpmLimiter } from "./modelTpmLimiter"
|
||||||
|
|
||||||
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
|
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
|
||||||
type RetryOptions = {
|
type RetryOptions = {
|
||||||
@@ -121,6 +122,8 @@ export async function handler(
|
|||||||
const authInfo = await authenticate(modelInfo, zenApiKey)
|
const authInfo = await authenticate(modelInfo, zenApiKey)
|
||||||
const billingSource = validateBilling(authInfo, modelInfo)
|
const billingSource = validateBilling(authInfo, modelInfo)
|
||||||
logger.metric({ source: billingSource })
|
logger.metric({ source: billingSource })
|
||||||
|
const modelTpmLimiter = createModelTpmLimiter(modelInfo.providers)
|
||||||
|
const modelTpmLimits = await modelTpmLimiter?.check()
|
||||||
|
|
||||||
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
|
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
|
||||||
const providerInfo = selectProvider(
|
const providerInfo = selectProvider(
|
||||||
@@ -133,6 +136,7 @@ export async function handler(
|
|||||||
trialProviders,
|
trialProviders,
|
||||||
retry,
|
retry,
|
||||||
stickyProvider,
|
stickyProvider,
|
||||||
|
modelTpmLimits,
|
||||||
)
|
)
|
||||||
validateModelSettings(billingSource, authInfo)
|
validateModelSettings(billingSource, authInfo)
|
||||||
updateProviderKey(authInfo, providerInfo)
|
updateProviderKey(authInfo, providerInfo)
|
||||||
@@ -229,6 +233,7 @@ export async function handler(
|
|||||||
const usageInfo = providerInfo.normalizeUsage(json.usage)
|
const usageInfo = providerInfo.normalizeUsage(json.usage)
|
||||||
const costInfo = calculateCost(modelInfo, usageInfo)
|
const costInfo = calculateCost(modelInfo, usageInfo)
|
||||||
await trialLimiter?.track(usageInfo)
|
await trialLimiter?.track(usageInfo)
|
||||||
|
await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo)
|
||||||
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
|
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
|
||||||
await reload(billingSource, authInfo, costInfo)
|
await reload(billingSource, authInfo, costInfo)
|
||||||
json.cost = calculateOccurredCost(billingSource, costInfo)
|
json.cost = calculateOccurredCost(billingSource, costInfo)
|
||||||
@@ -278,6 +283,7 @@ export async function handler(
|
|||||||
const usageInfo = providerInfo.normalizeUsage(usage)
|
const usageInfo = providerInfo.normalizeUsage(usage)
|
||||||
const costInfo = calculateCost(modelInfo, usageInfo)
|
const costInfo = calculateCost(modelInfo, usageInfo)
|
||||||
await trialLimiter?.track(usageInfo)
|
await trialLimiter?.track(usageInfo)
|
||||||
|
await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo)
|
||||||
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
|
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
|
||||||
await reload(billingSource, authInfo, costInfo)
|
await reload(billingSource, authInfo, costInfo)
|
||||||
const cost = calculateOccurredCost(billingSource, costInfo)
|
const cost = calculateOccurredCost(billingSource, costInfo)
|
||||||
@@ -433,12 +439,16 @@ export async function handler(
|
|||||||
trialProviders: string[] | undefined,
|
trialProviders: string[] | undefined,
|
||||||
retry: RetryOptions,
|
retry: RetryOptions,
|
||||||
stickyProvider: string | undefined,
|
stickyProvider: string | undefined,
|
||||||
|
modelTpmLimits: Record<string, number> | undefined,
|
||||||
) {
|
) {
|
||||||
const modelProvider = (() => {
|
const modelProvider = (() => {
|
||||||
|
// Byok is top priority b/c if user set their own API key, we should use it
|
||||||
|
// instead of using the sticky provider for the same session
|
||||||
if (authInfo?.provider?.credentials) {
|
if (authInfo?.provider?.credentials) {
|
||||||
return modelInfo.providers.find((provider) => provider.id === modelInfo.byokProvider)
|
return modelInfo.providers.find((provider) => provider.id === modelInfo.byokProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always use the same provider for the same session
|
||||||
if (stickyProvider) {
|
if (stickyProvider) {
|
||||||
const provider = modelInfo.providers.find((provider) => provider.id === stickyProvider)
|
const provider = modelInfo.providers.find((provider) => provider.id === stickyProvider)
|
||||||
if (provider) return provider
|
if (provider) return provider
|
||||||
@@ -451,10 +461,20 @@ export async function handler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (retry.retryCount !== MAX_FAILOVER_RETRIES) {
|
if (retry.retryCount !== MAX_FAILOVER_RETRIES) {
|
||||||
const providers = modelInfo.providers
|
const allProviders = modelInfo.providers
|
||||||
.filter((provider) => !provider.disabled)
|
.filter((provider) => !provider.disabled)
|
||||||
|
.filter((provider) => provider.weight !== 0)
|
||||||
.filter((provider) => !retry.excludeProviders.includes(provider.id))
|
.filter((provider) => !retry.excludeProviders.includes(provider.id))
|
||||||
.flatMap((provider) => Array<typeof provider>(provider.weight ?? 1).fill(provider))
|
.filter((provider) => {
|
||||||
|
if (!provider.tpmLimit) return true
|
||||||
|
const usage = modelTpmLimits?.[`${provider.id}/${provider.model}`] ?? 0
|
||||||
|
return usage < provider.tpmLimit * 1_000_000
|
||||||
|
})
|
||||||
|
|
||||||
|
const topPriority = Math.min(...allProviders.map((p) => p.priority))
|
||||||
|
const providers = allProviders
|
||||||
|
.filter((p) => p.priority <= topPriority)
|
||||||
|
.flatMap((provider) => Array<typeof provider>(provider.weight).fill(provider))
|
||||||
|
|
||||||
// Use the last 4 characters of session ID to select a provider
|
// Use the last 4 characters of session ID to select a provider
|
||||||
const identifier = sessionId.length ? sessionId : ip
|
const identifier = sessionId.length ? sessionId : ip
|
||||||
@@ -742,7 +762,8 @@ export async function handler(
|
|||||||
const billing = authInfo.billing
|
const billing = authInfo.billing
|
||||||
const billingUrl = `https://opencode.ai/workspace/${authInfo.workspaceID}/billing`
|
const billingUrl = `https://opencode.ai/workspace/${authInfo.workspaceID}/billing`
|
||||||
const membersUrl = `https://opencode.ai/workspace/${authInfo.workspaceID}/members`
|
const membersUrl = `https://opencode.ai/workspace/${authInfo.workspaceID}/members`
|
||||||
if (!billing.paymentMethodID) throw new CreditsError(t("zen.api.error.noPaymentMethod", { billingUrl }))
|
if (!billing.paymentMethodID && billing.balance <= 0)
|
||||||
|
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(t("zen.api.error.insufficientBalance", { billingUrl }))
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { and, Database, eq, inArray, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
||||||
|
import { ModelRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
|
||||||
|
import { UsageInfo } from "./provider/provider"
|
||||||
|
|
||||||
|
export function createModelTpmLimiter(providers: { id: string; model: string; tpmLimit?: number }[]) {
|
||||||
|
const keys = providers.filter((p) => p.tpmLimit).map((p) => `${p.id}/${p.model}`)
|
||||||
|
if (keys.length === 0) return
|
||||||
|
|
||||||
|
const yyyyMMddHHmm = new Date(Date.now())
|
||||||
|
.toISOString()
|
||||||
|
.replace(/[^0-9]/g, "")
|
||||||
|
.substring(0, 12)
|
||||||
|
|
||||||
|
return {
|
||||||
|
check: async () => {
|
||||||
|
const data = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select()
|
||||||
|
.from(ModelRateLimitTable)
|
||||||
|
.where(and(inArray(ModelRateLimitTable.key, keys), eq(ModelRateLimitTable.interval, yyyyMMddHHmm))),
|
||||||
|
)
|
||||||
|
|
||||||
|
// convert to map of model to count
|
||||||
|
return data.reduce(
|
||||||
|
(acc, curr) => {
|
||||||
|
acc[curr.key] = curr.count
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{} as Record<string, number>,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
track: async (id: string, model: string, usageInfo: UsageInfo) => {
|
||||||
|
const key = `${id}/${model}`
|
||||||
|
if (!keys.includes(key)) return
|
||||||
|
const usage =
|
||||||
|
usageInfo.inputTokens +
|
||||||
|
usageInfo.outputTokens +
|
||||||
|
(usageInfo.reasoningTokens ?? 0) +
|
||||||
|
(usageInfo.cacheReadTokens ?? 0) +
|
||||||
|
(usageInfo.cacheWrite5mTokens ?? 0) +
|
||||||
|
(usageInfo.cacheWrite1hTokens ?? 0)
|
||||||
|
if (usage <= 0) return
|
||||||
|
await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.insert(ModelRateLimitTable)
|
||||||
|
.values({ key, interval: yyyyMMddHHmm, count: usage })
|
||||||
|
.onDuplicateKeyUpdate({ set: { count: sql`${ModelRateLimitTable.count} + ${usage}` } }),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE TABLE `model_rate_limit` (
|
||||||
|
`key` varchar(255) NOT NULL,
|
||||||
|
`interval` varchar(40) NOT NULL,
|
||||||
|
`count` int NOT NULL,
|
||||||
|
CONSTRAINT PRIMARY KEY(`key`,`interval`)
|
||||||
|
);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE TABLE `coupon` (
|
||||||
|
`email` varchar(255),
|
||||||
|
`type` enum('BUILDATHON','GOFREEMONTH') NOT NULL,
|
||||||
|
`time_redeemed` timestamp(3),
|
||||||
|
CONSTRAINT PRIMARY KEY(`email`,`type`)
|
||||||
|
);
|
||||||
+2619
File diff suppressed because it is too large
Load Diff
@@ -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.4.6",
|
"version": "1.14.18",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Database } from "../src/drizzle/index.js"
|
||||||
|
import { CouponTable, CouponType } from "../src/schema/billing.sql.js"
|
||||||
|
|
||||||
|
const email = process.argv[2]
|
||||||
|
const type = process.argv[3] as (typeof CouponType)[number]
|
||||||
|
|
||||||
|
if (!email || !type) {
|
||||||
|
console.error(`Usage: bun create-coupon.ts <email> <${CouponType.join("|")}>`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(CouponType as readonly string[]).includes(type)) {
|
||||||
|
console.error(`Error: type must be one of ${CouponType.join(", ")}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
await Database.use((tx) =>
|
||||||
|
tx.insert(CouponTable).values({
|
||||||
|
email,
|
||||||
|
type,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log(`Created ${type} coupon for ${email}`)
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
import { Stripe } from "stripe"
|
import { Stripe } from "stripe"
|
||||||
import { Database, eq, sql } from "./drizzle"
|
import { and, Database, eq, isNull, sql } from "./drizzle"
|
||||||
import { BillingTable, LiteTable, PaymentTable, SubscriptionTable, UsageTable } from "./schema/billing.sql"
|
import {
|
||||||
|
BillingTable,
|
||||||
|
CouponTable,
|
||||||
|
CouponType,
|
||||||
|
LiteTable,
|
||||||
|
PaymentTable,
|
||||||
|
SubscriptionTable,
|
||||||
|
UsageTable,
|
||||||
|
} from "./schema/billing.sql"
|
||||||
import { Actor } from "./actor"
|
import { Actor } from "./actor"
|
||||||
import { fn } from "./util/fn"
|
import { fn } from "./util/fn"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
@@ -147,6 +155,37 @@ export namespace Billing {
|
|||||||
return amountInMicroCents
|
return amountInMicroCents
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const redeemCoupon = async (email: string, type: (typeof CouponType)[number]) => {
|
||||||
|
const coupon = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select()
|
||||||
|
.from(CouponTable)
|
||||||
|
.where(and(eq(CouponTable.email, email), eq(CouponTable.type, type)))
|
||||||
|
.then((rows) => rows[0]),
|
||||||
|
)
|
||||||
|
if (!coupon) throw new Error("Invalid coupon code")
|
||||||
|
if (coupon.timeRedeemed) throw new Error("Coupon already redeemed")
|
||||||
|
|
||||||
|
if (type === "BUILDATHON") await grantCredit(Actor.workspace(), 500)
|
||||||
|
|
||||||
|
await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.update(CouponTable)
|
||||||
|
.set({ timeRedeemed: sql`now()` })
|
||||||
|
.where(and(eq(CouponTable.email, email), eq(CouponTable.type, type))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hasCoupon = async (email: string, type: (typeof CouponType)[number]) => {
|
||||||
|
return await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select()
|
||||||
|
.from(CouponTable)
|
||||||
|
.where(and(eq(CouponTable.email, email), eq(CouponTable.type, type), isNull(CouponTable.timeRedeemed)))
|
||||||
|
.then((rows) => rows.length > 0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const setMonthlyLimit = fn(z.number(), async (input) => {
|
export const setMonthlyLimit = fn(z.number(), async (input) => {
|
||||||
return await Database.use((tx) =>
|
return await Database.use((tx) =>
|
||||||
tx
|
tx
|
||||||
@@ -245,16 +284,19 @@ export namespace Billing {
|
|||||||
const user = Actor.assert("user")
|
const user = Actor.assert("user")
|
||||||
const { successUrl, cancelUrl, method } = input
|
const { successUrl, cancelUrl, method } = input
|
||||||
|
|
||||||
const email = await User.getAuthEmail(user.properties.userID)
|
const email = (await User.getAuthEmail(user.properties.userID))!
|
||||||
const billing = await Billing.get()
|
const billing = await Billing.get()
|
||||||
|
|
||||||
if (billing.subscriptionID) throw new Error("Already subscribed to Black")
|
if (billing.subscriptionID) throw new Error("Already subscribed to Black")
|
||||||
if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite")
|
if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite")
|
||||||
|
|
||||||
|
const coupon = (await Billing.hasCoupon(email, "GOFREEMONTH"))
|
||||||
|
? LiteData.firstMonth100Coupon
|
||||||
|
: LiteData.firstMonth50Coupon
|
||||||
const createSession = () =>
|
const createSession = () =>
|
||||||
Billing.stripe().checkout.sessions.create({
|
Billing.stripe().checkout.sessions.create({
|
||||||
mode: "subscription",
|
mode: "subscription",
|
||||||
discounts: [{ coupon: LiteData.firstMonthCoupon(email!) }],
|
discounts: [{ coupon }],
|
||||||
...(billing.customerID
|
...(billing.customerID
|
||||||
? {
|
? {
|
||||||
customer: billing.customerID,
|
customer: billing.customerID,
|
||||||
@@ -264,7 +306,7 @@ export namespace Billing {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
customer_email: email!,
|
customer_email: email,
|
||||||
}),
|
}),
|
||||||
...(() => {
|
...(() => {
|
||||||
if (method === "alipay") {
|
if (method === "alipay") {
|
||||||
@@ -312,6 +354,8 @@ export namespace Billing {
|
|||||||
metadata: {
|
metadata: {
|
||||||
workspaceID: Actor.workspace(),
|
workspaceID: Actor.workspace(),
|
||||||
userID: user.properties.userID,
|
userID: user.properties.userID,
|
||||||
|
userEmail: email,
|
||||||
|
coupon,
|
||||||
type: "lite",
|
type: "lite",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,11 +11,7 @@ export namespace LiteData {
|
|||||||
export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product)
|
export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product)
|
||||||
export const priceID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.price)
|
export const priceID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.price)
|
||||||
export const priceInr = fn(z.void(), () => Resource.ZEN_LITE_PRICE.priceInr)
|
export const priceInr = fn(z.void(), () => Resource.ZEN_LITE_PRICE.priceInr)
|
||||||
export const firstMonthCoupon = fn(z.string(), (email) => {
|
export const firstMonth100Coupon = Resource.ZEN_LITE_PRICE.firstMonth100Coupon
|
||||||
const invitees = Resource.ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES.value.split(",")
|
export const firstMonth50Coupon = Resource.ZEN_LITE_PRICE.firstMonth50Coupon
|
||||||
return invitees.includes(email)
|
|
||||||
? Resource.ZEN_LITE_PRICE.firstMonth100Coupon
|
|
||||||
: Resource.ZEN_LITE_PRICE.firstMonth50Coupon
|
|
||||||
})
|
|
||||||
export const planName = fn(z.void(), () => "lite")
|
export const planName = fn(z.void(), () => "lite")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export namespace ZenData {
|
|||||||
z.object({
|
z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
model: z.string(),
|
model: z.string(),
|
||||||
|
priority: z.number().optional(),
|
||||||
|
tpmLimit: z.number().optional(),
|
||||||
weight: z.number().optional(),
|
weight: z.number().optional(),
|
||||||
disabled: z.boolean().optional(),
|
disabled: z.boolean().optional(),
|
||||||
storeModel: z.string().optional(),
|
storeModel: z.string().optional(),
|
||||||
@@ -123,10 +125,16 @@ export namespace ZenData {
|
|||||||
),
|
),
|
||||||
models: (() => {
|
models: (() => {
|
||||||
const normalize = (model: z.infer<typeof ModelSchema>) => {
|
const normalize = (model: z.infer<typeof ModelSchema>) => {
|
||||||
const composite = model.providers.find((p) => compositeProviders[p.id].length > 1)
|
const providers = model.providers.map((p) => ({
|
||||||
|
...p,
|
||||||
|
priority: p.priority ?? Infinity,
|
||||||
|
weight: p.weight ?? 1,
|
||||||
|
}))
|
||||||
|
const composite = providers.find((p) => compositeProviders[p.id].length > 1)
|
||||||
if (!composite)
|
if (!composite)
|
||||||
return {
|
return {
|
||||||
trialProvider: model.trialProvider ? [model.trialProvider] : undefined,
|
trialProvider: model.trialProvider ? [model.trialProvider] : undefined,
|
||||||
|
providers,
|
||||||
}
|
}
|
||||||
|
|
||||||
const weightMulti = compositeProviders[composite.id].length
|
const weightMulti = compositeProviders[composite.id].length
|
||||||
@@ -137,17 +145,16 @@ export namespace ZenData {
|
|||||||
if (model.trialProvider === composite.id) return compositeProviders[composite.id].map((p) => p.id)
|
if (model.trialProvider === composite.id) return compositeProviders[composite.id].map((p) => p.id)
|
||||||
return [model.trialProvider]
|
return [model.trialProvider]
|
||||||
})(),
|
})(),
|
||||||
providers: model.providers.flatMap((p) =>
|
providers: providers.flatMap((p) =>
|
||||||
p.id === composite.id
|
p.id === composite.id
|
||||||
? compositeProviders[p.id].map((sub) => ({
|
? compositeProviders[p.id].map((sub) => ({
|
||||||
...p,
|
...p,
|
||||||
id: sub.id,
|
id: sub.id,
|
||||||
weight: p.weight ?? 1,
|
|
||||||
}))
|
}))
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
...p,
|
...p,
|
||||||
weight: (p.weight ?? 1) * weightMulti,
|
weight: p.weight * weightMulti,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
import { bigint, boolean, index, int, json, mysqlEnum, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
|
import {
|
||||||
|
bigint,
|
||||||
|
boolean,
|
||||||
|
index,
|
||||||
|
int,
|
||||||
|
json,
|
||||||
|
mysqlEnum,
|
||||||
|
mysqlTable,
|
||||||
|
primaryKey,
|
||||||
|
uniqueIndex,
|
||||||
|
varchar,
|
||||||
|
} from "drizzle-orm/mysql-core"
|
||||||
import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types"
|
import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types"
|
||||||
import { workspaceIndexes } from "./workspace.sql"
|
import { workspaceIndexes } from "./workspace.sql"
|
||||||
|
|
||||||
@@ -121,3 +132,14 @@ export const UsageTable = mysqlTable(
|
|||||||
},
|
},
|
||||||
(table) => [...workspaceIndexes(table), index("usage_time_created").on(table.workspaceID, table.timeCreated)],
|
(table) => [...workspaceIndexes(table), index("usage_time_created").on(table.workspaceID, table.timeCreated)],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const CouponType = ["BUILDATHON", "GOFREEMONTH"] as const
|
||||||
|
export const CouponTable = mysqlTable(
|
||||||
|
"coupon",
|
||||||
|
{
|
||||||
|
email: varchar("email", { length: 255 }),
|
||||||
|
type: mysqlEnum("type", CouponType).notNull(),
|
||||||
|
timeRedeemed: utc("time_redeemed"),
|
||||||
|
},
|
||||||
|
(table) => [primaryKey({ columns: [table.email, table.type] })],
|
||||||
|
)
|
||||||
|
|||||||
@@ -30,3 +30,13 @@ export const KeyRateLimitTable = mysqlTable(
|
|||||||
},
|
},
|
||||||
(table) => [primaryKey({ columns: [table.key, table.interval] })],
|
(table) => [primaryKey({ columns: [table.key, table.interval] })],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const ModelRateLimitTable = mysqlTable(
|
||||||
|
"model_rate_limit",
|
||||||
|
{
|
||||||
|
key: varchar("key", { length: 255 }).notNull(),
|
||||||
|
interval: varchar("interval", { length: 40 }).notNull(),
|
||||||
|
count: int("count").notNull(),
|
||||||
|
},
|
||||||
|
(table) => [primaryKey({ columns: [table.key, table.interval] })],
|
||||||
|
)
|
||||||
|
|||||||
Vendored
-4
@@ -142,10 +142,6 @@ declare module "sst" {
|
|||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
}
|
}
|
||||||
"ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": {
|
|
||||||
"type": "sst.sst.Secret"
|
|
||||||
"value": string
|
|
||||||
}
|
|
||||||
"ZEN_LITE_PRICE": {
|
"ZEN_LITE_PRICE": {
|
||||||
"firstMonth100Coupon": string
|
"firstMonth100Coupon": string
|
||||||
"firstMonth50Coupon": string
|
"firstMonth50Coupon": string
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.4.6",
|
"version": "1.14.18",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user