Compare commits

..
Author SHA1 Message Date
copilot-swe-agent[bot]andGitHub edfba50d6e fix: resolve merge conflicts from dev branch 2026-05-28 01:41:40 +00:00
copilot-swe-agent[bot]andGitHub b8b16391df chore: resolve merge conflicts with dev branch 2026-05-28 01:39:49 +00:00
DSandGitHub ec26d78450 fix(openai): support websocket custom base URLs (#29636) 2026-05-27 18:46:30 -05:00
opencode-agent[bot] 49e8d324dd chore: update nix node_modules hashes 2026-05-27 22:16:44 +00:00
SebastianandGitHub 60b9ba4db7 upgrade opentui to 0.2.16 (#29463) 2026-05-28 00:01:32 +02:00
opencode-agent[bot] a1a401dc9b chore: update nix node_modules hashes 2026-05-27 20:22:29 +00:00
opencode-agent[bot] 2f1547ebaf chore: generate 2026-05-27 20:05:11 +00:00
Aiden ClineandGitHub 62da1e7682 feat(openai): add responses websocket transport (#29477) 2026-05-27 15:02:37 -05:00
James LongandGitHub 28a06e52fc feat(tui): add workspace management dialog (#29612) 2026-05-27 18:09:40 +00:00
JackandGitHub 5a5d981c4e docs(zen): add MiMo-V2.5 Free model (#29610) 2026-05-28 01:34:37 +08:00
opencode-agent[bot] 62a7781e04 chore: generate 2026-05-27 16:31:18 +00:00
JackandGitHub 2d03cf4a9a docs(go): update MiMo (#29598) 2026-05-28 00:29:31 +08:00
opencode-agent[bot] 4025d30b24 chore: generate 2026-05-27 15:53:52 +00:00
Adam dd3c443d8c fix: cpu bound ingest server 2026-05-27 10:51:36 -05:00
Adam 4aaa9f9e30 chore: better stats command 2026-05-27 10:50:34 -05:00
Adam 31c805bc97 chore: honeycomb backfill script 2026-05-27 10:37:02 -05:00
SebastianandGitHub 94f2ed1b84 keep session navigation active in prompt modes (#29464) 2026-05-27 17:26:56 +02:00
gotjoshuaandGitHub 86dc66eae9 docs: fix ts error in math examples (#18244) 2026-05-27 10:12:12 -05:00
Aiden ClineandGitHub 9814dc6526 fix(tui): surface subagent retry status (#29591) 2026-05-27 10:11:01 -05:00
Adam f09c859974 fix: memory pressure in ingest 2026-05-27 09:22:08 -05:00
Adam 1fcdb0246a fix: bun install 2026-05-27 08:37:34 -05:00
opencode-agent[bot] 340ad9e9ed chore: generate 2026-05-27 12:47:27 +00:00
Kit LangtonandGitHub 76d814e747 refactor(server): unify instance httpapi middleware routing
publish-github-action / publish (push) Has been cancelled
Unify declared instance HTTP API endpoints under typed middleware routing, including event streaming and PTY WebSocket connect handling.\n\nPreserve PTY connect compatibility by checking missing PTYs before parsing optional cursor and ticket query fields, with regression coverage.
2026-05-27 08:45:11 -04:00
Adam 9941e70d29 fix: better dockerfile 2026-05-27 06:55:44 -05:00
Adam 2afb812620 fix: stats dockerfile
publish-github-action / publish (push) Has been cancelled
2026-05-27 06:39:39 -05:00
Adam ad2fc1ce4b chore: reestablish datalake 2026-05-27 06:17:16 -05:00
Victor NavarroandGitHub 5acc368ef4 perf: use redis for api key rate limit (#29242) 2026-05-27 11:27:06 +02:00
Brendan AllanandGitHub e2dc89c6f3 fix(ui): migrate to --v2-font-family-sans variable and improve home empty state (#29552) 2026-05-27 09:18:52 +00:00
Brendan AllanandGitHub e1581183ff feat(app): allow toggling tabs layout (#29526) 2026-05-27 15:42:21 +08:00
Brendan AllanandGitHub f195c952fc fix(app): show tab close button properly (#29523) 2026-05-27 06:36:04 +00:00
opencode-agent[bot] 34b1045a01 chore: generate 2026-05-27 04:58:46 +00:00
OpeOginniandGitHub aa9956d46d fix(tui): handle non-git project paths when opening editor (#29180) 2026-05-26 23:57:24 -05:00
opencode cec5a752bb sync release versions for v1.15.11 2026-05-27 04:01:11 +00:00
opencode-agent[bot]andGitHub 0f0a4be253 fix(project): restore remote project identity (#29495) 2026-05-27 03:09:20 +00:00
opencode-agent[bot] 0e88dd99e7 chore: generate 2026-05-27 02:24:30 +00:00
Aiden ClineandGitHub f965db9e13 feat: add headerTimeout cfg option, default it on only for openai w/ default of 10s (#29484) 2026-05-26 21:22:24 -05:00
Aiden ClineandGitHub 519d344470 feat(plugin): add dispose hook (#29493) 2026-05-26 21:21:53 -05:00
Aiden ClineandGitHub a78605f8ea fix(plugin): use codex session-id header (#29471) 2026-05-26 19:16:07 -05:00
Aiden ClineandGitHub c0bc020ad6 fix(opencode): disconnect dynamically added mcp servers (#29452) 2026-05-26 18:28:03 -05:00
Dax Raad 6a994d6c67 test(core): run migration generation check on linux 2026-05-26 19:24:09 -04:00
Frank 0e669bbd18 zen: disable models 2026-05-26 18:49:10 -04:00
Dax Raad 00a6f228f2 fix(project): preserve cached project identity 2026-05-26 17:50:42 -04:00
Dax Raad 50f6df3f3a test(core): check migration generation is clean 2026-05-26 14:40:09 -04:00
Dax Raad c1e744f099 fix(core): include domain schemas in migrations 2026-05-26 14:31:58 -04:00
Dax Raad 714cbe6dbd fix(opencode): preserve worktree path when disposing instances 2026-05-26 13:48:52 -04:00
Dax Raad 3077ff3f0c fix(opencode): dispose booted worktrees before removal 2026-05-26 13:16:26 -04:00
Dax Raad 9b5fbbe398 fix(opencode): share httpapi exercise service graph 2026-05-26 12:39:36 -04:00
Dax Raad b5912f50c1 chore: merge dev into event v2 branch 2026-05-26 12:13:39 -04:00
Dax Raad 0364de0af6 refactor(opencode): replace instance bus with event v2 2026-05-26 12:03:42 -04:00
Dax Raad d0dae93c73 sync 2026-05-25 20:39:22 -04:00
Dax Raad 7c14627fc6 chore: merge dev into database wrapper branch 2026-05-25 19:35:44 -04:00
Dax Raad 876ce31fef refactor(opencode): remove legacy database wrapper 2026-05-25 19:26:05 -04:00
Dax Raad c3743b26e8 fix(opencode): preserve session event routing types 2026-05-25 18:23:03 -04:00
Dax Raad b97d254dcc refactor(opencode): migrate session events to core 2026-05-25 18:20:07 -04:00
Dax Raad 087d356d41 chore(core): format event service 2026-05-25 14:25:23 -04:00
Dax Raad 08c17e831f refactor(opencode): move session reads to core database 2026-05-25 14:24:27 -04:00
Dax Raad dad0f579fb refactor(opencode): remove storage db from domain services 2026-05-25 12:21:14 -04:00
Dax Raad b819034c50 fix(opencode): align v2 session endpoint errors 2026-05-25 12:21:06 -04:00
Dax Raad dcbe09bc9f refactor(core): mark session methods unimplemented 2026-05-25 11:44:21 -04:00
Dax Raad 7180c3a7fb fix(core): add session list cursor schema 2026-05-25 01:14:22 -04:00
Dax Raad 9440f7f5b9 test(core): cover sync event replay controls 2026-05-25 00:44:04 -04:00
Dax Raad 13e9e317b1 chore: merge dev into core database branch 2026-05-25 00:34:57 -04:00
Dax Raad 06bd34c953 feat(core): add sync event replay controls 2026-05-25 00:28:30 -04:00
Dax Raad 05dca9f698 feat(core): persist sync event projections 2026-05-25 00:13:17 -04:00
Dax Raad dedcb9ba91 refactor(core): project session events in core 2026-05-24 21:45:01 -04:00
Dax Raad b18ff8fd68 progress 2026-05-24 19:47:55 -04:00
Dax Raad 9ded717fc1 refactor(core): unify sqlite database clients 2026-05-24 19:14:24 -04:00
Dax Raad 9b812d9609 fix(core): migrate sync database clients 2026-05-24 15:26:22 -04:00
Dax Raad aa8ad494b8 refactor(core): centralize legacy session schemas 2026-05-24 15:14:29 -04:00
Dax Raad 46953462d9 refactor(core): centralize project and workspace schemas 2026-05-24 14:55:56 -04:00
Dax Raad 077c91b95d core: avoid session schema import cycle 2026-05-24 12:36:42 -04:00
Dax Raad 31d89590e1 core: isolate native node sqlite provider 2026-05-24 12:22:39 -04:00
Dax Raad 46422a4575 core: support native node sqlite database runtime 2026-05-24 02:11:57 -04:00
Dax Raad 977f48a97b Merge remote-tracking branch 'origin/dev' into refactor/core-database-schema-ownership
# Conflicts:
#	packages/core/src/project.ts
#	packages/opencode/src/project/project.ts
2026-05-24 01:57:05 -04:00
Dax Raad 0cab11f26b refactor(core): move database schema ownership 2026-05-24 01:27:22 -04:00
Dax Raad eaf5697b06 progress 2026-05-22 10:07:53 -04:00
Dax Raad 36160ef28f feat(core): add sqlite schema sync 2026-05-22 10:07:53 -04:00
523 changed files with 13515 additions and 10461 deletions
+3
View File
@@ -2,6 +2,9 @@
"$schema": "https://opencode.ai/config.json",
"provider": {},
"permission": {},
"reference": {
"effect": "github.com/Effect-TS/effect-smol",
},
"mcp": {},
"tools": {
"github-triage": false,
+6
View File
@@ -47,6 +47,12 @@ obj.b
const { a, b } = obj
```
### Imports
- Never alias imports. Do not use `import { foo as bar } from "..."` or renamed imports like `resolve as pathResolve`.
- Never use star imports. Do not use `import * as Foo from "..."` or `import type * as Foo from "..."`.
- If a namespace-style value is needed, import the module's own exported namespace by name, for example `import { Project } from "@opencode-ai/core/project"`, then reference `Project.ID`.
### Variables
Prefer `const` over `let`. Use ternaries or early returns instead of reassignment.
+65 -39
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -85,7 +85,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -121,7 +121,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -148,7 +148,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -170,7 +170,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -194,7 +194,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "0.0.0",
"version": "1.15.11",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@@ -214,7 +214,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.15.10",
"version": "1.15.11",
"bin": {
"opencode": "./bin/opencode",
},
@@ -242,8 +242,11 @@
"@aws-sdk/credential-providers": "3.993.0",
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@npmcli/arborist": "9.4.0",
"@npmcli/config": "10.8.1",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@openrouter/ai-sdk-provider": "2.8.1",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
@@ -251,6 +254,7 @@
"@opentelemetry/sdk-trace-base": "2.6.1",
"ai-gateway-provider": "3.1.2",
"cross-spawn": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"gitlab-ai-provider": "6.7.0",
"glob": "13.0.5",
@@ -271,11 +275,12 @@
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "catalog:",
"drizzle-kit": "catalog:",
},
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"drizzle-orm": "catalog:",
@@ -330,7 +335,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -342,9 +347,21 @@
"@typescript/native-preview": "catalog:",
},
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.15.10",
"dependencies": {
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -374,7 +391,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -390,7 +407,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
@@ -403,7 +420,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -421,7 +438,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.15.10",
"version": "1.15.11",
"bin": {
"opencode": "./bin/opencode",
},
@@ -478,6 +495,7 @@
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/scheduled": "1.5.2",
"@standard-schema/spec": "1.0.0",
"@types/ws": "8.18.1",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
"ai-gateway-provider": "3.1.2",
@@ -519,6 +537,7 @@
"vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"ws": "8.21.0",
"xdg-basedir": "5.1.0",
"yargs": "18.0.0",
"zod": "catalog:",
@@ -549,7 +568,6 @@
"@types/which": "3.0.4",
"@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:",
"drizzle-kit": "catalog:",
"drizzle-orm": "catalog:",
"prettier": "3.6.2",
"typescript": "catalog:",
@@ -559,7 +577,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -575,9 +593,9 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.2.15",
"@opentui/keymap": ">=0.2.15",
"@opentui/solid": ">=0.2.15",
"@opentui/core": ">=0.2.16",
"@opentui/keymap": ">=0.2.16",
"@opentui/solid": ">=0.2.16",
},
"optionalPeers": [
"@opentui/core",
@@ -597,7 +615,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -612,7 +630,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -625,7 +643,7 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.14.50",
"version": "1.15.11",
"dependencies": {
"@opencode-ai/stats-core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -648,7 +666,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.14.50",
"version": "1.15.11",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -667,7 +685,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.14.50",
"version": "1.15.11",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -707,7 +725,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -756,7 +774,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -824,9 +842,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.2.15",
"@opentui/keymap": "0.2.15",
"@opentui/solid": "0.2.15",
"@opentui/core": "0.2.16",
"@opentui/keymap": "0.2.16",
"@opentui/solid": "0.2.16",
"@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -1660,6 +1678,8 @@
"@opencode-ai/effect-drizzle-sqlite": ["@opencode-ai/effect-drizzle-sqlite@workspace:packages/effect-drizzle-sqlite"],
"@opencode-ai/effect-sqlite-node": ["@opencode-ai/effect-sqlite-node@workspace:packages/effect-sqlite-node"],
"@opencode-ai/enterprise": ["@opencode-ai/enterprise@workspace:packages/enterprise"],
"@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"],
@@ -1716,23 +1736,23 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@opentui/core": ["@opentui/core@0.2.15", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.15", "@opentui/core-darwin-x64": "0.2.15", "@opentui/core-linux-arm64": "0.2.15", "@opentui/core-linux-x64": "0.2.15", "@opentui/core-win32-arm64": "0.2.15", "@opentui/core-win32-x64": "0.2.15" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-YGHttdZWScMcSvtYgZkLR6VhUO1OoUiQzwYjZgIusf5eCkPLD8PapH+PTMVqAiX16CHO6JxfMlkHv5qDiHAccQ=="],
"@opentui/core": ["@opentui/core@0.2.16", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.16", "@opentui/core-darwin-x64": "0.2.16", "@opentui/core-linux-arm64": "0.2.16", "@opentui/core-linux-x64": "0.2.16", "@opentui/core-win32-arm64": "0.2.16", "@opentui/core-win32-x64": "0.2.16" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-4vWN15Zc3nsXJlOiHhhpqkBXD+wrNFKxCPtiTiillZYDRre+XsZogVTOOGUDwaBIC23OSxq7imezLmmtShVBEA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-s25f9GmZd6wxNM5ExRmwwnLT+NLCKxnTWuO9aObOlqsXfLMGHQZrb6YwgAn/PSTua98KmH7GJCVWdPgZ/P+0RQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aFb2Yp+oqDu3h6VCWi7xpQ9yjpKSQcROzGGfHgqC6Nd3U+uiLfPJBkmiI87iK0opCggCFj5TkKI004050DmGjg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-GyaipN+nOcEr8rcTO2mqKTGmOBk0C300I69fLtubD3BadHcMI1DVNlQrcf/J1mkQEuMYbmBTi/1hT1ybWGr2Mw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-KimiHE0j7EsTB5P8doW0lr1eH5iZKLPKWQO+tmy1VcdYr/TzqhdHSvGuJXrZvfTFi9/rV57Eq0d7964Ri9O0vQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-h+uyufselGT4afKMP8Lg4yUl5Kp+DJBlhu3XpWXhphE5Pnq5+f0uGBr4P+34CNcWxMsDnvagSQLFRCS4rGrOWA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-4fCwRCfTtUgS/5QcSEkSuBjgQymSOUWXgrXG2ycrf3Swi0QhKDA/pVjwLrUJ6eF+/8mQyQSEV72T8MxMO3M2qg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.15", "", { "os": "linux", "cpu": "x64" }, "sha512-jx+NImPq4wSp3Apfe7tlixiEJNnRyECTRJRWhGF6ZJz4PwFfgK2UHZKYR0DZHbV8nYawoDNQPJDXEWcoZShnMg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.16", "", { "os": "linux", "cpu": "x64" }, "sha512-KgQBGjiucw4e7gM+R8qOzHWBFhjCY1IfCrGjW3Wzxv2hKUlL+mPhelaeJwnEqtNxMUdVTYjlwlu3IHxslXMJWQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-2SQQLvf3sgmToxrNika9AdcccKrjPJEn5jW6sSv0oEixNBzUzW41vSZZG4LM/V3lL8eg0LoYDnRZeKLB4gwSqQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-C6WqEI3VkXatXraMgSFXZjEXq0pzURGjRpFAJZYmuVDmpqE57o7E80Np2UkdZ6m5kpJDt4mRyu3krc/P825iNQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.15", "", { "os": "win32", "cpu": "x64" }, "sha512-SVMVgnC7LVEm+yVZKdmmhRBj/xAT94PanT+UCcHxaCWK+OLmv/AX+ohHq2m0odup6iXcEqj+7mAltO9fgJLFIg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.16", "", { "os": "win32", "cpu": "x64" }, "sha512-kCX3CMTns6DMCFDNTDV4sjmBKyA/iEvzaVhl/jYi4JRIVT2zcy1lo+lhXT5mPgYHmJZu8Uye6j3Zi3c7Z2Me5A=="],
"@opentui/keymap": ["@opentui/keymap@0.2.15", "", { "dependencies": { "@opentui/core": "0.2.15" }, "peerDependencies": { "@opentui/react": "0.2.15", "@opentui/solid": "0.2.15", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-wqQp6y7P2jZZJiOMwupxjGryuSWCs+njjglwW/xny9J17gomBmUvTIcIIWNG0Jv+EGO9ScBzCScGlwBHFhHyYw=="],
"@opentui/keymap": ["@opentui/keymap@0.2.16", "", { "dependencies": { "@opentui/core": "0.2.16" }, "peerDependencies": { "@opentui/react": "0.2.16", "@opentui/solid": "0.2.16", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-YBLQfNLbU2kx49bjEY9rrFoNlvIoi5qNJfRcOt6frvnR3C6MLl0/8hZY+vMQ2PEQWeEiNejFnl1lQw+z4Nk2FQ=="],
"@opentui/solid": ["@opentui/solid@0.2.15", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.15", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-CViepAjsCWXwrLndMt+qlLo7cooVX7DXwSJHNizw7mfrRJtOPzSYJZCIk1vF4IJTWffCHygoYMe3uSeKvzAcbw=="],
"@opentui/solid": ["@opentui/solid@0.2.16", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.16", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-2Q+v1PPpXXr+sALi9Aj6I5Jvo7xDfbmstYjRLL7lW3Hghh9i7ONQKpt/gyDDRbhSsYrhxKYTNenF9OxgoXkTHg=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -5138,7 +5158,7 @@
"write-file-atomic": ["write-file-atomic@7.0.1", "", { "dependencies": { "signal-exit": "^4.0.1" } }, "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg=="],
"ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
"wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
@@ -5496,6 +5516,8 @@
"@cloudflare/kv-asset-handler/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
"@cloudflare/vite-plugin/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
"@develar/schema-utils/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
@@ -5958,6 +5980,8 @@
"miniflare/undici": ["undici@7.14.0", "", {}, "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ=="],
"miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
"miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="],
"minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
@@ -6074,6 +6098,8 @@
"storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"storybook/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
"storybook-solidjs-vite/vite-plugin-solid": ["vite-plugin-solid@2.11.12", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA=="],
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
+2 -2
View File
@@ -209,8 +209,8 @@ const ingestConfig = new sst.Linkable("LakeIngestConfig", {
const ingestService = new sst.aws.Service("LakeIngestService", {
cluster: lakeCluster,
architecture: "arm64",
cpu: "0.5 vCPU",
memory: "1 GB",
cpu: "1 vCPU",
memory: "4 GB",
image: {
context: ".",
dockerfile: "packages/stats/server/Dockerfile",
+1 -3
View File
@@ -5,10 +5,8 @@ export const domain = (() => {
})()
export const zoneID = "430ba34c138cfb5360826c4909f99be8"
// Dev owns the shared AWS lake/stats infra for all non-production stages.
export const awsStage = $app.stage === "production" ? "production" : "dev"
// Temporarily omit AWS infra so SST removes the lake/stats resources.
export const deployAws = false
export const deployAws = $app.stage === awsStage
new cloudflare.RegionalHostname("RegionalHostname", {
hostname: domain,
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-FT8N4SBP7OmVu73OwNyPJvBoxFd2+IXzNnFubB8y6J0=",
"aarch64-linux": "sha256-rGst01voY1/qr/fIMEgfL2zep/+W2RhjJh5pKs+/P3s=",
"aarch64-darwin": "sha256-+oTdR3CuR88Ra3QQsDn7ixNwJf0YjOxySTSiRsPPUOg=",
"x86_64-darwin": "sha256-3ZkB83KWtp6N7u9xhzmCouv27UuisXYvr2SBap4k++E="
"x86_64-linux": "sha256-KFVkxs0jtN1EXvwuxFMOlWg6BDr8L6K/wJZ/N73p8cI=",
"aarch64-linux": "sha256-7OLglRBBt5prwb9e0I0iMTXi3MrDfDI+8xcDrxtq/JI=",
"aarch64-darwin": "sha256-Q7iE6xJyomDnvqUgsHh+FlCnJFI6vK0zJSfI9BBYitQ=",
"x86_64-darwin": "sha256-9lVsq2Bw74Vgphg6GSo/HxCtDXUyabxcS/vE1Um/Hfw="
}
}
+4 -4
View File
@@ -10,7 +10,7 @@
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
"dev:stats": "bun run --cwd packages/stats/app dev",
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"lint": "oxlint",
"typecheck": "bun turbo typecheck",
@@ -38,9 +38,9 @@
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.2.15",
"@opentui/keymap": "0.2.15",
"@opentui/solid": "0.2.15",
"@opentui/core": "0.2.16",
"@opentui/keymap": "0.2.16",
"@opentui/solid": "0.2.16",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.15.10",
"version": "1.15.11",
"description": "",
"type": "module",
"exports": {
+19 -6
View File
@@ -14,6 +14,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect"
import {
type Component,
createEffect,
createMemo,
createResource,
createSignal,
@@ -40,7 +41,7 @@ import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission"
import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
import { SettingsProvider } from "@/context/settings"
import { SettingsProvider, useSettings } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal"
import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout"
@@ -48,11 +49,6 @@ import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
import { ServersProvider } from "./context/servers"
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "prod") {
document.body.classList.remove("text-12-regular")
document.body.classList.add("font-(family-name:--font-family-text)", "text-[13px]", "font-[440]")
}
const HomeRoute = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
@@ -97,9 +93,26 @@ function QueryProvider(props: ParentProps) {
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
}
function BodyDesignClass() {
const settings = useSettings()
createEffect(() => {
if (typeof document === "undefined") return
const enabled = settings.general.newLayoutDesigns()
document.body.classList.toggle("text-12-regular", !enabled)
document.body.classList.toggle("font-(family-name:--font-family-text)", enabled)
document.body.classList.toggle("text-[13px]", enabled)
document.body.classList.toggle("font-[440]", enabled)
})
return null
}
function AppShellProviders(props: ParentProps) {
return (
<SettingsProvider>
<BodyDesignClass />
<PermissionProvider>
<LayoutProvider>
<NotificationProvider>
+2 -4
View File
@@ -79,8 +79,6 @@ import { pathKey } from "@/utils/path-key"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { displayName } from "@/pages/layout/helpers"
const USE_V2_INPUT = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
interface PromptInputProps {
class?: string
variant?: "dock" | "new-session"
@@ -1456,7 +1454,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/>
<Switch>
<Match when={USE_V2_INPUT}>
<Match when={settings.general.newLayoutDesigns()}>
<div class="flex flex-col gap-3">
<DockShellForm
data-component={newSession() ? "session-new-composer" : "session-composer"}
@@ -1528,7 +1526,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onKeyDown={handleKeyDown}
classList={{
"select-text": true,
"min-h-[52px] w-full px-4 pt-4 pb-2 focus:outline-none whitespace-pre-wrap leading-5 text-[13px] font-[440] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)]": true,
"min-h-[52px] w-full px-4 pt-4 pb-2 focus:outline-none whitespace-pre-wrap leading-5 text-[13px] font-[440] text-v2-text-text-base": true,
"[&_[data-type=file]]:text-syntax-property": true,
"[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell",
@@ -45,8 +45,6 @@ const OPEN_APPS = [
"sublime-text",
] as const
const USE_V2_TITLEBAR = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
type OpenApp = (typeof OPEN_APPS)[number]
type OS = "macos" | "windows" | "linux" | "unknown"
@@ -157,11 +155,11 @@ export function SessionHeader() {
})
const hotkey = createMemo(() => command.keybind("file.open"))
const os = createMemo(() => detectOS(platform))
const isDesktopV2 = platform.platform === "desktop" && USE_V2_TITLEBAR
const search = createMemo(() => (isDesktopV2 ? settings.general.showSearch() : true))
const tree = createMemo(() => (isDesktopV2 ? settings.general.showFileTree() : true))
const term = createMemo(() => (isDesktopV2 ? settings.general.showTerminal() : true))
const status = createMemo(() => (isDesktopV2 ? settings.general.showStatus() : true))
const isDesktopV2 = createMemo(() => platform.platform === "desktop" && settings.general.newLayoutDesigns())
const search = createMemo(() => (isDesktopV2() ? settings.general.showSearch() : true))
const tree = createMemo(() => (isDesktopV2() ? settings.general.showFileTree() : true))
const term = createMemo(() => (isDesktopV2() ? settings.general.showTerminal() : true))
const status = createMemo(() => (isDesktopV2() ? settings.general.showStatus() : true))
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
finder: true,
@@ -399,6 +399,18 @@ export const SettingsGeneral: Component = () => {
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.newLayoutDesigns.title")}
description={language.t("settings.general.row.newLayoutDesigns.description")}
>
<div data-action="settings-new-layout-designs">
<Switch
checked={settings.general.newLayoutDesigns()}
onChange={(checked) => settings.general.setNewLayoutDesigns(checked)}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
@@ -802,7 +814,7 @@ export const SettingsGeneral: Component = () => {
<DisplaySection />
<Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
<Show when={desktop()}>
<AdvancedSection />
</Show>
</div>
+9 -11
View File
@@ -55,7 +55,6 @@ const legacyTitlebarHeight = 40
const v2TitlebarHeight = 44
const minTitlebarZoom = 0.25
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
const USE_V2_TITLEBAR = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const makeSessionHref = (b64Dir: string, sessionId: string) => `/${b64Dir}/session/${sessionId}`
@@ -75,6 +74,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const navigate = useNavigate()
const location = useLocation()
const params = useParams()
const useV2Titlebar = createMemo(() => settings.general.newLayoutDesigns())
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
@@ -85,7 +85,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
const minHeight = () => {
const height = USE_V2_TITLEBAR ? v2TitlebarHeight : legacyTitlebarHeight
const height = useV2Titlebar() ? v2TitlebarHeight : legacyTitlebarHeight
if (mac()) return `${height / zoom()}px`
if (windows()) return `${height / Math.min(titlebarZoom(), 1)}px`
return undefined
@@ -119,7 +119,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const canBack = createMemo(() => history.index > 0)
const canForward = createMemo(() => history.index < history.stack.length - 1)
const hasProjects = createMemo(() => layout.projects.list().length > 0)
const nav = createMemo(() => (USE_V2_TITLEBAR ? settings.general.showNavigation() : true))
const nav = createMemo(() => (useV2Titlebar() ? settings.general.showNavigation() : true))
const updateState = createMemo<TitlebarUpdatePillState>(() => {
const version = props.update?.version()
return {
@@ -222,8 +222,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<header
classList={{
"shrink-0 relative overflow-hidden flex flex-row": true,
"h-11 bg-v2-background-bg-deep": USE_V2_TITLEBAR,
"h-10 bg-background-base": !USE_V2_TITLEBAR,
"h-11 bg-v2-background-bg-deep": useV2Titlebar(),
"h-10 bg-background-base": !useV2Titlebar(),
}}
style={{
"min-height": minHeight(),
@@ -239,7 +239,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
onDblClick={maximize}
>
<Switch>
<Match when={USE_V2_TITLEBAR}>
<Match when={useV2Titlebar()}>
{(_) => {
const serverSync = useServerSync()
const navigate = useNavigate()
@@ -490,7 +490,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
project={projectForSession(tab.info, projects(), projectByID())}
directory={tab.dir}
onClose={() => tabsStoreActions.removeTab(tab.href)}
hideClose={tabsEnriched().length < 2}
/>
</>
)}
@@ -737,14 +736,13 @@ function TabNavItem(props: {
title: string
project?: LocalProject
directory: string
hideClose?: boolean
onClose: () => void
}) {
const match = useMatch(() => props.href)
const isActive = () => !!match()
return (
<div
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] pl-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
data-active={isActive()}
>
<a
@@ -755,7 +753,7 @@ function TabNavItem(props: {
<span class="text-clip leading-5">{props.title}</span>
</a>
<div class="absolute right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
<div class="absolute not-group-hover:not-group-data-[active=true]:left-52 group-hover:right-0 group-data-[active=true]:right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
<div
class="absolute inset-0 bg-(image:--inactive-bg) group-hover:bg-(image:--active-bg) group-data-[active=true]:bg-(image:--active-bg)"
style={{
@@ -766,7 +764,7 @@ function TabNavItem(props: {
<IconButtonV2
size="small"
variant="ghost-muted"
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100"
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100 z-10"
onClick={props.onClose}
icon={<IconV2 name="xmark-small" />}
/>
+6
View File
@@ -33,6 +33,7 @@ export interface Settings {
editToolPartsExpanded: boolean
showSessionProgressBar: boolean
showCustomAgents: boolean
newLayoutDesigns?: boolean
}
updates: {
startup: boolean
@@ -54,6 +55,7 @@ export interface Settings {
export const monoDefault = "System Mono"
export const sansDefault = "System Sans"
export const terminalDefault = "JetBrainsMono Nerd Font Mono"
export const newLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const monoFallback =
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
@@ -242,6 +244,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setShowCustomAgents(value: boolean) {
setStore("general", "showCustomAgents", value)
},
newLayoutDesigns: withFallback(() => store.general?.newLayoutDesigns, newLayoutDesignsDefault),
setNewLayoutDesigns(value: boolean) {
setStore("general", "newLayoutDesigns", value)
},
},
updates: {
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
+2
View File
@@ -791,6 +791,8 @@ export const dict = {
"settings.general.row.showSessionProgressBar.title": "Show session progress bar",
"settings.general.row.showSessionProgressBar.description":
"Display the animated progress bar at the top of the session when the agent is working",
"settings.general.row.newLayoutDesigns.title": "New layout and designs",
"settings.general.row.newLayoutDesigns.description": "Enable the redesigned layout, home, composer, and session UI",
"settings.general.row.pinchZoom.title": "Pinch to zoom",
"settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom",
+109 -104
View File
@@ -31,8 +31,8 @@ import { messageAgentColor } from "@/utils/agent"
import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
import { ServerHealthIndicator } from "@/components/server/server-row"
import { useServers } from "@/context/servers"
import { useSettings } from "@/context/settings"
const USE_HOME_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const HOME_SESSION_LIMIT = 15
const HOME_ROW =
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] border-0 bg-transparent text-left text-v2-text-text-muted transition-colors duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
@@ -52,8 +52,12 @@ type HomeSessionGroup = {
}
export default function Home() {
if (USE_HOME_DESIGN) return <HomeDesign />
return <LegacyHome />
const settings = useSettings()
return (
<Show when={settings.general.newLayoutDesigns()} fallback={<LegacyHome />}>
<HomeDesign />
</Show>
)
}
function HomeDesign() {
@@ -68,13 +72,11 @@ function HomeDesign() {
const [state, setState] = createStore({ search: "", project: undefined as string | undefined })
const projects = createMemo(() => layout.projects.list())
const selectedProject = createMemo(
() => projects().find((project) => project.worktree === state.project) ?? projects()[0],
)
const selectedProject = createMemo(() => projects().find((project) => project.worktree === state.project))
const directories = (project: LocalProject) => [project.worktree, ...(project.sandboxes ?? [])]
const projectDirectories = createMemo(() => {
const project = selectedProject()
if (!project) return []
if (!project) return [...projects().flatMap((project) => directories(project))]
return directories(project)
})
const search = createMemo(() => state.search.trim())
@@ -89,8 +91,8 @@ function HomeDesign() {
const projectByID = createMemo(
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
)
const records = createMemo(() =>
[
const records = createMemo(() => {
return [
...new Map(
projectDirectories()
.flatMap((directory) => sortedRootSessions(sync.child(directory, { bootstrap: false })[0], Date.now()))
@@ -112,8 +114,8 @@ function HomeDesign() {
if (!value) return true
return `${record.session.title} ${record.projectName}`.toLowerCase().includes(value)
})
.slice(0, HOME_SESSION_LIMIT),
)
.slice(0, HOME_SESSION_LIMIT)
})
const groups = createMemo(() => groupSessions(records(), language))
function selectProject(directory: string) {
@@ -220,7 +222,7 @@ function HomeDesign() {
aria-label={language.t("sidebar.project.recentSessions")}
>
<Show
when={selectedProject()}
when={projectDirectories().length > 0}
fallback={
<HomeEmptyState
icon="folder-add-left"
@@ -298,8 +300,8 @@ function HomeProjectColumn(props: {
const layout = useLayout()
const projects = createMemo(() => layout.projects.list())
return (
<aside class="flex min-w-0 flex-col lg:pt-[52px]" aria-label={props.language.t("home.projects")}>
<div class="flex h-7 min-w-0 items-center justify-between pl-3">
<aside class="flex min-w-0 flex-col lg:pt-[52px] gap-4" aria-label={props.language.t("home.projects")}>
<div class="flex h-7 min-w-0 items-center justify-between pl-1.5">
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
<IconButtonV2
data-action="home-add-project"
@@ -311,8 +313,8 @@ function HomeProjectColumn(props: {
aria-label={props.language.t("home.project.add")}
/>
</div>
<For
each={servers.list()}
<Show
when={servers.list().length > 1}
fallback={
<ProjectList
projects={projects()}
@@ -328,61 +330,63 @@ function HomeProjectColumn(props: {
/>
}
>
{(server) => {
const key = ServerConnection.key(server)
const healthy = () => !!servers.health[key]?.healthy
const [open, setOpen] = createSignal(true)
<For each={servers.list()}>
{(server) => {
const key = ServerConnection.key(server)
const healthy = () => !!servers.health[key]?.healthy
const [open, setOpen] = createSignal(true)
return (
<div class="mt-4 max-h-[min(572px,calc(100vh_-_300px))] min-w-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div class="relative h-7 group">
<button
class="w-full h-full px-1.5 gap-2 flex flex-row items-center hover:not-disabled:bg-v2-overlay-simple-overlay-hover rounded-[4px]"
disabled={!healthy()}
onClick={() => setOpen((o) => !o)}
>
<div class="size-4 flex items-center justify-center">
<ServerHealthIndicator health={servers.health[key]} />
</div>
<div class="flex flex-row items-center gap-1">
<span>{server.displayName ?? new URL(server.http.url).host}</span>
<Show when={healthy()}>
<IconV2
name="outline-chevron-down"
class="text-v2-icon-icon-muted data-[open=false]:-rotate-90"
data-open={open()}
/>
</Show>
</div>
</button>
<IconButtonV2
class="absolute right-1 inset-y-1 opacity-0 group-hover:opacity-100"
name="out"
variant="ghost-muted"
size="small"
icon={<IconV2 name="outline-dots" class="text-v2-icon-icon-muted" />}
/>
return (
<div class="max-h-[min(572px,calc(100vh_-_300px))] min-w-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div class="relative h-7 group">
<button
class="w-full h-full px-1.5 gap-2 flex flex-row items-center hover:not-disabled:bg-v2-overlay-simple-overlay-hover rounded-[4px]"
disabled={!healthy()}
onClick={() => setOpen((o) => !o)}
>
<div class="size-4 flex items-center justify-center">
<ServerHealthIndicator health={servers.health[key]} />
</div>
<div class="flex flex-row items-center gap-1">
<span>{server.displayName ?? new URL(server.http.url).host}</span>
<Show when={healthy()}>
<IconV2
name="outline-chevron-down"
class="text-v2-icon-icon-muted data-[open=false]:-rotate-90"
data-open={open()}
/>
</Show>
</div>
</button>
<IconButtonV2
class="absolute right-1 inset-y-1 opacity-0 group-hover:opacity-100"
name="out"
variant="ghost-muted"
size="small"
icon={<IconV2 name="outline-dots" class="text-v2-icon-icon-muted" />}
/>
</div>
<Show when={healthy() && open()}>
<div class="h-px bg-v2-border-border-base mx-3 my-1" />
<ProjectList
projects={projects()}
selectedProject={props.selectedProject}
onSelectedProjectChange={props.selectProject}
onChooseProject={props.chooseProject}
openNewSession={props.openNewSession}
editProject={props.editProject}
closeProject={props.closeProject}
clearNotifications={props.clearNotifications}
unseenCount={props.unseenCount}
language={props.language}
/>
</Show>
</div>
<Show when={healthy() && open()}>
<div class="h-px bg-v2-border-border-base mx-3 my-1" />
<ProjectList
projects={projects()}
selectedProject={props.selectedProject}
onSelectedProjectChange={props.selectProject}
onChooseProject={props.chooseProject}
openNewSession={props.openNewSession}
editProject={props.editProject}
closeProject={props.closeProject}
clearNotifications={props.clearNotifications}
unseenCount={props.unseenCount}
language={props.language}
/>
</Show>
</div>
)
}}
</For>
<div class="mt-4 flex min-w-0 flex-col gap-1">
)
}}
</For>
</Show>
<div class="flex min-w-0 flex-col gap-1">
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
@@ -416,12 +420,14 @@ function HomeProjectRow(props: {
language: ReturnType<typeof useLanguage>
}) {
const name = createMemo(() => displayName(props.project))
const [menuOpen, setMenuOpen] = createSignal(false)
return (
<div class="group/project relative flex h-8 min-w-0 items-center rounded-[6px] hover:bg-v2-overlay-simple-overlay-hover focus-within:bg-v2-overlay-simple-overlay-hover">
<div class="group/project relative flex h-8 min-w-0 items-center rounded-[6px]">
<button
type="button"
data-component="home-project-row"
class={`${HOME_PROJECT_NAV_ROW} pr-16`}
class={`${HOME_PROJECT_NAV_ROW} pr-16 peer`}
classList={{ "bg-v2-overlay-simple-overlay-hover": props.selected }}
data-selected={props.selected ? "" : undefined}
aria-current={props.selected ? "page" : undefined}
@@ -430,7 +436,37 @@ function HomeProjectRow(props: {
<HomeProjectAvatar project={props.project} />
<span>{name()}</span>
</button>
<div class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/project:opacity-100 group-focus-within/project:opacity-100">
<div
class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/project:opacity-100 peer-focus-visible:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
data-menu={menuOpen()}
>
<MenuV2 gutter={4} modal={false} placement="bottom-end" open={menuOpen()} onOpenChange={setMenuOpen}>
<MenuV2.Trigger
as={IconButtonV2}
data-action="home-project-menu"
variant="ghost-muted"
size="small"
icon={<IconV2 name="outline-dots" />}
aria-label={props.language.t("common.moreOptions")}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={() => props.openNewSession(props.project.worktree)}>
{props.language.t("command.session.new")}
</MenuV2.Item>
<MenuV2.Item onSelect={() => props.editProject(props.project)}>
{props.language.t("common.edit")}
</MenuV2.Item>
<MenuV2.Item disabled={props.unseenCount === 0} onSelect={() => props.clearNotifications(props.project)}>
{props.language.t("sidebar.project.clearNotifications")}
</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => props.closeProject(props.project.worktree)}>
{props.language.t("common.close")}
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
<IconButtonV2
data-action="home-project-new-session"
variant="ghost-muted"
@@ -442,37 +478,6 @@ function HomeProjectRow(props: {
props.openNewSession(props.project.worktree)
}}
/>
<MenuV2 gutter={4} modal={false} placement="bottom-end">
<MenuV2.Trigger
as={IconButtonV2}
data-action="home-project-menu"
variant="ghost-muted"
size="small"
icon={<IconV2 name="menu" />}
aria-label={props.language.t("common.moreOptions")}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={() => props.openNewSession(props.project.worktree)}>
<Icon name="new-session" size="small" />
{props.language.t("command.session.new")}
</MenuV2.Item>
<MenuV2.Item onSelect={() => props.editProject(props.project)}>
<Icon name="edit" size="small" />
{props.language.t("common.edit")}
</MenuV2.Item>
<MenuV2.Item disabled={props.unseenCount === 0} onSelect={() => props.clearNotifications(props.project)}>
<Icon name="circle-check" size="small" />
{props.language.t("sidebar.project.clearNotifications")}
</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => props.closeProject(props.project.worktree)}>
<Icon name="close" size="small" />
{props.language.t("common.close")}
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</div>
</div>
)
@@ -561,7 +566,7 @@ function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => voi
variant="ghost"
size="normal"
icon="edit"
class="h-7 px-2 text-v2-text-text-muted [font-weight:530]"
class="h-7 px-2 text-v2-text-text-muted"
onClick={onNewSession()}
>
{language.t("command.session.new")}
+160 -160
View File
@@ -89,8 +89,6 @@ import {
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
import { SidebarContent } from "./layout/sidebar-shell"
const USE_NEW_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
export default function Layout(props: ParentProps) {
const [store, setStore, , ready] = persisted(
Persist.global("layout.page", ["layout.page.v1"]),
@@ -129,6 +127,7 @@ export default function Layout(props: ParentProps) {
const command = useCommand()
const theme = useTheme()
const language = useLanguage()
const newDesign = createMemo(() => settings.general.newLayoutDesigns())
const initialDirectory = decode64(params.dir)
const location = useLocation()
const route = createMemo(() => {
@@ -154,7 +153,7 @@ export default function Layout(props: ParentProps) {
const currentDir = createMemo(() => route().dir)
const [state, setState] = createStore({
autoselect: !initialDirectory && !USE_NEW_DESIGN,
autoselect: !initialDirectory && !newDesign(),
busyWorkspaces: {} as Record<string, boolean>,
hoverProject: undefined as string | undefined,
scrollSessionKey: undefined as string | undefined,
@@ -1142,7 +1141,7 @@ export default function Layout(props: ParentProps) {
},
]
if (!USE_NEW_DESIGN)
if (!newDesign())
Array.from({ length: 9 }, (_, i) => {
const index = i
const number = index + 1
@@ -1821,7 +1820,7 @@ export default function Layout(props: ParentProps) {
createEffect(() => {
document.documentElement.style.setProperty(
"--dialog-left-margin",
USE_NEW_DESIGN ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
newDesign() ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
)
})
@@ -2363,179 +2362,180 @@ export default function Layout(props: ParentProps) {
/>
)
if (USE_NEW_DESIGN) {
return (
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
return (
<Show
when={!newDesign()}
fallback={
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
{autoselecting() ?? ""}
<Titlebar update={titlebarUpdate} />
<main
class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict bg-v2-background-bg-base"
classList={{
"m-2 mt-0 rounded-[10px] shadow-[var(--v2-elevation-raised)] overflow-hidden": !!params.id || !params.dir,
}}
>
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>
{import.meta.env.DEV && <DebugBar />}
<Toast.Region />
</div>
}
>
<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() ?? ""}
<Titlebar update={titlebarUpdate} />
<main
class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict bg-v2-background-bg-base"
classList={{
"m-2 mt-0 rounded-[10px] shadow-[var(--v2-elevation-raised)] overflow-hidden": !!params.id || !params.dir,
}}
>
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>
{import.meta.env.DEV && <DebugBar />}
<Toast.Region />
</div>
)
}
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">
{autoselecting() ?? ""}
<Titlebar update={titlebarUpdate} />
<Show when={updateVersion() !== undefined}>
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
</Show>
<div class="flex-1 min-h-0 min-w-0 flex">
<div class="flex-1 min-h-0 relative">
<div class="size-full relative overflow-x-hidden">
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-desktop"
classList={{
"hidden xl:block": true,
"absolute inset-y-0 left-0": true,
"z-10": true,
}}
style={{ width: `${side()}px` }}
ref={(el) => {
setState("nav", el)
}}
onMouseEnter={() => {
disarm()
}}
onMouseLeave={() => {
aim.reset()
if (!sidebarHovering()) return
arm()
}}
>
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
</nav>
<Show when={layout.sidebar.opened()}>
<div
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
style={{ left: `${side()}px` }}
onPointerDown={() => setState("sizing", true)}
>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
onResize={(w) => {
setState("sizing", true)
if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w)
}}
/>
</div>
</Show>
<div
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
style={{ left: "calc(4rem + 12px)" }}
/>
<div class="xl:hidden">
<div
classList={{
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
}}
onClick={(e) => {
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
}}
/>
<Show when={updateVersion() !== undefined}>
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
</Show>
<div class="flex-1 min-h-0 min-w-0 flex">
<div class="flex-1 min-h-0 relative">
<div class="size-full relative overflow-x-hidden">
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-mobile"
data-component="sidebar-nav-desktop"
classList={{
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
"translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(),
"hidden xl:block": true,
"absolute inset-y-0 left-0": true,
"z-10": true,
}}
style={{ width: `${side()}px` }}
ref={(el) => {
setState("nav", el)
}}
onMouseEnter={() => {
disarm()
}}
onMouseLeave={() => {
aim.reset()
if (!sidebarHovering()) return
arm()
}}
onClick={(e) => e.stopPropagation()}
>
{sidebarContent(true)}
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
</nav>
</div>
<div
classList={{
"absolute inset-0": true,
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
"z-20": true,
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
!state.sizing,
}}
style={{
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
}}
>
<main
<Show when={layout.sidebar.opened()}>
<div
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
style={{ left: `${side()}px` }}
onPointerDown={() => setState("sizing", true)}
>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
onResize={(w) => {
setState("sizing", true)
if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w)
}}
/>
</div>
</Show>
<div
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
style={{ left: "calc(4rem + 12px)" }}
/>
<div class="xl:hidden">
<div
classList={{
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
}}
onClick={(e) => {
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
}}
/>
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-mobile"
classList={{
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
"translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(),
}}
onClick={(e) => e.stopPropagation()}
>
{sidebarContent(true)}
</nav>
</div>
<div
classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
"absolute inset-0": true,
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
"z-20": true,
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
!state.sizing,
}}
style={{
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
}}
>
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children}
<main
classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
}}
>
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>
</div>
<div
classList={{
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
"opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
onMouseMove={disarm}
onMouseEnter={() => {
disarm()
aim.reset()
}}
onPointerDown={disarm}
onMouseLeave={() => {
arm()
}}
>
<Show when={peekProject()}>
<SidebarPanel project={peekProject} merged={false} />
</Show>
</main>
</div>
</div>
<div
classList={{
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
"opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
onMouseMove={disarm}
onMouseEnter={() => {
disarm()
aim.reset()
}}
onPointerDown={disarm}
onMouseLeave={() => {
arm()
}}
>
<Show when={peekProject()}>
<SidebarPanel project={peekProject} merged={false} />
</Show>
</div>
<div
classList={{
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
style={{ left: `calc(4rem + ${panel()}px)` }}
>
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
<div
classList={{
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
style={{ left: `calc(4rem + ${panel()}px)` }}
>
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
</div>
</div>
</div>
{import.meta.env.DEV && <DebugBar />}
</div>
{import.meta.env.DEV && <DebugBar />}
<Toast.Region />
</div>
<Toast.Region />
</div>
</Show>
)
}
+4 -4
View File
@@ -75,7 +75,6 @@ const emptyFollowups: FollowupItem[] = []
type ChangeMode = "git" | "branch" | "turn"
type VcsMode = "git" | "branch"
const USE_NEW_SESSION_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
type SessionHistoryWindowInput = {
sessionID: () => string | undefined
@@ -198,6 +197,7 @@ export default function Page() {
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
const location = useLocation()
const { params, sessionKey, tabs, view } = useSessionLayout()
const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns())
createEffect(() => {
if (!prompt.ready()) return
@@ -265,7 +265,7 @@ export default function Page() {
const isDesktop = createMediaQuery("(min-width: 768px)")
const size = createSizing()
const isV2NewSessionPage = () =>
shouldUseV2NewSessionPage({ channel: import.meta.env.VITE_OPENCODE_CHANNEL, sessionID: params.id })
shouldUseV2NewSessionPage({ newLayoutDesigns: newSessionDesign(), sessionID: params.id })
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened() && !isV2NewSessionPage())
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened() && !isV2NewSessionPage())
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
@@ -1798,14 +1798,14 @@ export default function Page() {
</Show>
</Match>
<Match when={true}>
<Show when={USE_NEW_SESSION_DESIGN} fallback={<NewSessionView worktree={newSessionWorktree()} />}>
<Show when={newSessionDesign()} fallback={<NewSessionView worktree={newSessionWorktree()} />}>
<NewSessionDesignView>{composerRegion("inline")}</NewSessionDesignView>
</Show>
</Match>
</Switch>
</div>
<Show when={params.id || !USE_NEW_SESSION_DESIGN}>{composerRegion("dock")}</Show>
<Show when={params.id || !newSessionDesign()}>{composerRegion("dock")}</Show>
<Show when={desktopReviewOpen()}>
<div onPointerDown={() => size.start()}>
@@ -2,13 +2,13 @@ import { describe, expect, test } from "bun:test"
import { shouldUseV2NewSessionPage } from "./new-session-layout"
describe("shouldUseV2NewSessionPage", () => {
test("keeps prod session pages on the legacy layout", () => {
expect(shouldUseV2NewSessionPage({ channel: "prod", sessionID: "ses_123" })).toBe(false)
expect(shouldUseV2NewSessionPage({ channel: "prod" })).toBe(false)
test("keeps disabled pages on the legacy layout", () => {
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: false, sessionID: "ses_123" })).toBe(false)
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: false })).toBe(false)
})
test("uses the v2 layout only for non-prod new-session pages", () => {
expect(shouldUseV2NewSessionPage({ channel: "dev" })).toBe(true)
expect(shouldUseV2NewSessionPage({ channel: "dev", sessionID: "ses_123" })).toBe(false)
test("uses the v2 layout only for enabled new-session pages", () => {
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: true })).toBe(true)
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: true, sessionID: "ses_123" })).toBe(false)
})
})
@@ -1,3 +1,3 @@
export function shouldUseV2NewSessionPage(input: { channel?: "dev" | "beta" | "prod"; sessionID?: string }) {
return input.channel !== "prod" && !input.sessionID
export function shouldUseV2NewSessionPage(input: { newLayoutDesigns: boolean; sessionID?: string }) {
return input.newLayoutDesigns && !input.sessionID
}
@@ -28,8 +28,6 @@ import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type S
import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
const USE_DESKTOP_V2 = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
@@ -60,7 +58,7 @@ export function SessionSidePanel(props: {
const { sessionKey, tabs, view, params } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
const desktopV2 = () => platform.platform === "desktop" && USE_DESKTOP_V2
const desktopV2 = () => platform.platform === "desktop" && settings.general.newLayoutDesigns()
const shown = createMemo(() => (desktopV2() ? settings.general.showFileTree() : true))
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
@@ -205,7 +203,7 @@ export function SessionSidePanel(props: {
})
return (
<Show when={isDesktop() && !(import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && !params.id)}>
<Show when={isDesktop() && !(settings.general.newLayoutDesigns() && !params.id)}>
<aside
id="review-panel"
aria-label={language.t("session.panel.reviewAndFiles")}
@@ -20,8 +20,6 @@ import { extractPromptFromParts } from "@/utils/prompt"
import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSessionLayout } from "@/pages/session/session-layout"
const USE_DESKTOP_V2 = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
export type SessionCommandContext = {
navigateMessageByOffset: (offset: number) => void
setActiveMessage: (message: UserMessage | undefined) => void
@@ -72,7 +70,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
})
const activeFileTab = tabState.activeFileTab
const closableTab = tabState.closableTab
const desktopV2 = () => platform.platform === "desktop" && USE_DESKTOP_V2
const desktopV2 = () => platform.platform === "desktop" && settings.general.newLayoutDesigns()
const shown = () => (desktopV2() ? settings.general.showFileTree() : true)
const messages = () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.15.10",
"version": "1.15.11",
"type": "module",
"license": "MIT",
"scripts": {
+2 -1
View File
@@ -64,11 +64,12 @@ function LimitsGraph(props: { href: string }) {
{ id: "glm-5.1", name: "GLM-5.1", req: 880, d: "100ms" },
{ id: "qwen3.7-max", name: "Qwen3.7 Max", req: 950, d: "280ms" },
{ id: "kimi-k2.6", name: "Kimi K2.6", req: 1150, d: "150ms" },
{ id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 1290, d: "150ms" },
{ id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 3250, d: "150ms" },
{ id: "qwen3.6-plus", name: "Qwen3.6 Plus", req: 3300, d: "280ms" },
{ id: "minimax-m2.7", name: "MiniMax M2.7", req: 3400, d: "300ms" },
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", req: 3450, d: "200ms" },
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus", req: 10200, d: "360ms" },
{ id: "mimo-v2.5", name: "MiMo-V2.5", req: 30100, d: "340ms" },
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", req: 31650, d: "340ms" },
]
@@ -483,27 +483,29 @@ export async function handler(
modelTpsLimits: Record<string, boolean> | undefined,
) {
const modelProvider = (() => {
const allProviders = modelInfo.providers.filter((provider) => !provider.disabled)
// 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) {
return modelInfo.providers.find((provider) => provider.id === modelInfo.byokProvider)
return allProviders.find((provider) => provider.id === modelInfo.byokProvider)
}
// Always use the same provider for the same session
if (stickyProvider) {
const provider = modelInfo.providers.find((provider) => provider.id === stickyProvider)
const provider = allProviders.find((provider) => provider.id === stickyProvider)
if (provider) return provider
}
if (trialProviders) {
const trialProvider = trialProviders[Math.floor(Math.random() * trialProviders.length)]
const provider = modelInfo.providers.find((provider) => provider.id === trialProvider)
const provider = allProviders.find((provider) => provider.id === trialProvider)
if (provider) return provider
}
if (retry.retryCount !== MAX_FAILOVER_RETRIES) {
let topPriority = Infinity
const providers = modelInfo.providers
const providers = allProviders
.filter((provider) => !provider.disabled)
.filter((provider) => provider.weight !== 0)
.filter((provider) => !retry.excludeProviders.includes(provider.id))
@@ -536,7 +538,7 @@ export async function handler(
}
// fallback provider
return modelInfo.providers.find((provider) => provider.id === modelInfo.fallbackProvider)
return allProviders.find((provider) => provider.id === modelInfo.fallbackProvider)
})()
if (!modelProvider) throw new ModelError(t("zen.api.error.noProviderAvailable"))
@@ -1,5 +1,3 @@
import { Database, eq, and, sql, inArray } from "@opencode-ai/console-core/drizzle/index.js"
import { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
import { FreeUsageLimitError } from "./error"
import { logger } from "./logger"
import { buildRateLimitKey, getRedis } from "./redis"
@@ -22,7 +20,6 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
const ip = !rawIp.length ? "unknown" : rawIp
const now = Date.now()
const lifetimeInterval = ""
const dailyInterval = rateLimit ? `${buildYYYYMMDD(now)}${modelId.substring(0, 2)}` : buildYYYYMMDD(now)
const retryAfter = getRetryAfterDay(now)
const redis = getRedis()
@@ -32,33 +29,12 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
return {
check: async () => {
const [counts, rows] = await Promise.all([
redis.mget<(string | number | null)[]>(isDefaultModel ? [lifetimeKey, dailyKey] : [dailyKey]).catch(() => []),
Database.use((tx) =>
tx
.select({ interval: IpRateLimitTable.interval, count: IpRateLimitTable.count })
.from(IpRateLimitTable)
.where(
and(
eq(IpRateLimitTable.ip, ip),
isDefaultModel
? inArray(IpRateLimitTable.interval, [lifetimeInterval, dailyInterval])
: inArray(IpRateLimitTable.interval, [dailyInterval]),
),
),
),
])
const redisLifetimeCount = isDefaultModel ? Number(counts[0] ?? 0) : 0
const redisDailyCount = Number(counts[isDefaultModel ? 1 : 0] ?? 0)
const databaseLifetimeCount = rows.find((r) => r.interval === lifetimeInterval)?.count ?? 0
const databaseDailyCount = rows.find((r) => r.interval === dailyInterval)?.count ?? 0
const lifetimeCount = Math.max(redisLifetimeCount, databaseLifetimeCount)
const dailyCount = Math.max(redisDailyCount, databaseDailyCount)
const counts = await redis.mget<(string | number | null)[]>(isDefaultModel ? [lifetimeKey, dailyKey] : [dailyKey])
const lifetimeCount = isDefaultModel ? Number(counts[0] ?? 0) : 0
const dailyCount = Number(counts[isDefaultModel ? 1 : 0] ?? 0)
logger.debug(`rate limit lifetime: ${lifetimeCount}, daily: ${dailyCount}`)
isNew = isDefaultModel && lifetimeCount < dailyLimit * 7
if (isDefaultModel && databaseLifetimeCount > redisLifetimeCount)
await redis.set(lifetimeKey, databaseLifetimeCount).catch(() => {})
if ((isNew && dailyCount >= dailyLimit * 2) || (!isNew && dailyCount >= dailyLimit))
throw new FreeUsageLimitError(dict["zen.api.error.rateLimitExceeded"], retryAfter)
@@ -68,18 +44,7 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
pipeline.incr(dailyKey)
pipeline.expire(dailyKey, retryAfter)
if (isNew) pipeline.incr(lifetimeKey)
await Promise.all([
pipeline.exec().catch(() => {}),
Database.use((tx) =>
tx
.insert(IpRateLimitTable)
.values([
{ ip, interval: dailyInterval, count: 1 },
...(isNew ? [{ ip, interval: lifetimeInterval, count: 1 }] : []),
])
.onDuplicateKeyUpdate({ set: { count: sql`${IpRateLimitTable.count} + 1` } }),
),
])
await pipeline.exec()
},
}
}
@@ -1,6 +1,5 @@
import { Database, eq, and, sql } from "@opencode-ai/console-core/drizzle/index.js"
import { KeyRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
import { RateLimitError } from "./error"
import { buildRateLimitKey, getRedis } from "./redis"
import { i18n } from "~/i18n"
import { localeFromRequest } from "~/lib/language"
@@ -19,26 +18,20 @@ export function createRateLimiter(
.replace(/[^0-9]/g, "")
.substring(0, 12)
const interval = `${modelId.substring(0, 27)}-${yyyyMMddHHmm}`
const redis = getRedis()
const key = buildRateLimitKey("key", zenApiKey, interval)
return {
check: async () => {
const rows = await Database.use((tx) =>
tx
.select({ interval: KeyRateLimitTable.interval, count: KeyRateLimitTable.count })
.from(KeyRateLimitTable)
.where(and(eq(KeyRateLimitTable.key, zenApiKey), eq(KeyRateLimitTable.interval, interval))),
).then((rows) => rows[0])
const count = rows?.count ?? 0
const count = Number((await redis.mget<(string | number | null)[]>([key]))[0] ?? 0)
if (count >= LIMIT) throw new RateLimitError(dict["zen.api.error.rateLimitExceeded"], 60)
},
track: async () => {
await Database.use((tx) =>
tx
.insert(KeyRateLimitTable)
.values({ key: zenApiKey, interval, count: 1 })
.onDuplicateKeyUpdate({ set: { count: sql`${KeyRateLimitTable.count} + 1` } }),
)
const pipeline = redis.pipeline()
pipeline.incr(key)
pipeline.expire(key, 60)
await pipeline.exec()
},
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.15.10",
"version": "1.15.11",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.15.10",
"version": "1.15.11",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-support",
"version": "0.0.0",
"version": "1.15.11",
"type": "module",
"license": "MIT",
"scripts": {
@@ -2,7 +2,7 @@ import { defineConfig } from "drizzle-kit"
export default defineConfig({
dialect: "sqlite",
schema: "./src/**/*.sql.ts",
schema: ["./src/**/*.sql.ts", "./src/**/sql.ts"],
out: "./migration",
dbCredentials: {
url: "/home/thdxr/.local/share/opencode/opencode.db",
+16 -3
View File
@@ -1,11 +1,13 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.10",
"version": "1.15.11",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
"private": true,
"scripts": {
"db": "bun drizzle-kit",
"migration": "bun run script/migration.ts",
"test": "bun test",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"typecheck": "tsgo --noEmit"
@@ -16,14 +18,21 @@
"exports": {
"./*": "./src/*.ts"
},
"imports": {},
"imports": {
"#sqlite": {
"bun": "./src/database/sqlite.bun.ts",
"node": "./src/database/sqlite.node.ts",
"default": "./src/database/sqlite.bun.ts"
}
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/cross-spawn": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "catalog:"
"@types/semver": "catalog:",
"drizzle-kit": "catalog:"
},
"dependencies": {
"@ai-sdk/alibaba": "1.0.17",
@@ -49,8 +58,11 @@
"@aws-sdk/credential-providers": "3.993.0",
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@npmcli/arborist": "9.4.0",
"@npmcli/config": "10.8.1",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
@@ -58,6 +70,7 @@
"@openrouter/ai-sdk-provider": "2.8.1",
"ai-gateway-provider": "3.1.2",
"cross-spawn": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"gitlab-ai-provider": "6.7.0",
"glob": "13.0.5",
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env bun
import { $ } from "bun"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { pathToFileURL } from "url"
const root = path.resolve(import.meta.dirname, "../../..")
const sqlDir = path.join(root, "packages/core/migration")
const tsDir = path.join(root, "packages/core/src/database/migration")
const registry = path.join(root, "packages/core/src/database/migration.gen.ts")
if (Bun.argv.includes("--check")) {
await check()
process.exit(0)
}
await $`bun drizzle-kit generate`.cwd(path.join(root, "packages/core"))
const sqlMigrations = (await Array.fromAsync(new Bun.Glob("*/migration.sql").scan({ cwd: sqlDir })))
.map((file) => file.split("/")[0])
.filter((name) => name !== undefined)
.sort()
for (const name of sqlMigrations) {
if (await Bun.file(path.join(tsDir, `${name}.ts`)).exists()) continue
await Bun.write(path.join(tsDir, `${name}.ts`), renderMigration(name, await Bun.file(path.join(sqlDir, name, "migration.sql")).text()))
}
await Bun.write(registry, renderRegistry(sqlMigrations))
async function check() {
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-core-migration-check-"))
const output = path.join(temporary, "migration")
try {
await fs.cp(sqlDir, output, { recursive: true })
const config = path.join(temporary, "drizzle.config.ts")
await Bun.write(
config,
`import config from ${JSON.stringify(pathToFileURL(path.join(root, "packages/core/drizzle.config.ts")).href)}
export default { ...config, out: ${JSON.stringify(output)} }
`,
)
const before = await snapshot(output)
await $`bun drizzle-kit generate --config ${config}`.cwd(path.join(root, "packages/core"))
const after = await snapshot(output)
if (JSON.stringify(after) !== JSON.stringify(before)) {
throw new Error("Core schema has ungenerated database migrations. Run `bun script/migration.ts` from packages/core.")
}
const migrations = before
.map((entry) => entry.path.split("/")[0])
.filter((name, index, all) => name !== undefined && all.indexOf(name) === index)
.sort()
for (const name of migrations) {
if (await Bun.file(path.join(tsDir, `${name}.ts`)).exists()) continue
throw new Error(`Database migration TypeScript wrapper is missing for ${name}. Run \`bun script/migration.ts\` from packages/core.`)
}
if ((await Bun.file(registry).text()) !== renderRegistry(migrations)) {
throw new Error("Database migration registry is stale. Run `bun script/migration.ts` from packages/core.")
}
} finally {
await fs.rm(temporary, { recursive: true, force: true })
}
}
async function snapshot(directory: string) {
const files = await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: directory, onlyFiles: true }))
return Promise.all(
files.sort().map(async (file) => ({ path: file, contents: await Bun.file(path.join(directory, file)).text() })),
)
}
function renderMigration(name: string, sql: string) {
return `import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: ${JSON.stringify(name)},
up(tx) {
return Effect.gen(function* () {
${sql
.split("--> statement-breakpoint")
.map((statement) => statement.trim())
.filter((statement) => statement.length > 0)
.map(renderRun)
.join("\n")}
})
},
} satisfies DatabaseMigration.Migration
`
}
function renderRun(statement: string) {
const lines = statement.replaceAll("\t", " ").split("\n")
if (lines.length === 1) return ` yield* tx.run(\`${escapeTemplate(lines[0])}\`)`
return ` yield* tx.run(\`\n${lines.map((line) => ` ${escapeTemplate(line)}`).join("\n")}\n \`)`
}
function escapeTemplate(line: string) {
return line.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${")
}
function renderRegistry(names: string[]) {
return `import type { DatabaseMigration } from "./migration"
export const migrations = (await Promise.all([
${names.map((name) => ` import("./migration/${name}"),`).join("\n")}
])).map((module) => module.default) satisfies DatabaseMigration.Migration[]
`
}
+89 -307
View File
@@ -1,319 +1,101 @@
import path from "path"
import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect"
import { Identifier } from "./util/identifier"
import { NonNegativeInt, withStatics } from "./schema"
import { Global } from "./global"
import { AppFileSystem } from "./filesystem"
import { EventV2 } from "./event"
export * as AccountV2 from "./account"
export const ID = Schema.String.pipe(
Schema.brand("AccountV2.ID"),
withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })),
)
export type ID = typeof ID.Type
import { Schema } from "effect"
import type * as HttpClientError from "effect/unstable/http/HttpClientError"
export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID"))
export type ServiceID = typeof ServiceID.Type
export const ID = Schema.String.pipe(Schema.brand("AccountID"))
export type ID = Schema.Schema.Type<typeof ID>
export class OAuthCredential extends Schema.Class<OAuthCredential>("AccountV2.OAuthCredential")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: NonNegativeInt,
}) {}
export const OrgID = Schema.String.pipe(Schema.brand("OrgID"))
export type OrgID = Schema.Schema.Type<typeof OrgID>
export class ApiKeyCredential extends Schema.Class<ApiKeyCredential>("AccountV2.ApiKeyCredential")({
type: Schema.Literal("api"),
key: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export const AccessToken = Schema.String.pipe(Schema.brand("AccessToken"))
export type AccessToken = Schema.Schema.Type<typeof AccessToken>
export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential])
.pipe(Schema.toTaggedUnion("type"))
.annotate({
identifier: "AccountV2.Credential",
})
export type Credential = Schema.Schema.Type<typeof Credential>
export const RefreshToken = Schema.String.pipe(Schema.brand("RefreshToken"))
export type RefreshToken = Schema.Schema.Type<typeof RefreshToken>
export class Info extends Schema.Class<Info>("AccountV2.Info")({
export const DeviceCode = Schema.String.pipe(Schema.brand("DeviceCode"))
export type DeviceCode = Schema.Schema.Type<typeof DeviceCode>
export const UserCode = Schema.String.pipe(Schema.brand("UserCode"))
export type UserCode = Schema.Schema.Type<typeof UserCode>
export class Info extends Schema.Class<Info>("Account")({
id: ID,
serviceID: ServiceID,
description: Schema.String,
credential: Credential,
email: Schema.String,
url: Schema.String,
active_org_id: Schema.NullOr(OrgID),
}) {}
export class FileWriteError extends Schema.TaggedErrorClass<FileWriteError>()("AccountV2.FileWriteError", {
operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]),
export class Org extends Schema.Class<Org>("Org")({
id: OrgID,
name: Schema.String,
}) {}
export class AccountRepoError extends Schema.TaggedErrorClass<AccountRepoError>()("AccountRepoError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export class AccountServiceError extends Schema.TaggedErrorClass<AccountServiceError>()("AccountServiceError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export class AccountTransportError extends Schema.TaggedErrorClass<AccountTransportError>()("AccountTransportError", {
method: Schema.String,
url: Schema.String,
description: Schema.optional(Schema.String),
cause: Schema.optional(Schema.Defect),
}) {
static fromHttpClientError(error: HttpClientError.TransportError): AccountTransportError {
return new AccountTransportError({
method: error.request.method,
url: error.request.url,
description: error.description,
cause: error.cause,
})
}
override get message(): string {
return [
`Could not reach ${this.method} ${this.url}.`,
`This failed before the server returned an HTTP response.`,
this.description,
`Check your network, proxy, or VPN configuration and try again.`,
]
.filter(Boolean)
.join("\n")
}
}
export type AccountError = AccountRepoError | AccountServiceError | AccountTransportError
export class Login extends Schema.Class<Login>("Login")({
code: DeviceCode,
user: UserCode,
url: Schema.String,
server: Schema.String,
expiry: Schema.Duration,
interval: Schema.Duration,
}) {}
export class PollSuccess extends Schema.TaggedClass<PollSuccess>()("PollSuccess", {
email: Schema.String,
}) {}
export class PollPending extends Schema.TaggedClass<PollPending>()("PollPending", {}) {}
export class PollSlow extends Schema.TaggedClass<PollSlow>()("PollSlow", {}) {}
export class PollExpired extends Schema.TaggedClass<PollExpired>()("PollExpired", {}) {}
export class PollDenied extends Schema.TaggedClass<PollDenied>()("PollDenied", {}) {}
export class PollError extends Schema.TaggedClass<PollError>()("PollError", {
cause: Schema.Defect,
}) {}
export type Error = FileWriteError
export const Event = {
Added: EventV2.define({
type: "account.added",
schema: {
account: Info,
},
}),
Removed: EventV2.define({
type: "account.removed",
schema: {
account: Info,
},
}),
Switched: EventV2.define({
type: "account.switched",
schema: {
serviceID: ServiceID,
from: Schema.optional(ID),
to: Schema.optional(ID),
},
}),
}
interface Writable {
version: 2
accounts: Record<string, Info>
active: Record<string, ID>
}
const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential))
function migrate(old: Record<string, unknown>): Writable {
const accounts: Record<string, Info> = {}
const active: Record<string, ID> = {}
for (const [serviceID, value] of Object.entries(old)) {
const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({}))
const parsed = (decoded as Record<string, Credential>)[serviceID]
if (!parsed) continue
const id = Identifier.ascending()
const account = ID.make(id)
const brandedServiceID = ServiceID.make(serviceID)
accounts[id] = new Info({
id: account,
serviceID: brandedServiceID,
description: "default",
credential: parsed,
})
active[brandedServiceID] = account
}
return { version: 2, accounts, active }
}
export interface Interface {
readonly get: (id: ID) => Effect.Effect<Info | undefined, Error>
readonly all: () => Effect.Effect<Info[], Error>
readonly create: (input: {
serviceID: ServiceID
credential: Credential
description?: string
}) => Effect.Effect<Info | undefined, Error>
readonly update: (id: ID, updates: Partial<Pick<Info, "description" | "credential">>) => Effect.Effect<void, Error>
readonly remove: (id: ID) => Effect.Effect<void, Error>
readonly activate: (id: ID) => Effect.Effect<void, Error>
readonly active: (serviceID: ServiceID) => Effect.Effect<Info | undefined, Error>
readonly forService: (serviceID: ServiceID) => Effect.Effect<Info[], Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Account") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const global = yield* Global.Service
const events = yield* EventV2.Service
const file = path.join(global.data, "account.json")
const legacyFile = path.join(global.data, "auth.json")
const writeMigrated = Effect.fnUntraced(function* (raw: Record<string, unknown>) {
const migrated = migrate(raw)
yield* fsys
.writeJson(file, migrated, 0o600)
.pipe(Effect.mapError((cause) => new FileWriteError({ operation: "migrate", cause })))
return migrated
})
const parseAuthContent = () => {
try {
return JSON.parse(process.env.OPENCODE_AUTH_CONTENT ?? "")
} catch {}
}
const load: () => Effect.Effect<Writable, Error> = Effect.fnUntraced(function* () {
if (process.env.OPENCODE_AUTH_CONTENT) {
const raw = parseAuthContent()
if (raw && typeof raw === "object") {
if ("version" in raw && raw.version === 2) return raw as Writable
return yield* writeMigrated(raw as Record<string, unknown>)
}
return { version: 2, accounts: {}, active: {} }
}
const legacy = yield* fsys.readJson(legacyFile).pipe(Effect.orElseSucceed(() => null))
if (legacy && typeof legacy === "object") return yield* writeMigrated(legacy as Record<string, unknown>)
const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null))
if (raw && typeof raw === "object") {
if ("version" in raw && raw.version === 2) return raw as Writable
return yield* writeMigrated(raw as Record<string, unknown>)
}
return { version: 2, accounts: {}, active: {} }
})
const write = (data: Writable) =>
fsys
.writeJson(file, data, 0o600)
.pipe(Effect.mapError((cause) => new FileWriteError({ operation: "write", cause })))
const state = SynchronizedRef.makeUnsafe(
yield* load().pipe(Effect.orElseSucceed((): Writable => ({ version: 2, accounts: {}, active: {} }))),
)
const activate = Effect.fn("AccountV2.activate")(function* (id: ID) {
const data = yield* SynchronizedRef.get(state)
const account = data.accounts[id]
if (!account) return
const activated = yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const nextAccount = data.accounts[id]
if (!nextAccount) return [undefined, data] as const
const next = { ...data, active: { ...data.active, [nextAccount.serviceID]: id } }
yield* write(next)
return [{ serviceID: nextAccount.serviceID, from: data.active[nextAccount.serviceID], to: id }, next] as const
}),
)
if (activated) yield* events.publish(Event.Switched, activated)
})
const result: Interface = {
get: Effect.fn("AccountV2.get")(function* (id) {
return (yield* SynchronizedRef.get(state)).accounts[id]
}),
all: Effect.fn("AccountV2.all")(function* () {
return Object.values((yield* SynchronizedRef.get(state)).accounts)
}),
active: Effect.fn("AccountV2.active")(function* (serviceID) {
const data = yield* SynchronizedRef.get(state)
return (
data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID)
)
}),
forService: Effect.fn("AccountV2.list")(function* (serviceID) {
return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID)
}),
create: Effect.fn("AccountV2.add")(function* (input) {
const id = ID.make(Identifier.ascending())
const account = new Info({
id,
serviceID: input.serviceID,
description: input.description ?? "default",
credential: input.credential,
})
const added = yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const next = {
...data,
accounts: { ...data.accounts, [account.id]: account },
active: { ...data.active, [account.serviceID]: account.id },
}
yield* write(next)
return [
{
account,
switched: { serviceID: account.serviceID, from: data.active[account.serviceID], to: account.id },
},
next,
] as const
}),
)
yield* events.publish(Event.Added, { account: added.account })
yield* events.publish(Event.Switched, added.switched)
return added.account
}),
update: Effect.fn("AccountV2.update")(function* (id, updates) {
const existing = (yield* SynchronizedRef.get(state)).accounts[id]
if (!existing) return
yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
if (!data.accounts[id]) return [undefined, data] as const
const next = {
...data,
accounts: {
...data.accounts,
[id]: new Info({
id,
serviceID: existing.serviceID,
description: updates.description ?? existing.description,
credential: updates.credential ?? existing.credential,
}),
},
}
yield* write(next)
return [undefined, next] as const
}),
)
}),
remove: Effect.fn("AccountV2.remove")(function* (id) {
const removed = yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const accounts = { ...data.accounts }
const active = { ...data.active }
const removed = accounts[id]
if (!removed) return [undefined, data] as const
const wasActive = active[removed.serviceID] === id
delete accounts[id]
const replacement = Object.values(accounts).find((account) => account.serviceID === removed.serviceID)
if (wasActive) {
if (replacement) active[removed.serviceID] = replacement.id
else delete active[removed.serviceID]
}
const next = { ...data, accounts, active }
yield* write(next)
return [
{
account: removed,
switched: wasActive ? { serviceID: removed.serviceID, from: id, to: replacement?.id } : undefined,
},
next,
] as const
}),
)
if (removed) {
yield* events.publish(Event.Removed, { account: removed.account })
if (removed.switched) yield* events.publish(Event.Switched, removed.switched)
}
}),
activate,
}
return Service.of(result)
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Global.defaultLayer),
Layer.provide(EventV2.defaultLayer),
)
export * as AccountV2 from "./account"
export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError])
export type PollResult = Schema.Schema.Type<typeof PollResult>
@@ -1,14 +1,14 @@
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
import { type AccessToken, type AccountID, type OrgID, type RefreshToken } from "./schema"
import { Timestamps } from "../storage/schema.sql"
import { AccountV2 } from "../account"
import { Timestamps } from "../database/schema.sql"
export const AccountTable = sqliteTable("account", {
id: text().$type<AccountID>().primaryKey(),
id: text().$type<AccountV2.ID>().primaryKey(),
email: text().notNull(),
url: text().notNull(),
access_token: text().$type<AccessToken>().notNull(),
refresh_token: text().$type<RefreshToken>().notNull(),
access_token: text().$type<AccountV2.AccessToken>().notNull(),
refresh_token: text().$type<AccountV2.RefreshToken>().notNull(),
token_expiry: integer(),
...Timestamps,
})
@@ -16,9 +16,9 @@ export const AccountTable = sqliteTable("account", {
export const AccountStateTable = sqliteTable("account_state", {
id: integer().primaryKey(),
active_account_id: text()
.$type<AccountID>()
.$type<AccountV2.ID>()
.references(() => AccountTable.id, { onDelete: "set null" }),
active_org_id: text().$type<OrgID>(),
active_org_id: text().$type<AccountV2.OrgID>(),
})
// LEGACY
@@ -27,8 +27,8 @@ export const ControlAccountTable = sqliteTable(
{
email: text().notNull(),
url: text().notNull(),
access_token: text().$type<AccessToken>().notNull(),
refresh_token: text().$type<RefreshToken>().notNull(),
access_token: text().$type<AccountV2.AccessToken>().notNull(),
refresh_token: text().$type<AccountV2.RefreshToken>().notNull(),
token_expiry: integer(),
active: integer({ mode: "boolean" })
.notNull()
+326
View File
@@ -0,0 +1,326 @@
export * as Auth from "./auth"
import path from "path"
import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect"
import { Identifier } from "./util/identifier"
import { NonNegativeInt, withStatics } from "./schema"
import { Global } from "./global"
import { AppFileSystem } from "./filesystem"
import { EventV2 } from "./event"
export const ID = Schema.String.pipe(
Schema.brand("Auth.ID"),
withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })),
)
export type ID = typeof ID.Type
export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID"))
export type ServiceID = typeof ServiceID.Type
export const OrgID = Schema.String.pipe(Schema.brand("OrgID"))
export type OrgID = typeof OrgID.Type
export const AccessToken = Schema.String.pipe(Schema.brand("AccessToken"))
export type AccessToken = typeof AccessToken.Type
export const RefreshToken = Schema.String.pipe(Schema.brand("RefreshToken"))
export type RefreshToken = typeof RefreshToken.Type
export class OAuthCredential extends Schema.Class<OAuthCredential>("Auth.OAuthCredential")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: NonNegativeInt,
}) {}
export class ApiKeyCredential extends Schema.Class<ApiKeyCredential>("Auth.ApiKeyCredential")({
type: Schema.Literal("api"),
key: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential])
.pipe(Schema.toTaggedUnion("type"))
.annotate({
identifier: "Auth.Credential",
})
export type Credential = Schema.Schema.Type<typeof Credential>
export class Info extends Schema.Class<Info>("Auth.Info")({
id: ID,
serviceID: ServiceID,
description: Schema.String,
credential: Credential,
}) {}
export class FileWriteError extends Schema.TaggedErrorClass<FileWriteError>()("Auth.FileWriteError", {
operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]),
cause: Schema.Defect,
}) {}
export type Error = FileWriteError
export const Event = {
Added: EventV2.define({
type: "account.added",
schema: {
account: Info,
},
}),
Removed: EventV2.define({
type: "account.removed",
schema: {
account: Info,
},
}),
Switched: EventV2.define({
type: "account.switched",
schema: {
serviceID: ServiceID,
from: Schema.optional(ID),
to: Schema.optional(ID),
},
}),
}
interface Writable {
version: 2
accounts: Record<string, Info>
active: Record<string, ID>
}
const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential))
function migrate(old: Record<string, unknown>): Writable {
const accounts: Record<string, Info> = {}
const active: Record<string, ID> = {}
for (const [serviceID, value] of Object.entries(old)) {
const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({}))
const parsed = (decoded as Record<string, Credential>)[serviceID]
if (!parsed) continue
const id = Identifier.ascending()
const account = ID.make(id)
const brandedServiceID = ServiceID.make(serviceID)
accounts[id] = new Info({
id: account,
serviceID: brandedServiceID,
description: "default",
credential: parsed,
})
active[brandedServiceID] = account
}
return { version: 2, accounts, active }
}
export interface Interface {
readonly get: (id: ID) => Effect.Effect<Info | undefined, Error>
readonly all: () => Effect.Effect<Info[], Error>
readonly create: (input: {
serviceID: ServiceID
credential: Credential
description?: string
}) => Effect.Effect<Info | undefined, Error>
readonly update: (id: ID, updates: Partial<Pick<Info, "description" | "credential">>) => Effect.Effect<void, Error>
readonly remove: (id: ID) => Effect.Effect<void, Error>
readonly activate: (id: ID) => Effect.Effect<void, Error>
readonly active: (serviceID: ServiceID) => Effect.Effect<Info | undefined, Error>
readonly forService: (serviceID: ServiceID) => Effect.Effect<Info[], Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Account") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const global = yield* Global.Service
const events = yield* EventV2.Service
const file = path.join(global.data, "account.json")
const legacyFile = path.join(global.data, "auth.json")
const writeMigrated = Effect.fnUntraced(function* (raw: Record<string, unknown>) {
const migrated = migrate(raw)
yield* fsys
.writeJson(file, migrated, 0o600)
.pipe(Effect.mapError((cause) => new FileWriteError({ operation: "migrate", cause })))
return migrated
})
const parseAuthContent = () => {
try {
return JSON.parse(process.env.OPENCODE_AUTH_CONTENT ?? "")
} catch {}
}
const load: () => Effect.Effect<Writable, Error> = Effect.fnUntraced(function* () {
if (process.env.OPENCODE_AUTH_CONTENT) {
const raw = parseAuthContent()
if (raw && typeof raw === "object") {
if ("version" in raw && raw.version === 2) return raw as Writable
return yield* writeMigrated(raw as Record<string, unknown>)
}
return { version: 2, accounts: {}, active: {} }
}
const legacy = yield* fsys.readJson(legacyFile).pipe(Effect.orElseSucceed(() => null))
if (legacy && typeof legacy === "object") return yield* writeMigrated(legacy as Record<string, unknown>)
const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null))
if (raw && typeof raw === "object") {
if ("version" in raw && raw.version === 2) return raw as Writable
return yield* writeMigrated(raw as Record<string, unknown>)
}
return { version: 2, accounts: {}, active: {} }
})
const write = (data: Writable) =>
fsys
.writeJson(file, data, 0o600)
.pipe(Effect.mapError((cause) => new FileWriteError({ operation: "write", cause })))
const state = SynchronizedRef.makeUnsafe(
yield* load().pipe(Effect.orElseSucceed((): Writable => ({ version: 2, accounts: {}, active: {} }))),
)
const activate = Effect.fn("Auth.activate")(function* (id: ID) {
const data = yield* SynchronizedRef.get(state)
const account = data.accounts[id]
if (!account) return
const activated = yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const nextAccount = data.accounts[id]
if (!nextAccount) return [undefined, data] as const
const next = { ...data, active: { ...data.active, [nextAccount.serviceID]: id } }
yield* write(next)
return [{ serviceID: nextAccount.serviceID, from: data.active[nextAccount.serviceID], to: id }, next] as const
}),
)
if (activated) yield* events.publish(Event.Switched, activated)
})
const result: Interface = {
get: Effect.fn("Auth.get")(function* (id) {
return (yield* SynchronizedRef.get(state)).accounts[id]
}),
all: Effect.fn("Auth.all")(function* () {
return Object.values((yield* SynchronizedRef.get(state)).accounts)
}),
active: Effect.fn("Auth.active")(function* (serviceID) {
const data = yield* SynchronizedRef.get(state)
return (
data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID)
)
}),
forService: Effect.fn("Auth.list")(function* (serviceID) {
return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID)
}),
create: Effect.fn("Auth.add")(function* (input) {
const id = ID.make(Identifier.ascending())
const account = new Info({
id,
serviceID: input.serviceID,
description: input.description ?? "default",
credential: input.credential,
})
const added = yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const next = {
...data,
accounts: { ...data.accounts, [account.id]: account },
active: { ...data.active, [account.serviceID]: account.id },
}
yield* write(next)
return [
{
account,
switched: { serviceID: account.serviceID, from: data.active[account.serviceID], to: account.id },
},
next,
] as const
}),
)
yield* events.publish(Event.Added, { account: added.account })
yield* events.publish(Event.Switched, added.switched)
return added.account
}),
update: Effect.fn("Auth.update")(function* (id, updates) {
const existing = (yield* SynchronizedRef.get(state)).accounts[id]
if (!existing) return
yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
if (!data.accounts[id]) return [undefined, data] as const
const next = {
...data,
accounts: {
...data.accounts,
[id]: new Info({
id,
serviceID: existing.serviceID,
description: updates.description ?? existing.description,
credential: updates.credential ?? existing.credential,
}),
},
}
yield* write(next)
return [undefined, next] as const
}),
)
}),
remove: Effect.fn("Auth.remove")(function* (id) {
const removed = yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const accounts = { ...data.accounts }
const active = { ...data.active }
const removed = accounts[id]
if (!removed) return [undefined, data] as const
const wasActive = active[removed.serviceID] === id
delete accounts[id]
const replacement = Object.values(accounts).find((account) => account.serviceID === removed.serviceID)
if (wasActive) {
if (replacement) active[removed.serviceID] = replacement.id
else delete active[removed.serviceID]
}
const next = { ...data, accounts, active }
yield* write(next)
return [
{
account: removed,
switched: wasActive ? { serviceID: removed.serviceID, from: id, to: replacement?.id } : undefined,
},
next,
] as const
}),
)
if (removed) {
yield* events.publish(Event.Removed, { account: removed.account })
if (removed.switched) yield* events.publish(Event.Switched, removed.switched)
}
}),
activate,
}
return Service.of(result)
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Global.defaultLayer),
Layer.provide(EventV2.defaultLayer),
)
@@ -1,17 +1,17 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
import { ProjectTable } from "../project/project.sql"
import type { ProjectID } from "../project/schema"
import type { WorkspaceID } from "./schema"
import { ProjectTable } from "../project/sql"
import { ProjectV2 } from "../project"
import { WorkspaceV2 } from "../workspace"
export const WorkspaceTable = sqliteTable("workspace", {
id: text().$type<WorkspaceID>().primaryKey(),
id: text().$type<WorkspaceV2.ID>().primaryKey(),
type: text().notNull(),
name: text().notNull().default(""),
branch: text(),
directory: text(),
extra: text({ mode: "json" }),
project_id: text()
.$type<ProjectID>()
.$type<ProjectV2.ID>()
.notNull()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
time_used: integer()
+60
View File
@@ -0,0 +1,60 @@
export * as Database from "./database"
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { layer as sqliteLayer } from "#sqlite"
import { Context, Effect, Layer } from "effect"
import { Global } from "../global"
import { Flag } from "../flag/flag"
import { isAbsolute, join } from "path"
import { DatabaseMigration } from "./migration"
import { InstallationChannel } from "../installation/version"
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
type DatabaseShape = Effect.Success<typeof makeDatabase>
export interface Interface {
db: DatabaseShape
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/storage/Database") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const db = yield* makeDatabase
yield* db.run("PRAGMA journal_mode = WAL")
yield* db.run("PRAGMA synchronous = NORMAL")
yield* db.run("PRAGMA busy_timeout = 5000")
yield* db.run("PRAGMA cache_size = -64000")
yield* db.run("PRAGMA foreign_keys = ON")
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
yield* DatabaseMigration.apply(db)
return { db }
}).pipe(Effect.orDie),
)
export function layerFromPath(filename: string) {
return layer.pipe(Layer.provide(sqliteLayer({ filename })))
}
export function path() {
if (Flag.OPENCODE_DB) {
if (Flag.OPENCODE_DB === ":memory:" || isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB
return join(Global.Path.data, Flag.OPENCODE_DB)
}
if (
["latest", "beta", "prod"].includes(InstallationChannel) ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
)
return join(Global.Path.data, "opencode.db")
return join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
}
export const defaultLayer = Layer.unwrap(
Effect.gen(function* () {
return layerFromPath(path())
}),
).pipe(Layer.provide(Global.defaultLayer))
@@ -0,0 +1,24 @@
import type { DatabaseMigration } from "./migration"
export const migrations = (await Promise.all([
import("./migration/20260127222353_familiar_lady_ursula"),
import("./migration/20260211171708_add_project_commands"),
import("./migration/20260213144116_wakeful_the_professor"),
import("./migration/20260225215848_workspace"),
import("./migration/20260227213759_add_session_workspace_id"),
import("./migration/20260228203230_blue_harpoon"),
import("./migration/20260303231226_add_workspace_fields"),
import("./migration/20260309230000_move_org_to_state"),
import("./migration/20260312043431_session_message_cursor"),
import("./migration/20260323234822_events"),
import("./migration/20260410174513_workspace-name"),
import("./migration/20260413175956_chief_energizer"),
import("./migration/20260423070820_add_icon_url_override"),
import("./migration/20260427172553_slow_nightmare"),
import("./migration/20260428004200_add_session_path"),
import("./migration/20260501142318_next_venus"),
import("./migration/20260504145000_add_sync_owner"),
import("./migration/20260507164347_add_workspace_time"),
import("./migration/20260510033149_session_usage"),
import("./migration/20260511000411_data_migration_state"),
])).map((module) => module.default) satisfies DatabaseMigration.Migration[]
+58
View File
@@ -0,0 +1,58 @@
export * as DatabaseMigration from "./migration"
import { sql } from "drizzle-orm"
import { Effect } from "effect"
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { migrations } from "./migration.gen"
type Database = EffectDrizzleSqlite.EffectSQLiteDatabase
type Transaction = Parameters<Parameters<Database["transaction"]>[0]>[0]
export type Migration = {
id: string
up: (tx: Transaction) => Effect.Effect<void, unknown>
}
export function apply(db: Database) {
return applyOnly(db, migrations)
}
export function applyOnly(db: Database, input: Migration[]) {
return Effect.gen(function* () {
yield* db.run(
sql`CREATE TABLE IF NOT EXISTS ${sql.identifier("migration")} (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`,
)
let completed = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM ${sql.identifier("migration")}`)).map((row) => row.id),
)
if (completed.size === 0) {
// Existing installs used Drizzle's migration journal. Seed the new
// journal once so TypeScript migrations don't replay old SQL.
if (
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${"__drizzle_migrations"}`)
) {
yield* db.run(sql`
INSERT OR IGNORE INTO ${sql.identifier("migration")} (id, time_completed)
SELECT name, ${Date.now()}
FROM ${sql.identifier("__drizzle_migrations")}
WHERE name IS NOT NULL
`)
completed = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM ${sql.identifier("migration")}`)).map((row) => row.id),
)
}
}
for (const migration of input) {
if (completed.has(migration.id)) continue
yield* db.transaction((tx) =>
Effect.gen(function* () {
yield* migration.up(tx)
yield* tx.run(
sql`INSERT INTO ${sql.identifier("migration")} (id, time_completed) VALUES (${migration.id}, ${Date.now()})`,
)
}),
)
}
})
}
@@ -0,0 +1,107 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260127222353_familiar_lady_ursula",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`project\` (
\`id\` text PRIMARY KEY,
\`worktree\` text NOT NULL,
\`vcs\` text,
\`name\` text,
\`icon_url\` text,
\`icon_color\` text,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
\`time_initialized\` integer,
\`sandboxes\` text NOT NULL
);
`)
yield* tx.run(`
CREATE TABLE \`message\` (
\`id\` text PRIMARY KEY,
\`session_id\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
\`data\` text NOT NULL,
CONSTRAINT \`fk_message_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`part\` (
\`id\` text PRIMARY KEY,
\`message_id\` text NOT NULL,
\`session_id\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
\`data\` text NOT NULL,
CONSTRAINT \`fk_part_message_id_message_id_fk\` FOREIGN KEY (\`message_id\`) REFERENCES \`message\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`permission\` (
\`project_id\` text PRIMARY KEY,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
\`data\` text NOT NULL,
CONSTRAINT \`fk_permission_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`session\` (
\`id\` text PRIMARY KEY,
\`project_id\` text NOT NULL,
\`parent_id\` text,
\`slug\` text NOT NULL,
\`directory\` text NOT NULL,
\`title\` text NOT NULL,
\`version\` text NOT NULL,
\`share_url\` text,
\`summary_additions\` integer,
\`summary_deletions\` integer,
\`summary_files\` integer,
\`summary_diffs\` text,
\`revert\` text,
\`permission\` text,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
\`time_compacting\` integer,
\`time_archived\` integer,
CONSTRAINT \`fk_session_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`todo\` (
\`session_id\` text NOT NULL,
\`content\` text NOT NULL,
\`status\` text NOT NULL,
\`priority\` text NOT NULL,
\`position\` integer NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
CONSTRAINT \`todo_pk\` PRIMARY KEY(\`session_id\`, \`position\`),
CONSTRAINT \`fk_todo_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`session_share\` (
\`session_id\` text PRIMARY KEY,
\`id\` text NOT NULL,
\`secret\` text NOT NULL,
\`url\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
CONSTRAINT \`fk_session_share_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`CREATE INDEX \`message_session_idx\` ON \`message\` (\`session_id\`);`)
yield* tx.run(`CREATE INDEX \`part_message_idx\` ON \`part\` (\`message_id\`);`)
yield* tx.run(`CREATE INDEX \`part_session_idx\` ON \`part\` (\`session_id\`);`)
yield* tx.run(`CREATE INDEX \`session_project_idx\` ON \`session\` (\`project_id\`);`)
yield* tx.run(`CREATE INDEX \`session_parent_idx\` ON \`session\` (\`parent_id\`);`)
yield* tx.run(`CREATE INDEX \`todo_session_idx\` ON \`todo\` (\`session_id\`);`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260211171708_add_project_commands",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`project\` ADD \`commands\` text;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,23 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260213144116_wakeful_the_professor",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`control_account\` (
\`email\` text NOT NULL,
\`url\` text NOT NULL,
\`access_token\` text NOT NULL,
\`refresh_token\` text NOT NULL,
\`token_expiry\` integer,
\`active\` integer NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
CONSTRAINT \`control_account_pk\` PRIMARY KEY(\`email\`, \`url\`)
);
`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,19 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260225215848_workspace",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`workspace\` (
\`id\` text PRIMARY KEY,
\`branch\` text,
\`project_id\` text NOT NULL,
\`config\` text NOT NULL,
CONSTRAINT \`fk_workspace_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);
`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,12 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260227213759_add_session_workspace_id",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session\` ADD \`workspace_id\` text;`)
yield* tx.run(`CREATE INDEX \`session_workspace_idx\` ON \`session\` (\`workspace_id\`);`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,30 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260228203230_blue_harpoon",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`account\` (
\`id\` text PRIMARY KEY,
\`email\` text NOT NULL,
\`url\` text NOT NULL,
\`access_token\` text NOT NULL,
\`refresh_token\` text NOT NULL,
\`token_expiry\` integer,
\`selected_org_id\` text,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL
);
`)
yield* tx.run(`
CREATE TABLE \`account_state\` (
\`id\` integer PRIMARY KEY NOT NULL,
\`active_account_id\` text,
FOREIGN KEY (\`active_account_id\`) REFERENCES \`account\`(\`id\`) ON UPDATE no action ON DELETE set null
);
`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,15 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260303231226_add_workspace_fields",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`workspace\` ADD \`type\` text NOT NULL;`)
yield* tx.run(`ALTER TABLE \`workspace\` ADD \`name\` text;`)
yield* tx.run(`ALTER TABLE \`workspace\` ADD \`directory\` text;`)
yield* tx.run(`ALTER TABLE \`workspace\` ADD \`extra\` text;`)
yield* tx.run(`ALTER TABLE \`workspace\` DROP COLUMN \`config\`;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,13 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260309230000_move_org_to_state",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`account_state\` ADD \`active_org_id\` text;`)
yield* tx.run(`UPDATE \`account_state\` SET \`active_org_id\` = (SELECT \`selected_org_id\` FROM \`account\` WHERE \`account\`.\`id\` = \`account_state\`.\`active_account_id\`);`)
yield* tx.run(`ALTER TABLE \`account\` DROP COLUMN \`selected_org_id\`;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,14 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260312043431_session_message_cursor",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DROP INDEX IF EXISTS \`message_session_idx\`;`)
yield* tx.run(`DROP INDEX IF EXISTS \`part_message_idx\`;`)
yield* tx.run(`CREATE INDEX \`message_session_time_created_id_idx\` ON \`message\` (\`session_id\`,\`time_created\`,\`id\`);`)
yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,26 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260323234822_events",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`event_sequence\` (
\`aggregate_id\` text PRIMARY KEY,
\`seq\` integer NOT NULL
);
`)
yield* tx.run(`
CREATE TABLE \`event\` (
\`id\` text PRIMARY KEY,
\`aggregate_id\` text NOT NULL,
\`seq\` integer NOT NULL,
\`type\` text NOT NULL,
\`data\` text NOT NULL,
CONSTRAINT \`fk_event_aggregate_id_event_sequence_aggregate_id_fk\` FOREIGN KEY (\`aggregate_id\`) REFERENCES \`event_sequence\`(\`aggregate_id\`) ON DELETE CASCADE
);
`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,27 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260410174513_workspace-name",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`PRAGMA foreign_keys=OFF;`)
yield* tx.run(`
CREATE TABLE \`__new_workspace\` (
\`id\` text PRIMARY KEY,
\`type\` text NOT NULL,
\`name\` text DEFAULT '' NOT NULL,
\`branch\` text,
\`directory\` text,
\`extra\` text,
\`project_id\` text NOT NULL,
CONSTRAINT \`fk_workspace_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`INSERT INTO \`__new_workspace\`(\`id\`, \`type\`, \`branch\`, \`name\`, \`directory\`, \`extra\`, \`project_id\`) SELECT \`id\`, \`type\`, \`branch\`, \`name\`, \`directory\`, \`extra\`, \`project_id\` FROM \`workspace\`;`)
yield* tx.run(`DROP TABLE \`workspace\`;`)
yield* tx.run(`ALTER TABLE \`__new_workspace\` RENAME TO \`workspace\`;`)
yield* tx.run(`PRAGMA foreign_keys=ON;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,24 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260413175956_chief_energizer",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`session_entry\` (
\`id\` text PRIMARY KEY,
\`session_id\` text NOT NULL,
\`type\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
\`data\` text NOT NULL,
CONSTRAINT \`fk_session_entry_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`CREATE INDEX \`session_entry_session_idx\` ON \`session_entry\` (\`session_id\`);`)
yield* tx.run(`CREATE INDEX \`session_entry_session_type_idx\` ON \`session_entry\` (\`session_id\`,\`type\`);`)
yield* tx.run(`CREATE INDEX \`session_entry_time_created_idx\` ON \`session_entry\` (\`time_created\`);`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,14 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260423070820_add_icon_url_override",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
ALTER TABLE \`project\` ADD \`icon_url_override\` text;
UPDATE \`project\` SET \`icon_url_override\` = \`icon_url\` WHERE \`icon_url\` IS NOT NULL;
`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,28 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260427172553_slow_nightmare",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`session_message\` (
\`id\` text PRIMARY KEY,
\`session_id\` text NOT NULL,
\`type\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
\`data\` text NOT NULL,
CONSTRAINT \`fk_session_message_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`DROP INDEX IF EXISTS \`session_entry_session_idx\`;`)
yield* tx.run(`DROP INDEX IF EXISTS \`session_entry_session_type_idx\`;`)
yield* tx.run(`DROP INDEX IF EXISTS \`session_entry_time_created_idx\`;`)
yield* tx.run(`CREATE INDEX \`session_message_session_idx\` ON \`session_message\` (\`session_id\`);`)
yield* tx.run(`CREATE INDEX \`session_message_session_type_idx\` ON \`session_message\` (\`session_id\`,\`type\`);`)
yield* tx.run(`CREATE INDEX \`session_message_time_created_idx\` ON \`session_message\` (\`time_created\`);`)
yield* tx.run(`DROP TABLE \`session_entry\`;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260428004200_add_session_path",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session\` ADD \`path\` text;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,12 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260501142318_next_venus",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session\` ADD \`agent\` text;`)
yield* tx.run(`ALTER TABLE \`session\` ADD \`model\` text;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260504145000_add_sync_owner",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`event_sequence\` ADD \`owner_id\` text;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260507164347_add_workspace_time",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`workspace\` ADD \`time_used\` integer NOT NULL DEFAULT 0;`)
})
},
} satisfies DatabaseMigration.Migration

Some files were not shown because too many files have changed in this diff Show More