Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 44f4211357 | |||
| 6281e35a18 | |||
| bd149f0daa | |||
| 6eec98371a | |||
| 0016dfd9b1 | |||
| fe655b7630 | |||
| 05f335ce62 | |||
| ad3651d8f1 | |||
| 633fc6fc03 | |||
| cacb7d045f | |||
| 2312a15e57 | |||
| d465cd476a | |||
| a4551a94b4 | |||
| abcfeb380b | |||
| 8cc79f7432 | |||
| 67e8b8582e | |||
| 8aff7b8c7b | |||
| 6c12c32fb1 | |||
| fbfd725d73 | |||
| 246d40db73 | |||
| 947e0017f5 | |||
| 800d41ddab | |||
| 5647ed8ba3 | |||
| af31e97493 | |||
| 5303ab09f8 | |||
| 51eb87b256 | |||
| 556337d6dd | |||
| c416ede20d | |||
| 784eea3911 | |||
| a4b9351c0f | |||
| 57895586c2 | |||
| 6298db7a77 | |||
| 5202bf1bf2 | |||
| 516cfe4e09 | |||
| 60aba622ab | |||
| e8610d821c | |||
| 53076999c5 | |||
| f8c9bfd454 | |||
| fed4f4d86b | |||
| 1490752059 | |||
| 4898263dec | |||
| c556bddda3 | |||
| cfddb2407c | |||
| cf80b5c470 | |||
| c17b9557f1 | |||
| 10f6bb7878 | |||
| b8cfd69acf | |||
| d2305d4a76 | |||
| c6cc13e183 | |||
| dcf7b4e792 | |||
| 5152150bfe | |||
| 2ba18b84a5 | |||
| e04c5e72f7 | |||
| a131811cdc | |||
| 976e5d421f | |||
| 8e2d422ffe | |||
| a3825286cf | |||
| e0c0411003 | |||
| e439456b13 | |||
| e3edbba3ca | |||
| 3cdd431794 | |||
| ef4500d6af | |||
| f416f52ae3 | |||
| e0ff4fd1ac | |||
| 428ae6d21a | |||
| d568fed095 | |||
| 58a8d9fcb0 | |||
| d24848359d |
@@ -2,4 +2,4 @@ blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 💬 Discord Community
|
||||
url: https://discord.gg/opencode
|
||||
about: For quick questions or real-time discussion. Note that issues are searchable and help others with the same question.
|
||||
about: For support, troubleshooting, how-to questions, and real-time discussion.
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
name: Question
|
||||
description: Ask a question
|
||||
body:
|
||||
- type: textarea
|
||||
id: question
|
||||
attributes:
|
||||
label: Question
|
||||
description: What's your question?
|
||||
validations:
|
||||
required: true
|
||||
@@ -277,6 +277,7 @@
|
||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
||||
"@opencode-ai/llm": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "2.9.0",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "2.6.1",
|
||||
@@ -480,6 +481,7 @@
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
"aws4fetch": "1.0.20",
|
||||
@@ -641,9 +643,9 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.3.4",
|
||||
"@opentui/keymap": ">=0.3.4",
|
||||
"@opentui/solid": ">=0.3.4",
|
||||
"@opentui/core": ">=0.4.2",
|
||||
"@opentui/keymap": ">=0.4.2",
|
||||
"@opentui/solid": ">=0.4.2",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@opentui/core",
|
||||
@@ -651,6 +653,17 @@
|
||||
"@opentui/solid",
|
||||
],
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode-ai/schema",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/script": {
|
||||
"name": "@opencode-ai/script",
|
||||
"dependencies": {
|
||||
@@ -947,9 +960,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.3.4",
|
||||
"@opentui/keymap": "0.3.4",
|
||||
"@opentui/solid": "0.3.4",
|
||||
"@opentui/core": "0.4.2",
|
||||
"@opentui/keymap": "0.4.2",
|
||||
"@opentui/solid": "0.4.2",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -1804,6 +1817,8 @@
|
||||
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
|
||||
|
||||
"@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"],
|
||||
|
||||
"@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"],
|
||||
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"],
|
||||
@@ -1854,27 +1869,27 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.3.4", "", { "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.3.4", "@opentui/core-darwin-x64": "0.3.4", "@opentui/core-linux-arm64": "0.3.4", "@opentui/core-linux-arm64-musl": "0.3.4", "@opentui/core-linux-x64": "0.3.4", "@opentui/core-linux-x64-musl": "0.3.4", "@opentui/core-win32-arm64": "0.3.4", "@opentui/core-win32-x64": "0.3.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-y0DlrChP9lcJ4jC5z/1wMS34+ygfSTW7gD5OJHwJaAScfmlFvuJOZbwmCGrJURZ+5wFBxuOi9LatZsmeAUIKAA=="],
|
||||
"@opentui/core": ["@opentui/core@0.4.2", "", { "dependencies": { "bun-ffi-structs": "0.2.3", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.2", "@opentui/core-darwin-x64": "0.4.2", "@opentui/core-linux-arm64": "0.4.2", "@opentui/core-linux-arm64-musl": "0.4.2", "@opentui/core-linux-x64": "0.4.2", "@opentui/core-linux-x64-musl": "0.4.2", "@opentui/core-win32-arm64": "0.4.2", "@opentui/core-win32-x64": "0.4.2" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-ulx6RMqftf2fm7Itf9e81GcCDMNY6NAhmnKYhllDOMYD+PxYXR+vomy2bxQNV5ow31RE7s8WQFnb7hWTRUbx2g=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4A7JYXUsZqhu9PPCe07E30ourSJYkitkwMujUyNKjM5e/dHNDVnz+5r5cO3M5snofLafc1DN7+9jEPn4UQzchQ=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-is+O+sS/l3E9cZXyM9pRF1WhqnE+hYSPYoZkbseR9CthJcaWPGi3R3jUJa1cLj325252jWgxVupnDqFUtKg36w=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jvm9E8n2sPhKEyKSXn9GlmJcj8WoJXJTooXb3djwjVaiimjihIj0XxHzCWhdqbDtQp+VxDFyCKoQagOOz20qhA=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ACi42h81DurSeybUAD1XyKT6xmXZcKeTxS54lZFi0CVZh46w0g99vNj8PlQzIFXvvFLT0e0IlRS//eWSWS2zGQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-0uPuHCeZxm/O7+L+iNQl8zRAfehiwYstKkT9J0uTZO64/byBCLvy5lvn1DiE/72s/nTJ5nwpLN+pQs2/WYVKLQ=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-RjOx2HcjLRtGSy9WrAGSdr5M9SpJuPifPORpImx6Mciovw0ltnE0uoYjIyor82uf6/LExWC7YA2AcAl+YBxayA=="],
|
||||
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-sJYUzYcSOb5PCXRlhwsse/fdsMiVomNvIwq/2TDhAANef+YPO3Br+OH9kQRbuj0bjVDmUS36SGYWSTFu2lUO+A=="],
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-heNciL2ngPU+kq1h01PHLsxn6Fr8iqTFtbxSdVbhaY3XihuIjkuXyEhFeuoa1lsXY7Bb2gpWnX5EQVWnZsAuDQ=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-btYIQeNdPbN4JCrCjVB/RwMGrnRY7qWB2piNEfALSByuULKNjPKQ33PYIj38Yd01zCvCV7FotIeXEGSHx3tgCA=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-9s0s/ooK+AhWP306By3gu+XhzcVEThC2sqKMPK1nQmGDujQhd+xOrtbtfCVcJSx62UzAovC2VNqypvP8vHByOg=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-fhmUey4oJJ2+N62xlIgAPxAl36Fa7wYffqDOT4QLpm0jfyD5xzo+wL/hr2zUqaEI439R8Iq6jHNxf/Nsx1WuuQ=="],
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cjv6Bv7l3p/KLNJr5RyqCS0FmRlAGJnkA2IK3S+HkHhCOv/O02S1G+DBUY6POnyjp1eNy95vauustApobhdbig=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-sh432vPU+eLp8eA4I0KWKKn7D0VHbk01YTg6mA9/ihCNYHntc6LZ8/sLvsPv8CvKscMotfIkh3M5YhdS36BuXw=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mfJZrJ0TNPFRZUzXNsxAPe1YdiWsy/vbTl93+yeXGHPI1B8Qnk9V5hpzSxxEyBGhlTHSfGNtgiO+VrrdRC3kZA=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-dw8FcjUZaLAjw25P3/7BarobCh/QOHn3srYaWYQdysoqyvSlPkQumpI8kV/KgpJtdITU1GW02MQC4EeLIFFalA=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.2", "", { "os": "win32", "cpu": "x64" }, "sha512-P2oguG3ng3OMjAdasFSA3GhHaQXtzDUsIRDGbzWFOimpZ/zMemidp+JQ0V8V6XwK6Utk5G0aQ03oBaRCoLyYDw=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.3.4", "", { "dependencies": { "@opentui/core": "0.3.4" }, "peerDependencies": { "@opentui/react": "0.3.4", "@opentui/solid": "0.3.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8fo6BZWQgCjANfbKkzPo0ghAzS1E7TlHjDDS+SUhrX01qEUO1clFTRssKluHbXd2UJY1Ehle01TV5bFmY78f8w=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.4.2", "", { "dependencies": { "@opentui/core": "0.4.2" }, "peerDependencies": { "@opentui/react": "0.4.2", "@opentui/solid": "0.4.2", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-wxBEFfWgm3feqCRLckWg1JH4tbMJinpyK3yobkLTsWJ7PDsM+fPoFMyQ8ieKVdUL2eP6ELTmHvM1bHKShZ7SUQ=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.3.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.4", "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-gin1VnsVBahX0nrU3mpgh5U1qvyJBIZu4NE5mc0YnObWOEf9HVNxKY4/BpUvQPh91kT6zeOzTBvAvYK4R7g9MQ=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.4.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.2", "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-zuYXsnrlsMtnXrS7QCYBdPzMtUSonG2LqnJikBR2NjEE2O4zEKvJd48n3eB1igcxjv96tiotTXRNCylYS0SNdQ=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -3052,7 +3067,7 @@
|
||||
|
||||
"builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="],
|
||||
|
||||
"bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="],
|
||||
"bun-ffi-structs": ["bun-ffi-structs@0.2.3", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-pgJiXP+hEgFo9qG51J6ItfY4ocs3vniwNzJ9WhoakB3QB2GdzQxX2EXssentPYlB2hOfJrTjO6iIQkWYzUodpg=="],
|
||||
|
||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||
|
||||
@@ -5410,8 +5425,6 @@
|
||||
|
||||
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
|
||||
|
||||
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
|
||||
|
||||
"youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
|
||||
|
||||
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
|
||||
|
||||
@@ -256,6 +256,7 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
SECRET.UpstashRedisRestToken,
|
||||
AUTH_API_URL,
|
||||
STRIPE_WEBHOOK_SECRET,
|
||||
SECRET.SupportApiKey,
|
||||
DISCORD_INCIDENT_WEBHOOK_URL,
|
||||
SECRET.HoneycombWebhookSecret,
|
||||
STRIPE_SECRET_KEY,
|
||||
|
||||
@@ -7,6 +7,7 @@ new sst.cloudflare.x.SolidStart("Teams", {
|
||||
domain: shortDomain,
|
||||
path: "packages/enterprise",
|
||||
buildCommand: "bun run build:cloudflare",
|
||||
link: [SECRET.SupportApiKey],
|
||||
environment: {
|
||||
OPENCODE_STORAGE_ADAPTER: "r2",
|
||||
OPENCODE_STORAGE_ACCOUNT_ID: sst.cloudflare.DEFAULT_ACCOUNT_ID,
|
||||
|
||||
@@ -9,6 +9,7 @@ export const SECRET = {
|
||||
R2SecretKey: new sst.Secret("R2SecretKey", "unknown"),
|
||||
HoneycombApiKey: new sst.Secret("HONEYCOMB_API_KEY"),
|
||||
HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }),
|
||||
SupportApiKey: new sst.Secret("SUPPORT_API_KEY"),
|
||||
UpstashRedisRestUrl: new sst.Secret("UpstashRedisRestUrl"),
|
||||
UpstashRedisRestToken: new sst.Secret("UpstashRedisRestToken"),
|
||||
}
|
||||
|
||||
+2
-1
@@ -56,6 +56,7 @@ const inferenceEventTable = new aws.s3tables.Table(
|
||||
{ name: "error_cause2", type: "string", required: false },
|
||||
{ name: "api_key", type: "string", required: false },
|
||||
{ name: "workspace", type: "string", required: false },
|
||||
{ name: "user_id", type: "string", required: false },
|
||||
{ name: "is_subscription", type: "boolean", required: false },
|
||||
{ name: "subscription", type: "string", required: false },
|
||||
{ name: "response_length", type: "long", required: false },
|
||||
@@ -84,7 +85,7 @@ const inferenceEventTable = new aws.s3tables.Table(
|
||||
},
|
||||
},
|
||||
},
|
||||
{ deleteBeforeReplace: $app.stage !== "production" },
|
||||
{ deleteBeforeReplace: $app.stage !== "production", ignoreChanges: ["metadata"] },
|
||||
)
|
||||
|
||||
export const inferenceEvent = new sst.Linkable("InferenceEvent", {
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-oWSGu+SP66Aquy/0Vaq7Bgp8404ZdOWbQX+O7h3jxHU=",
|
||||
"aarch64-linux": "sha256-UsS0+c+GwtIukmWwQeFbY/3Oaz3t4Q7C6cFMGkmlyAY=",
|
||||
"aarch64-darwin": "sha256-CArz92ewPmXO+ORFCBkCH8LzMpU/DjyaO4ic7QL0UpI=",
|
||||
"x86_64-darwin": "sha256-rhnz9gmG6L06wIzfMhTaXDDEf6IbMD32CavqwXoqcUs="
|
||||
"x86_64-linux": "sha256-JJnJVqP2+NiiRVoTKXjGD09lPRyfz8YMfx0m3jBaUzU=",
|
||||
"aarch64-linux": "sha256-tY3/IA+iZbOQQGxrcHMvQ1xqByskZyVI+4LZkU0wVjs=",
|
||||
"aarch64-darwin": "sha256-9qMRrQKKOgQuOcAgAq8oZacVFD7G4nbAfZawmCRuJdY=",
|
||||
"x86_64-darwin": "sha256-dR2VGxDLoAPEmk8SsEF4dhlPBcb9ubztAuVfwxuAD4M="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,13 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
writableTmpDirAsHomeHook
|
||||
];
|
||||
|
||||
postPatch = ''
|
||||
# NOTE: Relax Bun version check to be a warning instead of an error
|
||||
substituteInPlace packages/script/src/index.ts \
|
||||
--replace-fail 'throw new Error(`This script requires bun@''${expectedBunVersionRange}' \
|
||||
'console.warn(`Warning: This script requires bun@''${expectedBunVersionRange}'
|
||||
'';
|
||||
|
||||
configurePhase = ''
|
||||
runHook preConfigure
|
||||
|
||||
|
||||
+3
-3
@@ -39,9 +39,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.3.4",
|
||||
"@opentui/keymap": "0.3.4",
|
||||
"@opentui/solid": "0.3.4",
|
||||
"@opentui/core": "0.4.2",
|
||||
"@opentui/keymap": "0.4.2",
|
||||
"@opentui/solid": "0.4.2",
|
||||
"@tanstack/solid-virtual": "3.13.28",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"ulid": "3.0.1",
|
||||
|
||||
@@ -18,6 +18,8 @@ bun run test:bench
|
||||
The suite contains:
|
||||
|
||||
- cold and hot session-tab timing
|
||||
- home-session click timing split between content and titlebar-tab paint
|
||||
- single-session tab close timing through stable home restoration
|
||||
- cached session repaint and mutation tracing
|
||||
- streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
import { measureFirstNavigation } from "./first-navigation-probe"
|
||||
import { fixture } from "./session-timeline-stress.fixture"
|
||||
import {
|
||||
installStressSessionTabs,
|
||||
installTimelineSettings,
|
||||
mockStressTimeline,
|
||||
stressDraftHref,
|
||||
stressSessionHref,
|
||||
} from "./timeline-test-helpers"
|
||||
import { waitForStableTimeline } from "./session-tab-switch-probe"
|
||||
|
||||
const contentSelector = '[data-message-id], [data-component="prompt-input"]'
|
||||
const draftID = "draft_first_navigation"
|
||||
|
||||
benchmark.describe("performance: first navigation paint", () => {
|
||||
benchmark("opens an unvisited session tab without a blank frame", async ({ page, report }) => {
|
||||
await setup(page)
|
||||
const href = stressSessionHref(fixture.targetID)
|
||||
const result = await measureFirstNavigation(page, {
|
||||
href,
|
||||
destinationPath: href,
|
||||
sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!),
|
||||
destinationSelector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!),
|
||||
contentSelector,
|
||||
navigate: async () => {
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first().click()
|
||||
await expectSessionTitle(page, fixture.expected.targetTitle)
|
||||
},
|
||||
})
|
||||
report(result)
|
||||
expect(result.summary.blankSamples).toBe(0)
|
||||
expect(result.summary.unknownSamples).toBe(0)
|
||||
})
|
||||
|
||||
benchmark("opens the new session page before its lazy module is used", async ({ page, report }) => {
|
||||
await setup(page, draftID)
|
||||
const href = stressDraftHref(draftID)
|
||||
const result = await measureFirstNavigation(page, {
|
||||
href,
|
||||
destinationPath: href,
|
||||
sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!),
|
||||
destinationSelector: '[data-component="prompt-input"]',
|
||||
contentSelector,
|
||||
navigate: async () => {
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first().click()
|
||||
await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
|
||||
},
|
||||
})
|
||||
report(result)
|
||||
expect(result.summary.blankSamples).toBe(0)
|
||||
expect(result.summary.unknownSamples).toBe(0)
|
||||
})
|
||||
|
||||
benchmark("opens a child session without a blank frame", async ({ page, report }) => {
|
||||
await setup(page)
|
||||
const href = stressSessionHref(fixture.childID)
|
||||
const result = await measureFirstNavigation(page, {
|
||||
href,
|
||||
destinationPath: href,
|
||||
sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!),
|
||||
destinationSelector: messageSelector(fixture.expected.childMessageIDs.at(-1)!),
|
||||
contentSelector,
|
||||
navigate: async () => {
|
||||
await page.locator(`a[href="${href}"]`, { has: page.locator('[data-component="task-tool-card"]') }).click()
|
||||
await expectSessionTitle(page, fixture.expected.childTitle)
|
||||
},
|
||||
})
|
||||
report(result)
|
||||
expect(result.summary.blankSamples).toBe(0)
|
||||
expect(result.summary.unknownSamples).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
async function setup(page: Parameters<typeof mockStressTimeline>[0], draft?: string) {
|
||||
await mockStressTimeline(page)
|
||||
await installTimelineSettings(page)
|
||||
await installStressSessionTabs(page, draft ? { draftID: draft } : undefined)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||
}
|
||||
|
||||
function messageSelector(id: string) {
|
||||
return `[data-message-id="${id}"]`
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export type FirstNavigationSample = {
|
||||
observedAtMs: number
|
||||
source: boolean
|
||||
destination: boolean
|
||||
content: boolean
|
||||
pathname?: string
|
||||
center?: string
|
||||
}
|
||||
|
||||
function category(sample: FirstNavigationSample) {
|
||||
if (sample.destination && !sample.source) return "destination"
|
||||
if (sample.source && !sample.destination) return "source"
|
||||
if (!sample.content) return "blank"
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
export function summarizeFirstNavigation(samples: FirstNavigationSample[]) {
|
||||
const categories = samples.map(category)
|
||||
const stable = categories.findIndex(
|
||||
(value, index) =>
|
||||
value === "destination" && categories[index + 1] === "destination" && categories[index + 2] === "destination",
|
||||
)
|
||||
return {
|
||||
samples: samples.length,
|
||||
firstDestinationObservedMs: samples[categories.indexOf("destination")]?.observedAtMs ?? null,
|
||||
stableDestinationObservedMs: stable === -1 ? null : samples[stable + 2]!.observedAtMs,
|
||||
sourceSamples: categories.filter((value) => value === "source").length,
|
||||
blankSamples: categories.filter((value) => value === "blank").length,
|
||||
unknownSamples: categories.filter((value) => value === "unknown").length,
|
||||
destinationSamples: categories.filter((value) => value === "destination").length,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { summarizeFirstNavigation, type FirstNavigationSample } from "./first-navigation-metrics"
|
||||
|
||||
type FirstNavigationProbe = {
|
||||
samples: FirstNavigationSample[]
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
export async function measureFirstNavigation(
|
||||
page: Page,
|
||||
input: {
|
||||
href: string
|
||||
destinationPath: string
|
||||
sourceSelector: string
|
||||
destinationSelector: string
|
||||
contentSelector: string
|
||||
navigate: () => Promise<void>
|
||||
},
|
||||
) {
|
||||
await page.evaluate(
|
||||
({ href, destinationPath, sourceSelector, destinationSelector, contentSelector }) => {
|
||||
const samples: FirstNavigationSample[] = []
|
||||
let started: number | undefined
|
||||
let running = true
|
||||
const visible = (selector: string) =>
|
||||
[...document.querySelectorAll<HTMLElement>(selector)].some((element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const style = getComputedStyle(element)
|
||||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"
|
||||
})
|
||||
const sample = () => {
|
||||
if (!running || started === undefined) return
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
if (!running || started === undefined) return
|
||||
samples.push({
|
||||
observedAtMs: performance.now() - started,
|
||||
source: visible(sourceSelector),
|
||||
destination: `${location.pathname}${location.search}` === destinationPath && visible(destinationSelector),
|
||||
content: visible(contentSelector),
|
||||
pathname: `${location.pathname}${location.search}`,
|
||||
center: document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.textContent?.slice(0, 80),
|
||||
})
|
||||
sample()
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
const link = event.target instanceof Element ? event.target.closest("a") : undefined
|
||||
if (link?.getAttribute("href") !== href) return
|
||||
started = performance.now()
|
||||
sample()
|
||||
},
|
||||
{ capture: true, once: true },
|
||||
)
|
||||
;(window as Window & { __firstNavigationProbe?: FirstNavigationProbe }).__firstNavigationProbe = {
|
||||
samples,
|
||||
stop: () => {
|
||||
running = false
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
href: input.href,
|
||||
destinationPath: input.destinationPath,
|
||||
sourceSelector: input.sourceSelector,
|
||||
destinationSelector: input.destinationSelector,
|
||||
contentSelector: input.contentSelector,
|
||||
},
|
||||
)
|
||||
await input.navigate()
|
||||
await page.waitForFunction(() => {
|
||||
const samples = (window as Window & { __firstNavigationProbe?: FirstNavigationProbe }).__firstNavigationProbe
|
||||
?.samples
|
||||
if (!samples) return false
|
||||
return samples.length >= 3 && samples.slice(-3).every((sample) => sample.destination && !sample.source)
|
||||
})
|
||||
const samples = await page.evaluate(() => {
|
||||
const probe = (window as Window & { __firstNavigationProbe?: FirstNavigationProbe }).__firstNavigationProbe!
|
||||
probe.stop()
|
||||
return probe.samples
|
||||
})
|
||||
return { summary: summarizeFirstNavigation(samples), samples }
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { measureNavigationMilestones } from "./navigation-milestones"
|
||||
import { fixture } from "./session-timeline-stress.fixture"
|
||||
import {
|
||||
installStressSessionTabs,
|
||||
installTimelineSettings,
|
||||
mockStressTimeline,
|
||||
stressSessionHref,
|
||||
} from "./timeline-test-helpers"
|
||||
import { waitForStableTimeline } from "./session-tab-switch-probe"
|
||||
|
||||
const homeRow = '[data-component="home-session-row"]'
|
||||
const homeShell = '[data-component="home-session-search"]'
|
||||
|
||||
benchmark.describe("performance: home and tab navigation", () => {
|
||||
benchmark("opens a home session and paints its titlebar tab", async ({ page, report }) => {
|
||||
await setup(page, [])
|
||||
await page.goto("/")
|
||||
const row = page.locator(homeRow).filter({ hasText: fixture.expected.targetTitle }).first()
|
||||
await expect(row).toBeVisible()
|
||||
const href = stressSessionHref(fixture.targetID)
|
||||
const result = await measureNavigationMilestones(page, {
|
||||
triggerSelector: homeRow,
|
||||
milestones: {
|
||||
content: { selector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!) },
|
||||
tab: { selector: `[data-slot="titlebar-tabs"] a[href="${href}"]` },
|
||||
},
|
||||
navigate: async () => {
|
||||
await row.click()
|
||||
await expectSessionTitle(page, fixture.expected.targetTitle)
|
||||
},
|
||||
})
|
||||
report(result)
|
||||
await expect(page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`)).toContainText(
|
||||
fixture.expected.targetTitle,
|
||||
)
|
||||
})
|
||||
|
||||
benchmark("stages the review body after cold session content", async ({ page, report }) => {
|
||||
await setup(page, [])
|
||||
await page.goto("/")
|
||||
const row = page.locator(homeRow).filter({ hasText: fixture.expected.targetTitle }).first()
|
||||
await expect(row).toBeVisible()
|
||||
const result = await page.evaluate(
|
||||
({ rowSelector, title, contentSelector }) =>
|
||||
new Promise<{ contentBeforeReview: boolean; samples: number }>((resolve) => {
|
||||
let samples = 0
|
||||
const sample = () => {
|
||||
samples++
|
||||
const content = !!document.querySelector(contentSelector)
|
||||
const review = !!document.querySelector('[data-component="session-review"]')
|
||||
if (content && !review) {
|
||||
resolve({ contentBeforeReview: true, samples })
|
||||
return
|
||||
}
|
||||
if (content && review) {
|
||||
resolve({ contentBeforeReview: false, samples })
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
}
|
||||
const target = [...document.querySelectorAll<HTMLElement>(rowSelector)].find((item) =>
|
||||
item.textContent?.includes(title),
|
||||
)
|
||||
if (!target) throw new Error(`Home session row not found: ${title}`)
|
||||
target.click()
|
||||
requestAnimationFrame(sample)
|
||||
}),
|
||||
{
|
||||
rowSelector: homeRow,
|
||||
title: fixture.expected.targetTitle,
|
||||
contentSelector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!),
|
||||
},
|
||||
)
|
||||
report(result)
|
||||
expect(result.contentBeforeReview).toBe(true)
|
||||
await expect(page.locator('[data-component="session-review"]')).toBeVisible()
|
||||
})
|
||||
|
||||
benchmark("closes the only session tab and paints home", async ({ page, report }) => {
|
||||
await setup(page, [fixture.sourceID])
|
||||
const href = stressSessionHref(fixture.sourceID)
|
||||
await page.goto(href)
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
||||
const close = tab.locator("..").locator('[data-component="icon-button-v2"]')
|
||||
await expect(close).toBeVisible()
|
||||
const result = await measureNavigationMilestones(page, {
|
||||
triggerSelector: '[data-slot="titlebar-tabs"] [data-component="icon-button-v2"]',
|
||||
milestones: {
|
||||
home: { selector: homeShell },
|
||||
row: { selector: homeRow },
|
||||
tabRemoved: { selector: `[data-slot="titlebar-tabs"] a[href="${href}"]`, visible: false },
|
||||
},
|
||||
navigate: async () => {
|
||||
await close.click()
|
||||
await expect(page).toHaveURL("/")
|
||||
},
|
||||
})
|
||||
report(result)
|
||||
})
|
||||
})
|
||||
|
||||
async function setup(page: Parameters<typeof mockStressTimeline>[0], sessionIDs: string[]) {
|
||||
await mockStressTimeline(page)
|
||||
await installTimelineSettings(page)
|
||||
await installStressSessionTabs(page, { sessionIDs })
|
||||
}
|
||||
|
||||
function messageSelector(id: string) {
|
||||
return `[data-message-id="${id}"]`
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
|
||||
export type NavigationMilestoneSample = {
|
||||
observedAtMs: number
|
||||
milestones: Record<string, boolean>
|
||||
}
|
||||
|
||||
export function summarizeNavigationMilestones(samples: NavigationMilestoneSample[]) {
|
||||
const names = Object.keys(samples[0]?.milestones ?? {})
|
||||
const summarize = (matches: (sample: NavigationMilestoneSample) => boolean) => {
|
||||
const first = samples.find(matches)
|
||||
const stable = samples.findIndex(
|
||||
(sample, index) =>
|
||||
index + 2 < samples.length && matches(sample) && matches(samples[index + 1]!) && matches(samples[index + 2]!),
|
||||
)
|
||||
return {
|
||||
firstObservedMs: first?.observedAtMs ?? null,
|
||||
stableObservedMs: stable === -1 ? null : samples[stable + 2]!.observedAtMs,
|
||||
}
|
||||
}
|
||||
return {
|
||||
samples: samples.length,
|
||||
milestones: Object.fromEntries(
|
||||
names.map((name) => [name, summarize((sample) => sample.milestones[name] === true)]),
|
||||
),
|
||||
all: summarize((sample) => names.every((name) => sample.milestones[name] === true)),
|
||||
}
|
||||
}
|
||||
|
||||
type NavigationMilestoneProbe = {
|
||||
samples: NavigationMilestoneSample[]
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
export async function measureNavigationMilestones(
|
||||
page: Page,
|
||||
input: {
|
||||
triggerSelector: string
|
||||
milestones: Record<string, { selector: string; visible?: boolean }>
|
||||
navigate: () => Promise<void>
|
||||
},
|
||||
) {
|
||||
await page.evaluate(
|
||||
({ triggerSelector, milestones }) => {
|
||||
const samples: NavigationMilestoneSample[] = []
|
||||
const streaks = new Map<string, number>()
|
||||
const marked = new Set<string>()
|
||||
let started: number | undefined
|
||||
let running = true
|
||||
const visible = (selector: string) =>
|
||||
[...document.querySelectorAll<HTMLElement>(selector)].some((element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const style = getComputedStyle(element)
|
||||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"
|
||||
})
|
||||
const sample = () => {
|
||||
if (!running || started === undefined) return
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
if (!running || started === undefined) return
|
||||
const current = Object.fromEntries(
|
||||
Object.entries(milestones).map(([name, milestone]) => [
|
||||
name,
|
||||
milestone.visible === false ? !document.querySelector(milestone.selector) : visible(milestone.selector),
|
||||
]),
|
||||
)
|
||||
samples.push({
|
||||
observedAtMs: performance.now() - started,
|
||||
milestones: current,
|
||||
})
|
||||
Object.entries(current).forEach(([name, value]) => {
|
||||
if (!value) {
|
||||
streaks.set(name, 0)
|
||||
return
|
||||
}
|
||||
if (!marked.has(`${name}.first`)) {
|
||||
performance.mark(`opencode.navigation.${name}.first`)
|
||||
marked.add(`${name}.first`)
|
||||
}
|
||||
const streak = (streaks.get(name) ?? 0) + 1
|
||||
streaks.set(name, streak)
|
||||
if (streak === 3) performance.mark(`opencode.navigation.${name}.stable`)
|
||||
})
|
||||
const all = Object.values(current).every(Boolean)
|
||||
const allStreak = all ? (streaks.get("all") ?? 0) + 1 : 0
|
||||
streaks.set("all", allStreak)
|
||||
if (all && !marked.has("all.first")) {
|
||||
performance.mark("opencode.navigation.all.first")
|
||||
marked.add("all.first")
|
||||
}
|
||||
if (allStreak === 3) performance.mark("opencode.navigation.all.stable")
|
||||
sample()
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
if (!(event.target instanceof Element) || !event.target.closest(triggerSelector)) return
|
||||
started = performance.now()
|
||||
performance.mark("opencode.navigation.click")
|
||||
sample()
|
||||
},
|
||||
{ capture: true, once: true },
|
||||
)
|
||||
;(window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones = {
|
||||
samples,
|
||||
stop: () => {
|
||||
running = false
|
||||
},
|
||||
}
|
||||
},
|
||||
{ triggerSelector: input.triggerSelector, milestones: input.milestones },
|
||||
)
|
||||
await input.navigate()
|
||||
await page.waitForFunction(() => {
|
||||
const samples = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones
|
||||
?.samples
|
||||
if (!samples || samples.length < 3) return false
|
||||
return samples.slice(-3).every((sample) => Object.values(sample.milestones).every(Boolean))
|
||||
})
|
||||
const samples = await page.evaluate(() => {
|
||||
const probe = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones!
|
||||
probe.stop()
|
||||
return probe.samples
|
||||
})
|
||||
return { summary: summarizeNavigationMilestones(samples), samples }
|
||||
}
|
||||
@@ -47,3 +47,21 @@ benchmark("samples cached session repaint after the click", async ({ page, repor
|
||||
report(compressCachedRepaintTrace(result))
|
||||
expect(result.samples.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
benchmark("prefetches every open session tab", async ({ page, report }) => {
|
||||
const prefetched = new Set<string>()
|
||||
await mockStressTimeline(page, {
|
||||
onMessages: (input) => {
|
||||
if (!input.before && input.phase === "start") prefetched.add(input.sessionID)
|
||||
},
|
||||
})
|
||||
await installStressSessionTabs(page, {
|
||||
sessionIDs: [fixture.sourceID, fixture.targetID, fixture.childID],
|
||||
})
|
||||
await installTimelineSettings(page)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
|
||||
await expect.poll(() => prefetched.has(fixture.childID)).toBe(true)
|
||||
report({ prefetched: [...prefetched] })
|
||||
})
|
||||
|
||||
@@ -23,6 +23,7 @@ const words = [
|
||||
|
||||
const sourceID = "ses_smoke_source"
|
||||
const targetID = "ses_smoke_target"
|
||||
const childID = "ses_smoke_child"
|
||||
const directory = "C:/OpenCode/SmokeProject"
|
||||
const projectID = "proj_smoke_timeline"
|
||||
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||
@@ -126,9 +127,11 @@ function toolPart(
|
||||
tool: string,
|
||||
input: Record<string, unknown>,
|
||||
outputLength = 160,
|
||||
metadataOverride?: Record<string, unknown>,
|
||||
): MessagePart {
|
||||
const metadata =
|
||||
tool === "apply_patch"
|
||||
metadataOverride ??
|
||||
(tool === "apply_patch"
|
||||
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
|
||||
: tool === "edit" || tool === "write"
|
||||
? {
|
||||
@@ -138,7 +141,7 @@ function toolPart(
|
||||
}
|
||||
: tool === "question"
|
||||
? { answers: [["Proceed"], ["Keep sample output"]] }
|
||||
: {}
|
||||
: {})
|
||||
return {
|
||||
id: id(`prt_tool_${tool}_${partIndex}`, index),
|
||||
type: "tool",
|
||||
@@ -244,7 +247,25 @@ function turn(index: number): Message[] {
|
||||
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
|
||||
const sourceMessages = Array.from({ length: 12 }, (_, index) => [
|
||||
userMessage(sourceID, index + 1000, 120),
|
||||
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
|
||||
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [
|
||||
textPart(index + 1000, 0, 240),
|
||||
...(index === 11
|
||||
? [
|
||||
toolPart(
|
||||
index + 1000,
|
||||
1,
|
||||
"task",
|
||||
{ description: "Inspect child navigation", subagent_type: "explore" },
|
||||
160,
|
||||
{ sessionId: childID },
|
||||
),
|
||||
]
|
||||
: []),
|
||||
]),
|
||||
]).flat()
|
||||
const childMessages = Array.from({ length: 4 }, (_, index) => [
|
||||
userMessage(childID, index + 2000, 120),
|
||||
assistantMessage(childID, index + 2000, id("msg_user", index + 2000), [textPart(index + 2000, 0, 240)]),
|
||||
]).flat()
|
||||
|
||||
function renderable(part: MessagePart) {
|
||||
@@ -298,19 +319,32 @@ export const fixture = {
|
||||
version: "dev",
|
||||
time: { created: 1700000001000, updated: 1700000001000 },
|
||||
},
|
||||
{
|
||||
id: childID,
|
||||
parentID: sourceID,
|
||||
slug: "child",
|
||||
projectID,
|
||||
directory,
|
||||
title: "Inspect child navigation",
|
||||
version: "dev",
|
||||
time: { created: 1700000002000, updated: 1700000002000 },
|
||||
},
|
||||
],
|
||||
sourceID,
|
||||
targetID,
|
||||
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages },
|
||||
childID,
|
||||
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages, [childID]: childMessages },
|
||||
expected: {
|
||||
sourceTitle: "Uncommitted changes inquiry",
|
||||
targetTitle: "Example Game: sample jump movement & sample physics analysis",
|
||||
childTitle: "Inspect child navigation",
|
||||
sourceMessageIDs: sourceMessages
|
||||
.filter((message) => message.info.role === "user")
|
||||
.map((message) => message.info.id),
|
||||
targetMessageIDs: targetMessages
|
||||
.filter((message) => message.info.role === "user")
|
||||
.map((message) => message.info.id),
|
||||
childMessageIDs: childMessages.filter((message) => message.info.role === "user").map((message) => message.info.id),
|
||||
targetPartIDs: targetMessages.flatMap((message) =>
|
||||
orderedParts(message)
|
||||
.filter(renderable)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { fixture } from "./session-timeline-stress.fixture"
|
||||
import { fixture, pageMessages } from "./session-timeline-stress.fixture"
|
||||
|
||||
export async function installTimelineSettings(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
@@ -9,6 +9,7 @@ export async function installTimelineSettings(page: Page) {
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
general: {
|
||||
newLayoutDesigns: true,
|
||||
editToolPartsExpanded: true,
|
||||
shellToolPartsExpanded: true,
|
||||
showReasoningSummaries: true,
|
||||
@@ -19,20 +20,24 @@ export async function installTimelineSettings(page: Page) {
|
||||
})
|
||||
}
|
||||
|
||||
export function mockStressTimeline(page: Page) {
|
||||
export function mockStressTimeline(
|
||||
page: Page,
|
||||
input?: { onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void },
|
||||
) {
|
||||
return mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
|
||||
pageMessages,
|
||||
onMessages: input?.onMessages,
|
||||
})
|
||||
}
|
||||
|
||||
export async function installStressSessionTabs(page: Page) {
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
export async function installStressSessionTabs(page: Page, input?: { draftID?: string; sessionIDs?: string[] }) {
|
||||
const server = stressServer()
|
||||
await page.addInitScript(
|
||||
({ directory, sourceID, targetID, dirBase64, server }) => {
|
||||
({ directory, sessionIDs, dirBase64, server, draftID }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
@@ -42,26 +47,35 @@ export async function installStressSessionTabs(page: Page) {
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:tabs",
|
||||
JSON.stringify(
|
||||
[sourceID, targetID].map((sessionId) => ({
|
||||
JSON.stringify([
|
||||
...sessionIDs.map((sessionId) => ({
|
||||
type: "session",
|
||||
server,
|
||||
dirBase64,
|
||||
sessionId,
|
||||
})),
|
||||
),
|
||||
...(draftID ? [{ type: "draft", draftID, server, directory }] : []),
|
||||
]),
|
||||
)
|
||||
},
|
||||
{
|
||||
directory: fixture.directory,
|
||||
sourceID: fixture.sourceID,
|
||||
targetID: fixture.targetID,
|
||||
sessionIDs: input?.sessionIDs ?? [fixture.sourceID, fixture.targetID],
|
||||
dirBase64: base64Encode(fixture.directory),
|
||||
server,
|
||||
draftID: input?.draftID,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export function stressSessionHref(sessionID: string) {
|
||||
return `/${base64Encode(fixture.directory)}/session/${sessionID}`
|
||||
return `/server/${base64Encode(stressServer())}/session/${sessionID}`
|
||||
}
|
||||
|
||||
export function stressDraftHref(draftID: string) {
|
||||
return `/new-session?draftId=${encodeURIComponent(draftID)}`
|
||||
}
|
||||
|
||||
function stressServer() {
|
||||
return `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { summarizeFirstNavigation } from "../timeline/first-navigation-metrics"
|
||||
|
||||
test("reports blank frames before first destination and stable paint", () => {
|
||||
expect(
|
||||
summarizeFirstNavigation([
|
||||
{ observedAtMs: 16, source: true, destination: false, content: true },
|
||||
{ observedAtMs: 32, source: false, destination: false, content: false },
|
||||
{ observedAtMs: 48, source: false, destination: true, content: true },
|
||||
{ observedAtMs: 64, source: false, destination: true, content: true },
|
||||
{ observedAtMs: 80, source: false, destination: true, content: true },
|
||||
]),
|
||||
).toEqual({
|
||||
samples: 5,
|
||||
firstDestinationObservedMs: 48,
|
||||
stableDestinationObservedMs: 80,
|
||||
sourceSamples: 1,
|
||||
blankSamples: 1,
|
||||
unknownSamples: 0,
|
||||
destinationSamples: 3,
|
||||
})
|
||||
})
|
||||
|
||||
test("does not report stability for interrupted destination frames", () => {
|
||||
expect(
|
||||
summarizeFirstNavigation([
|
||||
{ observedAtMs: 16, source: false, destination: true, content: true },
|
||||
{ observedAtMs: 32, source: false, destination: false, content: true },
|
||||
{ observedAtMs: 48, source: false, destination: true, content: true },
|
||||
]).stableDestinationObservedMs,
|
||||
).toBeNull()
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { summarizeNavigationMilestones } from "../timeline/navigation-milestones"
|
||||
|
||||
test("reports first and stable paint for each navigation milestone", () => {
|
||||
expect(
|
||||
summarizeNavigationMilestones([
|
||||
{ observedAtMs: 16, milestones: { content: false, tab: false } },
|
||||
{ observedAtMs: 32, milestones: { content: true, tab: false } },
|
||||
{ observedAtMs: 48, milestones: { content: true, tab: true } },
|
||||
{ observedAtMs: 64, milestones: { content: true, tab: true } },
|
||||
{ observedAtMs: 80, milestones: { content: true, tab: true } },
|
||||
]),
|
||||
).toEqual({
|
||||
samples: 5,
|
||||
milestones: {
|
||||
content: { firstObservedMs: 32, stableObservedMs: 64 },
|
||||
tab: { firstObservedMs: 48, stableObservedMs: 80 },
|
||||
},
|
||||
all: { firstObservedMs: 48, stableObservedMs: 80 },
|
||||
})
|
||||
})
|
||||
|
||||
test("reports missing stability when a milestone appears in the final samples", () => {
|
||||
expect(
|
||||
summarizeNavigationMilestones([
|
||||
{ observedAtMs: 16, milestones: { content: false } },
|
||||
{ observedAtMs: 32, milestones: { content: true } },
|
||||
]),
|
||||
).toEqual({
|
||||
samples: 2,
|
||||
milestones: { content: { firstObservedMs: 32, stableObservedMs: null } },
|
||||
all: { firstObservedMs: 32, stableObservedMs: null },
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { fixture } from "../timeline/session-timeline-stress.fixture"
|
||||
import { stressSessionHref } from "../timeline/timeline-test-helpers"
|
||||
|
||||
test("builds stress session links for the benchmark server", () => {
|
||||
expect(stressSessionHref(fixture.sourceID)).toBe(
|
||||
`/server/${base64Encode("http://127.0.0.1:4096")}/session/${fixture.sourceID}`,
|
||||
)
|
||||
})
|
||||
@@ -57,8 +57,8 @@ test("shows a comment button when a line number is hovered", async ({ page }) =>
|
||||
await page.mouse.move(0, 0)
|
||||
await lineNumber.hover()
|
||||
await expect(comment).toBeVisible({ timeout: 500 })
|
||||
await comment.click({ timeout: 500 })
|
||||
}).toPass()
|
||||
await comment.click()
|
||||
await expect(review.getByRole("textbox")).toBeVisible()
|
||||
})
|
||||
|
||||
|
||||
@@ -58,7 +58,18 @@ test.describe("smoke: session timeline", () => {
|
||||
await page.mouse.wheel(0, -120)
|
||||
await page.waitForTimeout(20)
|
||||
}
|
||||
const keys = ["prt_user_text_smoke_0032", "prt_text_2_smoke_0032", "prt_tool_apply_patch_8_smoke_0032"]
|
||||
const keys = await scroller.evaluate((element) => {
|
||||
const view = element.getBoundingClientRect()
|
||||
return [...element.querySelectorAll<HTMLElement>("[data-timeline-part-id]")]
|
||||
.filter((row) => {
|
||||
const rect = row.getBoundingClientRect()
|
||||
return rect.bottom > view.top && rect.top < view.bottom
|
||||
})
|
||||
.map((row) => row.dataset.timelinePartId)
|
||||
.filter((id): id is string => !!id)
|
||||
.slice(0, 3)
|
||||
})
|
||||
expect(keys.length).toBeGreaterThan(0)
|
||||
const positions = () =>
|
||||
scroller.evaluate((element, keys) => {
|
||||
const top = element.getBoundingClientRect().top
|
||||
@@ -718,7 +729,6 @@ async function navigateToSession(page: Page, directory: string, sessionId: strin
|
||||
}
|
||||
|
||||
async function switchTitlebarSession(page: Page, sessionID: string, title: string) {
|
||||
console.log(process.env)
|
||||
const href = `/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`
|
||||
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
||||
await expect(tab).toBeVisible()
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface MockServerConfig {
|
||||
}
|
||||
|
||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const cursors = new Map<string, string>()
|
||||
let nextCursor = 0
|
||||
const staticRoutes: Record<string, unknown> = {
|
||||
"/provider": config.provider,
|
||||
"/path": {
|
||||
@@ -68,13 +70,18 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
|
||||
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
||||
if (messagesMatch) {
|
||||
const before = url.searchParams.get("before") ?? undefined
|
||||
const token = url.searchParams.get("before") ?? undefined
|
||||
const before = token ? cursors.get(token) : undefined
|
||||
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
|
||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
|
||||
if (config.messageDelay) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||
const limit = Number(url.searchParams.get("limit") ?? 80)
|
||||
const pageData = config.pageMessages(messagesMatch[1], limit, before)
|
||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
|
||||
return json(route, pageData.items, pageData.cursor ? { "x-next-cursor": pageData.cursor } : undefined)
|
||||
if (!pageData.cursor) return json(route, pageData.items)
|
||||
const cursor = `cursor_${++nextCursor}`
|
||||
cursors.set(cursor, pageData.cursor)
|
||||
return json(route, pageData.items, { "x-next-cursor": cursor })
|
||||
}
|
||||
|
||||
if (url.port === targetPort && targetPort !== appPort) return json(route, {})
|
||||
@@ -82,9 +89,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
})
|
||||
}
|
||||
|
||||
function json(route: Route, body: unknown, headers?: Record<string, string>) {
|
||||
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
status,
|
||||
contentType: "application/json",
|
||||
headers: {
|
||||
"access-control-allow-origin": "*",
|
||||
|
||||
+177
-181
@@ -10,7 +10,7 @@ import { Splash } from "@opencode-ai/ui/logo"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { keepPreviousData, QueryClient, QueryClientProvider, useQuery } from "@tanstack/solid-query"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
type Component,
|
||||
@@ -32,7 +32,7 @@ import { CommentsProvider } from "@/context/comments"
|
||||
import { FileProvider } from "@/context/file"
|
||||
import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk"
|
||||
import { ServerSyncProvider } from "@/context/server-sync"
|
||||
import { GlobalProvider } from "@/context/global"
|
||||
import { GlobalProvider, useGlobal } from "@/context/global"
|
||||
import { HighlightsProvider } from "@/context/highlights"
|
||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||
import { LayoutProvider } from "@/context/layout"
|
||||
@@ -53,56 +53,127 @@ import { ErrorPage } from "./pages/error"
|
||||
import { useCheckServerHealth } from "./utils/server-health"
|
||||
import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./utils/session-route"
|
||||
|
||||
const LegacyHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.LegacyHome })))
|
||||
const NewHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.NewHome })))
|
||||
const Session = lazy(() => import("@/pages/session"))
|
||||
import Session from "@/pages/session"
|
||||
import { NewHome, LegacyHome } from "@/pages/home"
|
||||
|
||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||
|
||||
const SessionRoute = Object.assign(
|
||||
() => {
|
||||
const settings = useSettings()
|
||||
const params = useParams()
|
||||
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||
const sdk = useSDK()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const SessionRoute = () => {
|
||||
const settings = useSettings()
|
||||
const params = useParams()
|
||||
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||
const sdk = useSDK()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
|
||||
if (params.id && settings.general.newLayoutDesigns()) {
|
||||
return <Navigate href={sessionHref(server.key, params.id)} />
|
||||
}
|
||||
if (params.id && settings.general.newLayoutDesigns()) {
|
||||
return <Navigate href={sessionHref(server.key, params.id)} />
|
||||
}
|
||||
|
||||
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
|
||||
// is replaced by a draft at /new-session?draftId=…
|
||||
createEffect(() => {
|
||||
if (!settings.general.newLayoutDesigns()) return
|
||||
if (params.id || search.draftId) return
|
||||
if (!tabs.ready() || !sdk().directory) return
|
||||
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
|
||||
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
|
||||
// is replaced by a draft at /new-session?draftId=…
|
||||
createEffect(() => {
|
||||
if (!settings.general.newLayoutDesigns()) return
|
||||
if (params.id || search.draftId) return
|
||||
if (!tabs.ready() || !sdk().directory) return
|
||||
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
|
||||
})
|
||||
|
||||
return (
|
||||
<SessionProviders>
|
||||
<Session />
|
||||
</SessionProviders>
|
||||
)
|
||||
}
|
||||
|
||||
const TargetSessionRoute = () => {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
const server = useServer()
|
||||
const conn = createMemo(() => {
|
||||
const key = requireServerKey(params.serverKey)
|
||||
return server.list.find((item) => ServerConnection.key(item) === key)
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={`${params.serverKey}\0${params.id}`} keyed>
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>
|
||||
<ResolvedTargetSessionRoute />
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function ResolvedTargetSessionRoute() {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
const settings = useSettings()
|
||||
const tabs = useTabs()
|
||||
const global = useGlobal()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverKey = createMemo(() => requireServerKey(params.serverKey))
|
||||
const placement = createMemo(() => global.sessionPlacement.get(serverKey(), params.id))
|
||||
const [resolved] = createResource(
|
||||
() => {
|
||||
if (placement()) return
|
||||
return { id: params.id, sdk: serverSDK() }
|
||||
},
|
||||
async ({ id, sdk }) => {
|
||||
const session = (await sdk.client.session.get({ sessionID: id })).data!
|
||||
const root = await rootSession(session, (sessionID) =>
|
||||
sdk.client.session.get({ sessionID }).then((result) => result.data!),
|
||||
)
|
||||
return global.sessionPlacement.set({
|
||||
server: serverKey(),
|
||||
leafID: session.id,
|
||||
rootID: root.id,
|
||||
directory: session.directory,
|
||||
})
|
||||
},
|
||||
)
|
||||
const directory = createMemo(() => placement()?.directory ?? resolved()?.directory)
|
||||
const targetDirectory = () => directory()!
|
||||
|
||||
createEffect(() => {
|
||||
const current = placement() ?? resolved()
|
||||
if (!current) return
|
||||
tabs.addSessionTab({
|
||||
server: serverKey(),
|
||||
sessionId: current.rootID,
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
return (
|
||||
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
|
||||
<Show when={!resolved.error} fallback={<ErrorPage error={resolved.error} />}>
|
||||
<Show when={directory()}>
|
||||
<Show
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
fallback={<Navigate href={legacySessionHref(directory()!, params.id)} />}
|
||||
>
|
||||
<SDKProvider directory={targetDirectory}>
|
||||
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
|
||||
<TargetSessionPage />
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</TargetServerScopedProviders>
|
||||
)
|
||||
}
|
||||
|
||||
function TargetSessionPage() {
|
||||
const sdk = useSDK()
|
||||
const serverSDK = useServerSDK()
|
||||
return (
|
||||
<Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
|
||||
<SessionProviders>
|
||||
<Session />
|
||||
</SessionProviders>
|
||||
)
|
||||
},
|
||||
{ preload: Session.preload },
|
||||
)
|
||||
|
||||
const TargetSessionRoute = Object.assign(
|
||||
() => {
|
||||
const sdk = useSDK()
|
||||
const serverSDK = useServerSDK()
|
||||
return (
|
||||
<Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
|
||||
<SessionProviders>
|
||||
<Session />
|
||||
</SessionProviders>
|
||||
</Show>
|
||||
)
|
||||
},
|
||||
{ preload: Session.preload },
|
||||
)
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
||||
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
|
||||
@@ -125,121 +196,42 @@ function LegacyServerLayout(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// Wraps /new-session. It resolves the draft's target server and provides the
|
||||
// server-scoped shell for that server — without ServerKey, so the page never depends
|
||||
// on the globally "selected" server.
|
||||
function TargetServerLayout(props: ParentProps) {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const params = useParams<{ serverKey?: string }>()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const conn = createMemo(() => {
|
||||
if (params.serverKey) {
|
||||
const key = requireServerKey(params.serverKey)
|
||||
return server.list.find((item) => ServerConnection.key(item) === key)
|
||||
}
|
||||
const id = search.draftId
|
||||
if (!id) return undefined
|
||||
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === id)
|
||||
if (!draft) return undefined
|
||||
return server.list.find((c) => ServerConnection.key(c) === draft.server)
|
||||
})
|
||||
|
||||
return (
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>
|
||||
<TargetDirectoryLayout>{props.children}</TargetDirectoryLayout>
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TargetDirectoryLayout(props: ParentProps) {
|
||||
const params = useParams<{ serverKey?: string; id?: string }>()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const settings = useSettings()
|
||||
const tabs = useTabs()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverKey = createMemo(() => {
|
||||
if (params.serverKey) return requireServerKey(params.serverKey)
|
||||
if (!search.draftId) return undefined
|
||||
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)?.server
|
||||
})
|
||||
|
||||
const resolved = useQuery(() => ({
|
||||
queryKey: [serverSDK().scope, "session-route", params.id] as const,
|
||||
enabled: !!params.serverKey && !!params.id,
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: async () => {
|
||||
const session = (await serverSDK().client.session.get({ sessionID: params.id! })).data!
|
||||
const root = await rootSession(session, (sessionID) =>
|
||||
serverSDK()
|
||||
.client.session.get({ sessionID })
|
||||
.then((result) => result.data!),
|
||||
)
|
||||
return { session, rootID: root.id }
|
||||
},
|
||||
}))
|
||||
const resolvedDirectory = createMemo(() => {
|
||||
if (params.serverKey) return resolved.data?.session.directory
|
||||
if (!search.draftId) return undefined
|
||||
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)?.directory
|
||||
})
|
||||
const directory = createMemo<string | undefined>((prev) => prev ?? resolvedDirectory())
|
||||
const home = () => !params.serverKey && !search.draftId
|
||||
const targetDirectory = () => directory()!
|
||||
|
||||
createEffect(() => {
|
||||
const current = resolved.data
|
||||
const key = serverKey()
|
||||
if (!current || !key) return
|
||||
tabs.addSessionTab({
|
||||
server: key,
|
||||
sessionId: current.rootID,
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<NewServerScopedShell directory={() => (home() ? undefined : directory())} sessionID={() => params.id}>
|
||||
<Show when={!home()} fallback={props.children}>
|
||||
<Show when={!resolved.error} fallback={<ErrorPage error={resolved.error} />}>
|
||||
<Show when={directory()}>
|
||||
<Show
|
||||
when={!params.serverKey || settings.general.newLayoutDesigns()}
|
||||
fallback={<Navigate href={legacySessionHref(directory()!, params.id!)} />}
|
||||
>
|
||||
<SDKProvider directory={targetDirectory}>
|
||||
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
|
||||
<Show when={!params.serverKey || (resolved.data && !resolved.isPlaceholderData)}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</NewServerScopedShell>
|
||||
)
|
||||
}
|
||||
|
||||
function DraftRoute() {
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const tabs = useTabs()
|
||||
return (
|
||||
<Show when={tabs.ready()}>
|
||||
<Show when={search.draftId} keyed fallback={<Navigate href="/" />}>
|
||||
<ResolvedDraftRoute />
|
||||
<Show
|
||||
when={tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)}
|
||||
keyed
|
||||
fallback={<Navigate href="/" />}
|
||||
>
|
||||
{(draft) => <ResolvedDraftRoute draft={draft} />}
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function ResolvedDraftRoute() {
|
||||
function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||
const server = useServer()
|
||||
const conn = createMemo(() => server.list.find((item) => ServerConnection.key(item) === props.draft.server))
|
||||
const directory = () => props.draft.directory
|
||||
const serverKey = () => props.draft.server
|
||||
|
||||
return (
|
||||
<DraftProviders>
|
||||
<NewSession />
|
||||
</DraftProviders>
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>
|
||||
<TargetServerScopedProviders directory={directory}>
|
||||
<SDKProvider directory={directory}>
|
||||
<DirectoryDataProvider directory={directory} server={serverKey}>
|
||||
<DraftProviders>
|
||||
<NewSession />
|
||||
</DraftProviders>
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
</TargetServerScopedProviders>
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -302,9 +294,7 @@ function SharedProviders(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// Server-scoped providers plus the visual Layout (tabs/sidebar). These live inside
|
||||
// each per-route server layout so they resolve to that route's server (selected vs
|
||||
// draft). The Layout remounts when crossing between those groups.
|
||||
// Server-scoped providers shared by the legacy shell and the top-level new shell.
|
||||
type ServerScopedShellProps = ParentProps<{
|
||||
directory?: () => string | undefined
|
||||
sessionID?: () => string | undefined
|
||||
@@ -330,11 +320,23 @@ function LegacyServerScopedShell(props: ServerScopedShellProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function NewServerScopedShell(props: ServerScopedShellProps) {
|
||||
function NewAppLayout(props: ParentProps) {
|
||||
return (
|
||||
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID}>
|
||||
<NewLayout>{props.children}</NewLayout>
|
||||
</ServerScopedProviders>
|
||||
<SelectedServerProviders>
|
||||
<ServerScopedProviders>
|
||||
<NewLayout>{props.children}</NewLayout>
|
||||
</ServerScopedProviders>
|
||||
</SelectedServerProviders>
|
||||
)
|
||||
}
|
||||
|
||||
function TargetServerScopedProviders(props: ServerScopedShellProps) {
|
||||
return (
|
||||
<PermissionProvider directory={props.directory}>
|
||||
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
|
||||
<ModelsProvider>{props.children}</ModelsProvider>
|
||||
</NotificationProvider>
|
||||
</PermissionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -520,11 +522,9 @@ export function AppInterface(props: {
|
||||
router?: Component<BaseRouterProps>
|
||||
disableHealthCheck?: boolean
|
||||
}) {
|
||||
// The shared shell holds only server-agnostic providers (QueryClient + Settings/
|
||||
// Command/Highlights) and stays mounted across every route. The server-scoped
|
||||
// providers and the visual Layout live in the per-route layouts below, so they
|
||||
// resolve to that route's server (selected for most routes, the draft's server for
|
||||
// /new-session). appChildren is server-agnostic, so it renders here once.
|
||||
// The visual new layout lives in the router root so it remains mounted across
|
||||
// route changes. Draft and session routes override only their server-bound data
|
||||
// providers beneath it.
|
||||
const ServerShell = (shellProps: ParentProps) => (
|
||||
<QueryProvider>
|
||||
<SharedProviders>
|
||||
@@ -548,7 +548,11 @@ export function AppInterface(props: {
|
||||
component={props.router ?? Router}
|
||||
root={(routerProps) => (
|
||||
<TabsProvider>
|
||||
<ServerShell>{routerProps.children}</ServerShell>
|
||||
<ServerShell>
|
||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||
<NewAppLayout>{routerProps.children}</NewAppLayout>
|
||||
</Show>
|
||||
</ServerShell>
|
||||
</TabsProvider>
|
||||
)}
|
||||
>
|
||||
@@ -574,28 +578,20 @@ function Routes() {
|
||||
<Route path="/session/:id?" component={SessionRoute} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route component={TargetServerLayout}>
|
||||
<Show when={settings.general.newLayoutDesigns()}>
|
||||
{
|
||||
<>
|
||||
<Route path="/" component={NewHome} />
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route
|
||||
path="/session/:id"
|
||||
component={() => {
|
||||
const server = useServer()
|
||||
const { id } = useParams()
|
||||
<Show when={settings.general.newLayoutDesigns()}>
|
||||
<Route path="/" component={NewHome} />
|
||||
<Route
|
||||
path="/:dir/session/:id"
|
||||
component={() => {
|
||||
const server = useServer()
|
||||
const { id } = useParams()
|
||||
|
||||
return <Navigate href={`/server/${server.key}/session/${id}`} />
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
</>
|
||||
}
|
||||
</Show>
|
||||
<Route path="/new-session" component={DraftRoute} />
|
||||
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
||||
</Route>
|
||||
return <Navigate href={`/server/${server.key}/session/${id}`} />
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<Route path="/new-session" component={DraftRoute} />
|
||||
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -363,7 +363,7 @@ export function DebugBar() {
|
||||
return (
|
||||
<aside
|
||||
aria-label={language.t("debugBar.ariaLabel")}
|
||||
class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 text-text-strong shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
|
||||
class="pointer-events-auto fixed bottom-3 right-3 z-50 hidden w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 text-text-strong shadow-[var(--shadow-lg-border-base)] md:block sm:bottom-4 sm:right-4 sm:w-[324px]"
|
||||
>
|
||||
<div class="grid grid-cols-5 gap-px font-mono">
|
||||
<Cell
|
||||
|
||||
@@ -20,7 +20,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
||||
const dialog = useDialog()
|
||||
const global = useGlobal()
|
||||
const language = useLanguage()
|
||||
const serverCtx = createMemo(() => global.createServerCtx(props.server))
|
||||
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
|
||||
const serverSDK = () => serverCtx().sdk
|
||||
const serverSync = () => serverCtx().sync
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
pickerMode,
|
||||
preloadTreeDirectories,
|
||||
cleanPickerInput,
|
||||
createPriorityTaskQueue,
|
||||
createDirectorySearch,
|
||||
currentPickerSuggestions,
|
||||
displayPickerPath,
|
||||
@@ -38,7 +39,7 @@ interface DialogSelectDirectoryV2Props {
|
||||
|
||||
export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
const global = useGlobal()
|
||||
const { sync, sdk } = global.createServerCtx(props.server)
|
||||
const { sync, sdk } = global.ensureServerCtx(props.server)
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const policy = pickerMode(props.mode ?? "directory", props.start)
|
||||
@@ -55,6 +56,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
const [error, setError] = createSignal(false)
|
||||
const [rootValid, setRootValid] = createSignal(false)
|
||||
const listings = new Map<string, Promise<Array<{ name: string; type: "file" | "directory" }> | undefined>>()
|
||||
const loads = createPriorityTaskQueue<Array<{ name: string; type: "file" | "directory" }> | undefined>(3)
|
||||
const advanced = new Set<string>()
|
||||
let tree: FileTree | undefined
|
||||
let container: HTMLDivElement | undefined
|
||||
@@ -102,16 +104,21 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
})
|
||||
const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions(), input()))
|
||||
|
||||
async function load(path: string, generation: number, preload = true) {
|
||||
async function load(path: string, generation: number, eager = false) {
|
||||
const key = path.replace(/\/+$/, "")
|
||||
setError(false)
|
||||
const absolute = absoluteTreePath(root(), key)
|
||||
const existing = listings.get(key)
|
||||
if (existing && !eager) loads.promote(`${generation}:${key}`)
|
||||
const request =
|
||||
listings.get(key) ??
|
||||
sdk.client.file
|
||||
.list({ directory: absolute, path: "" })
|
||||
.then((result) => result.data ?? [])
|
||||
.catch(() => undefined)
|
||||
existing ??
|
||||
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
||||
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
||||
return sdk.client.file
|
||||
.list({ directory: absolute, path: "" })
|
||||
.then((result) => result.data ?? [])
|
||||
.catch(() => undefined)
|
||||
})
|
||||
listings.set(key, request)
|
||||
const nodes = await request
|
||||
if (!activeTreeNavigation(generation, navigation)) return false
|
||||
@@ -121,8 +128,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
return false
|
||||
}
|
||||
tree?.batch(policy.entries(key, nodes).map((item) => ({ type: "add", path: item })))
|
||||
if (preload && advanceTreePreload(advanced, key)) {
|
||||
void Promise.all(preloadTreeDirectories(key, nodes).map((directory) => load(directory, generation, false)))
|
||||
if (!eager && advanceTreePreload(advanced, key)) {
|
||||
for (const directory of preloadTreeDirectories(key, nodes)) void load(directory, generation, true)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ function uniqueRows(rows: Row[]) {
|
||||
|
||||
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
const global = useGlobal()
|
||||
const { sync, sdk, ...serverCtx } = global.createServerCtx(props.server)
|
||||
const { sync, sdk, ...serverCtx } = global.ensureServerCtx(props.server)
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
treePathWithin,
|
||||
currentPickerSuggestions,
|
||||
createDirectorySearch,
|
||||
createPriorityTaskQueue,
|
||||
displayPickerPath,
|
||||
pickerParent,
|
||||
pickerRoot,
|
||||
@@ -168,6 +169,41 @@ test("advances preloading once for every expanded directory", () => {
|
||||
expect(advanceTreePreload(advanced, "repos/")).toBeTrue()
|
||||
})
|
||||
|
||||
test("limits background tasks and prioritizes newly requested work", async () => {
|
||||
const queue = createPriorityTaskQueue<void>(2)
|
||||
const first = Promise.withResolvers<void>()
|
||||
const second = Promise.withResolvers<void>()
|
||||
const started: string[] = []
|
||||
let active = 0
|
||||
let maximum = 0
|
||||
const task = (name: string, blocker?: Promise<void>) => async () => {
|
||||
started.push(name)
|
||||
active++
|
||||
maximum = Math.max(maximum, active)
|
||||
await blocker
|
||||
active--
|
||||
}
|
||||
|
||||
const running = [
|
||||
queue.schedule("first", "background", task("first", first.promise)),
|
||||
queue.schedule("second", "background", task("second", second.promise)),
|
||||
queue.schedule("preload", "background", task("preload")),
|
||||
queue.schedule("opened", "user", task("opened")),
|
||||
]
|
||||
await Promise.resolve()
|
||||
expect(started).toEqual(["first", "second"])
|
||||
|
||||
first.resolve()
|
||||
await running[0]
|
||||
await Promise.resolve()
|
||||
expect(started).toEqual(["first", "second", "opened"])
|
||||
|
||||
second.resolve()
|
||||
await Promise.all(running)
|
||||
expect(started).toEqual(["first", "second", "opened", "preload"])
|
||||
expect(maximum).toBe(2)
|
||||
})
|
||||
|
||||
test("clamps bridged tree wheel scrolling", () => {
|
||||
expect(nextTreeScrollTop(100, 40, 500, 200)).toBe(140)
|
||||
expect(nextTreeScrollTop(10, -40, 500, 200)).toBe(0)
|
||||
|
||||
@@ -138,6 +138,79 @@ export function activeTreeNavigation(request: number, current: number) {
|
||||
return request === current
|
||||
}
|
||||
|
||||
export function createPriorityTaskQueue<T>(concurrency: number) {
|
||||
type Job = {
|
||||
key: string
|
||||
priority: "user" | "background"
|
||||
promise: Promise<T>
|
||||
run: () => void
|
||||
}
|
||||
|
||||
const jobs = new Map<string, Job>()
|
||||
const user: Job[] = []
|
||||
const background: Job[] = []
|
||||
let active = 0
|
||||
|
||||
const drain = () => {
|
||||
while (active < concurrency) {
|
||||
const job = user.pop() ?? background.shift()
|
||||
if (!job) return
|
||||
active++
|
||||
job.run()
|
||||
}
|
||||
}
|
||||
|
||||
const schedule = (key: string, priority: Job["priority"], task: () => Promise<T>) => {
|
||||
const existing = jobs.get(key)
|
||||
if (existing) {
|
||||
if (priority === "user") promote(key)
|
||||
return existing.promise
|
||||
}
|
||||
|
||||
const deferred = Promise.withResolvers<T>()
|
||||
const job: Job = {
|
||||
key,
|
||||
priority,
|
||||
promise: deferred.promise,
|
||||
run: () => {
|
||||
const complete = () => {
|
||||
active--
|
||||
jobs.delete(key)
|
||||
drain()
|
||||
}
|
||||
Promise.resolve()
|
||||
.then(task)
|
||||
.then(
|
||||
(value) => {
|
||||
complete()
|
||||
deferred.resolve(value)
|
||||
},
|
||||
(error) => {
|
||||
complete()
|
||||
deferred.reject(error)
|
||||
},
|
||||
)
|
||||
},
|
||||
}
|
||||
jobs.set(key, job)
|
||||
;(priority === "user" ? user : background).push(job)
|
||||
drain()
|
||||
return job.promise
|
||||
}
|
||||
|
||||
const promote = (key: string) => {
|
||||
const job = jobs.get(key)
|
||||
if (!job || job.priority === "user") return
|
||||
const index = background.indexOf(job)
|
||||
if (index === -1) return
|
||||
background.splice(index, 1)
|
||||
job.priority = "user"
|
||||
user.push(job)
|
||||
}
|
||||
|
||||
return { schedule, promote }
|
||||
}
|
||||
|
||||
export function nextTreeScrollTop(current: number, delta: number, scrollHeight: number, clientHeight: number) {
|
||||
return Math.min(Math.max(0, scrollHeight - clientHeight), Math.max(0, current + delta))
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export function HelpButton() {
|
||||
|
||||
return (
|
||||
<Show when={!state.dismissed}>
|
||||
<div class="fixed bottom-4 right-4 z-50">
|
||||
<div class="fixed bottom-4 right-4 z-50 hidden md:block">
|
||||
<Popover
|
||||
open={shown()}
|
||||
onOpenChange={setShown}
|
||||
|
||||
@@ -1379,7 +1379,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
|
||||
|
||||
const [promptReady] = createResource(
|
||||
() => prompt.ready().promise,
|
||||
() => prompt.ready.promise,
|
||||
(p) => p,
|
||||
)
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ let variant: string | undefined
|
||||
|
||||
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
const prompt = {
|
||||
ready: () => Object.assign(() => true, { promise: Promise.resolve(true) }),
|
||||
ready: Object.assign(() => true, { promise: Promise.resolve(true) }),
|
||||
current: () => promptValue,
|
||||
cursor: () => 0,
|
||||
dirty: () => true,
|
||||
|
||||
@@ -26,7 +26,7 @@ export function SettingsServerScope(props: ParentProps) {
|
||||
|
||||
function SettingsServerDataProviders(props: ParentProps<{ server: ServerConnection.Any }>) {
|
||||
const global = useGlobal()
|
||||
const serverCtx = () => global.createServerCtx(props.server)
|
||||
const serverCtx = () => global.ensureServerCtx(props.server)
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={serverCtx().queryClient}>
|
||||
|
||||
@@ -167,6 +167,15 @@
|
||||
background-color: var(--v2-background-bg-layer-01);
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.settings-v2[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
|
||||
[data-slot="tabs-v2-list"] {
|
||||
width: 144px;
|
||||
min-width: 144px;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-v2-nav-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createRoot,
|
||||
createSignal,
|
||||
For,
|
||||
Match,
|
||||
@@ -22,17 +23,15 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
|
||||
import { getProjectAvatarVariant, LayoutRoute, useLayout, type LocalProject } from "@/context/layout"
|
||||
import { LayoutRoute, useLayout } from "@/context/layout"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { WindowsAppMenu } from "./windows-app-menu"
|
||||
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
|
||||
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
|
||||
import { projectForSession } from "@/pages/layout/helpers"
|
||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
@@ -41,8 +40,9 @@ import { useGlobal } from "@/context/global"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { tabHref, useTabs } from "@/context/tabs"
|
||||
import "./titlebar.css"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { Session } from "@opencode-ai/sdk/v2"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { createTabPromptState } from "@/context/prompt"
|
||||
|
||||
type TauriDesktopWindow = {
|
||||
startDragging?: () => Promise<void>
|
||||
@@ -101,10 +101,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
|
||||
const minHeight = () => {
|
||||
const height = useV2Titlebar() ? v2TitlebarHeight : legacyTitlebarHeight
|
||||
if (useV2Titlebar() && mobile()) {
|
||||
const inset = bottom() ? "env(safe-area-inset-bottom, 0px)" : "env(safe-area-inset-top, 0px)"
|
||||
return `calc(${height}px + ${inset})`
|
||||
}
|
||||
if (mac()) return `${height / zoom()}px`
|
||||
if (windows()) return `${height / Math.min(titlebarZoom(), 1)}px`
|
||||
return undefined
|
||||
@@ -246,8 +242,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
}}
|
||||
style={{
|
||||
"min-height": minHeight(),
|
||||
"padding-top": useV2Titlebar() && mobile() && !bottom() ? "env(safe-area-inset-top, 0px)" : undefined,
|
||||
"padding-bottom": bottom() ? "env(safe-area-inset-bottom, 0px)" : undefined,
|
||||
"padding-left": mac() && !mobile() ? `${84 / zoom()}px` : 0,
|
||||
width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
|
||||
"max-width": electronWindows()
|
||||
@@ -262,18 +256,9 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
<Switch>
|
||||
<Match when={useV2Titlebar()}>
|
||||
{(_) => {
|
||||
const serverSdk = useServerSDK()
|
||||
const navigate = useNavigate()
|
||||
const layout = useLayout()
|
||||
|
||||
const newSessionHref = () => {
|
||||
if (params.dir) return `/${params.dir}/session`
|
||||
|
||||
const project = layout.projects.list()[0]
|
||||
if (!project) return "/"
|
||||
|
||||
return `/${base64Encode(project.worktree)}/session`
|
||||
}
|
||||
const global = useGlobal()
|
||||
|
||||
const tabs = useTabs()
|
||||
const tabsStore = tabs.store
|
||||
@@ -281,11 +266,15 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
const [session] = createResource(
|
||||
() => {
|
||||
const route = layout.route()
|
||||
return route.type === "session" ? route : undefined
|
||||
if (route.type !== "session") return undefined
|
||||
const conn = global.servers
|
||||
.list()
|
||||
.find((item) => ServerConnection.key(item) === (route.server ?? server.key))
|
||||
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
|
||||
},
|
||||
(route) =>
|
||||
serverSdk()
|
||||
.client.session.get({ sessionID: route.sessionId })
|
||||
({ route, sdk }) =>
|
||||
sdk.client.session
|
||||
.get({ sessionID: route.sessionId })
|
||||
.then((x) => x.data)
|
||||
.catch(() => {}),
|
||||
)
|
||||
@@ -338,7 +327,28 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
tabsStoreActions.removeSessions(detail)
|
||||
})
|
||||
|
||||
const openNewTab = () => navigate(newSessionHref())
|
||||
const openNewTab = () => {
|
||||
const route = layout.route()
|
||||
const activeSession = session()
|
||||
if (route.type === "session" && activeSession) {
|
||||
tabs.newDraft({ server: route.server ?? server.key, directory: activeSession.directory }, "")
|
||||
return
|
||||
}
|
||||
|
||||
const current = layout.projects.list()[0]
|
||||
if (current) {
|
||||
tabs.newDraft({ server: server.key, directory: current.worktree }, "")
|
||||
return
|
||||
}
|
||||
|
||||
const fallback = global.servers.list().flatMap((conn) => {
|
||||
const project = global.ensureServerCtx(conn).projects.list()[0]
|
||||
return project ? [{ server: ServerConnection.key(conn), project }] : []
|
||||
})[0]
|
||||
if (!fallback) return
|
||||
|
||||
tabs.newDraft({ server: fallback.server, directory: fallback.project.worktree }, "")
|
||||
}
|
||||
const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() })
|
||||
|
||||
command.register("titlebar-home", () => [
|
||||
@@ -408,22 +418,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
if (next) tabs.select(next)
|
||||
},
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => {
|
||||
const index = i
|
||||
const number = index + 1
|
||||
return {
|
||||
id: `tab.${number}`,
|
||||
category: "tab",
|
||||
title: "",
|
||||
keybind: `mod+${number}`,
|
||||
disabled: layout.projects.list().length <= index,
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
const tab = tabsStore[index]
|
||||
if (tab) tabs.select(tab)
|
||||
},
|
||||
}
|
||||
}),
|
||||
].filter((v) => v !== undefined)
|
||||
})
|
||||
|
||||
@@ -487,6 +481,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
<For each={tabsStore}>
|
||||
{(tab, i) => {
|
||||
let ref!: HTMLDivElement
|
||||
useTabShortcut(i, () => tabs.select(tab))
|
||||
|
||||
const divider = () =>
|
||||
i() !== 0 && (
|
||||
@@ -512,14 +507,76 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
)
|
||||
}
|
||||
|
||||
const [session] = createResource(
|
||||
() => tab.sessionId,
|
||||
(sessionID) =>
|
||||
serverSdk()
|
||||
.client.session.get({ sessionID })
|
||||
.then((x) => x.data)
|
||||
const serverCtx = createMemo(() => {
|
||||
const conn = server.list.find((item) => ServerConnection.key(item) === tab.server)
|
||||
return conn ? global.ensureServerCtx(conn) : undefined
|
||||
})
|
||||
const sdk = createMemo(() => serverCtx()?.sdk ?? null)
|
||||
const cachedSession = createMemo(() => {
|
||||
const placement = global.sessionPlacement.get(tab.server, tab.sessionId)
|
||||
const ctx = serverCtx()
|
||||
if (!placement || !ctx) return
|
||||
return ctx.sync
|
||||
.child(placement.directory, { bootstrap: false })[0]
|
||||
.session.find((session) => session.id === tab.sessionId)
|
||||
})
|
||||
|
||||
const [loadedSession] = createResource(
|
||||
() => {
|
||||
if (cachedSession()) return null
|
||||
const id = tab.sessionId
|
||||
const ctx = serverCtx()
|
||||
return ctx ? { id, ctx } : null
|
||||
},
|
||||
({ id, ctx }) =>
|
||||
ctx.sdk.client.session
|
||||
.get({ sessionID: id })
|
||||
.then((x) => {
|
||||
const session = x.data
|
||||
if (!session) return
|
||||
if (!session.parentID)
|
||||
global.sessionPlacement.set({
|
||||
server: tab.server,
|
||||
leafID: session.id,
|
||||
rootID: session.id,
|
||||
directory: session.directory,
|
||||
})
|
||||
return session
|
||||
})
|
||||
.catch(() => undefined),
|
||||
)
|
||||
const session = createMemo(() => cachedSession() ?? loadedSession())
|
||||
let prefetched = false
|
||||
|
||||
createEffect(() => {
|
||||
const ctx = serverCtx()
|
||||
const sess = session()
|
||||
if (!ctx || !sess || prefetched) return
|
||||
prefetched = true
|
||||
createRoot((dispose) => {
|
||||
try {
|
||||
void ctx.sync
|
||||
.ensureDirSyncContext(sess.directory)
|
||||
.session.sync(sess.id)
|
||||
.catch(() => {})
|
||||
.finally(dispose)
|
||||
} catch {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (tab.type !== "session") return
|
||||
const _sdk = sdk()
|
||||
if (!_sdk) return
|
||||
const sess = session()
|
||||
if (!sess) return
|
||||
createTabPromptState(tabs, tab, _sdk.scope, {
|
||||
dir: base64Encode(sess.directory),
|
||||
id: sess.id,
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -593,8 +650,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
size="large"
|
||||
class="shrink-0"
|
||||
icon={<IconV2 name="plus" />}
|
||||
as="a"
|
||||
href={newSessionHref()}
|
||||
onClick={openNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
/>
|
||||
</Show>
|
||||
@@ -819,6 +875,26 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
|
||||
)
|
||||
}
|
||||
|
||||
function useTabShortcut(index: () => number, onSelect: () => void) {
|
||||
const command = useCommand()
|
||||
|
||||
command.register(() => {
|
||||
const number = index() + 1
|
||||
if (number > 9) return []
|
||||
|
||||
return [
|
||||
{
|
||||
id: `tab.${number}`,
|
||||
category: "tab",
|
||||
title: "",
|
||||
keybind: `mod+${number}`,
|
||||
hidden: true,
|
||||
onSelect,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function TabNavItem(props: {
|
||||
ref?: HTMLDivElement
|
||||
href: string
|
||||
@@ -841,13 +917,13 @@ function TabNavItem(props: {
|
||||
const global = useGlobal()
|
||||
const serverCtx = createMemo(() => {
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
|
||||
if (conn) return global.createServerCtx(conn)
|
||||
if (conn) return global.ensureServerCtx(conn)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={props.ref}
|
||||
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)]"
|
||||
class="group relative flex h-7 w-56 shrink-0 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={props.active}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
@@ -868,7 +944,7 @@ function TabNavItem(props: {
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base"
|
||||
>
|
||||
<span data-slot="project-avatar-slot">
|
||||
<ProjectTabAvatar
|
||||
<SessionTabAvatar
|
||||
project={project()}
|
||||
directory={session().directory}
|
||||
sessionId={session().id}
|
||||
@@ -904,26 +980,6 @@ function TabNavItem(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectTabAvatar(props: {
|
||||
project?: LocalProject
|
||||
directory: string
|
||||
sessionId: string
|
||||
activeServer: boolean
|
||||
}) {
|
||||
const directory = () => props.directory
|
||||
const sessionId = () => props.sessionId
|
||||
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
|
||||
return (
|
||||
<ProjectAvatar
|
||||
fallback={displayName(props.project ?? { worktree: props.directory })}
|
||||
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
|
||||
variant={getProjectAvatarVariant(props.project?.icon?.color)}
|
||||
unread={state.unread()}
|
||||
loading={state.loading()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DraftTabItem(props: {
|
||||
ref?: HTMLDivElement
|
||||
href: string
|
||||
@@ -941,7 +997,7 @@ function DraftTabItem(props: {
|
||||
<div
|
||||
ref={props.ref}
|
||||
data-active={props.active}
|
||||
class="group relative shrink-0 flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
|
||||
class="group relative flex h-7 w-56 shrink-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
closeTab(event)
|
||||
@@ -986,7 +1042,7 @@ function NewSessionTabItem(props: { ref?: HTMLDivElement; href: string; title: s
|
||||
return (
|
||||
<div
|
||||
ref={props.ref}
|
||||
class="group relative shrink-0 flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
|
||||
class="group relative flex h-7 w-56 shrink-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
closeTab(event)
|
||||
|
||||
@@ -187,7 +187,7 @@ export const createDirSyncContext = (
|
||||
return serverSync.child(directory)
|
||||
}
|
||||
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
||||
const initialMessagePageSize = 80
|
||||
const initialMessagePageSize = 2
|
||||
const historyMessagePageSize = 200
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
const inflightDiff = new Map<string, Promise<void>>()
|
||||
|
||||
@@ -8,11 +8,13 @@ import { createServerSyncContext } from "./server-sync"
|
||||
import { getOwner } from "solid-js/web"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { createSessionPlacementStore } from "@/utils/session-placement"
|
||||
|
||||
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
|
||||
name: "Global",
|
||||
init: () => {
|
||||
const server = useServer()
|
||||
const sessionPlacement = createSessionPlacementStore()
|
||||
const serverHealth = useServerHealth(
|
||||
() => server.list,
|
||||
() => true,
|
||||
@@ -85,7 +87,8 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
|
||||
},
|
||||
},
|
||||
},
|
||||
createServerCtx(conn: ServerConnection.Any) {
|
||||
sessionPlacement,
|
||||
ensureServerCtx(conn: ServerConnection.Any) {
|
||||
return ensureServerCtx(conn)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useServerSync } from "./server-sync"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
||||
@@ -208,12 +207,12 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
|
||||
const lookup = async (directory: string, sessionID?: string) => {
|
||||
if (!sessionID) return undefined
|
||||
const [syncStore] = serverSync().child(directory, { bootstrap: false })
|
||||
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
|
||||
if (match.found) return syncStore.session[match.index]
|
||||
return serverSDK()
|
||||
.client.session.get({ directory, sessionID })
|
||||
.then((x) => x.data)
|
||||
const sync = serverSync().ensureDirSyncContext(directory)
|
||||
const session = sync.session.get(sessionID)
|
||||
if (session) return session
|
||||
return sync.session
|
||||
.sync(sessionID)
|
||||
.then(() => sync.session.get(sessionID))
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
|
||||
import { useParams, useSearchParams } from "@solidjs/router"
|
||||
import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { batch, createMemo, createRoot, getOwner, onCleanup, type Accessor } from "solid-js"
|
||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useTabs, type Tab } from "./tabs"
|
||||
import { useServer } from "./server"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
import { useSettings } from "./settings"
|
||||
|
||||
interface PartBase {
|
||||
content: string
|
||||
@@ -181,7 +185,7 @@ function promptTarget(serverScope: ServerScope, scope: Scope) {
|
||||
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
|
||||
}
|
||||
|
||||
function createPromptSession(serverScope: ServerScope, scope: Scope) {
|
||||
export function createPromptSession(serverScope: ServerScope, scope: Scope) {
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
promptTarget(serverScope, scope),
|
||||
createStore<PromptStore>(promptStore()),
|
||||
@@ -190,6 +194,12 @@ function createPromptSession(serverScope: ServerScope, scope: Scope) {
|
||||
return { ready, ...createPromptStateValue(store, setStore) }
|
||||
}
|
||||
|
||||
export function createPromptReady(session: Accessor<PromptSession>) {
|
||||
return Object.defineProperty(() => session().ready(), "promise", {
|
||||
get: () => session().ready.promise,
|
||||
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
|
||||
}
|
||||
|
||||
function promptStore(): PromptStore {
|
||||
return {
|
||||
prompt: clonePrompt(DEFAULT_PROMPT),
|
||||
@@ -247,19 +257,28 @@ export function createPromptState() {
|
||||
const [store, setStore] = createStore<PromptStore>(promptStore())
|
||||
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
|
||||
return {
|
||||
ready: () => ready,
|
||||
ready,
|
||||
...createPromptStateValue(store, setStore),
|
||||
}
|
||||
}
|
||||
|
||||
export const createTabPromptState = (
|
||||
tabs: ReturnType<typeof useTabs>,
|
||||
tab: Tab,
|
||||
...args: Parameters<typeof createPromptSession>
|
||||
) => tabs.state(tab, "prompt", () => createPromptSession(...args))
|
||||
|
||||
export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
|
||||
name: "Prompt",
|
||||
gate: false,
|
||||
init: () => {
|
||||
const params = useParams()
|
||||
const params = useParams<{ serverKey?: string; id?: string }>()
|
||||
const sdk = useSDK()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const serverSDK = useServerSDK()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const settings = useSettings()
|
||||
const cache = new Map<string, PromptCacheEntry>()
|
||||
|
||||
const disposeAll = () => {
|
||||
@@ -282,7 +301,25 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
}
|
||||
|
||||
const owner = getOwner()
|
||||
const tab = createMemo<Tab | undefined>(() => {
|
||||
if (!settings.general.newLayoutDesigns()) return
|
||||
if (search.draftId) {
|
||||
return tabs.store.find((item) => item.type === "draft" && item.draftID === search.draftId)
|
||||
}
|
||||
if (!params.id) return
|
||||
const serverKey = params.serverKey ? requireServerKey(params.serverKey) : server.key
|
||||
return (
|
||||
tabs.store.find(
|
||||
(item) => item.type === "session" && item.server === serverKey && item.sessionId === params.id,
|
||||
) ?? { type: "session", server: serverKey, sessionId: params.id }
|
||||
)
|
||||
})
|
||||
const load = (scope: Scope) => {
|
||||
const current = tab()
|
||||
if (current) {
|
||||
return createTabPromptState(tabs, current, serverSDK().scope, scope)
|
||||
}
|
||||
|
||||
const key = scopeKey(scope)
|
||||
const existing = cache.get(key)
|
||||
if (existing) {
|
||||
@@ -308,9 +345,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
load(search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }),
|
||||
)
|
||||
const pick = (scope?: Scope) => (scope ? load(scope) : session())
|
||||
const ready = createPromptReady(session)
|
||||
|
||||
return {
|
||||
ready: () => session().ready,
|
||||
ready,
|
||||
current: () => session().current(),
|
||||
cursor: () => session().cursor(),
|
||||
dirty: () => session().dirty(),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { type Accessor, createMemo } from "solid-js"
|
||||
import { type ServerSDK, useServerSDK } from "./server-sdk"
|
||||
|
||||
export type DirectorySDK = ReturnType<ServerSDK["createDirSdkContext"]>
|
||||
export type DirectorySDK = ReturnType<ServerSDK["ensureDirSdkContext"]>
|
||||
|
||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
name: "SDK",
|
||||
@@ -11,7 +11,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
const serverSDK = useServerSDK()
|
||||
return createMemo(() => {
|
||||
const directory = typeof props.directory === "function" ? props.directory() : props.directory
|
||||
return serverSDK().createDirSdkContext(directory)
|
||||
return serverSDK().ensureDirSdkContext(directory)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -289,13 +289,13 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
|
||||
type ServerSDKBase = ReturnType<typeof createServerSdkContextBase>
|
||||
export type ServerSDK = ServerSDKBase & {
|
||||
createDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
|
||||
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
|
||||
}
|
||||
|
||||
export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope): ServerSDK {
|
||||
const sdk = createServerSdkContextBase(server, scope)
|
||||
return Object.assign(sdk, {
|
||||
createDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)),
|
||||
ensureDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -311,7 +311,7 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
|
||||
return createMemo<ServerSDK>(() => {
|
||||
const conn = props.server?.() ?? server.current
|
||||
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
|
||||
return global.createServerCtx(conn).sdk
|
||||
return global.ensureServerCtx(conn).sdk
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -509,7 +509,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
export function createServerSyncContext(serverSDK: ServerSDK) {
|
||||
const inner = createServerSyncContextInner(serverSDK)
|
||||
return Object.assign(inner, {
|
||||
createDirSyncContext: createRefCountMap(
|
||||
ensureDirSyncContext: createRefCountMap(
|
||||
(dir) => createDirSyncContext(dir, inner, serverSDK),
|
||||
(dir) => inner.disableMcp(dir),
|
||||
directoryKey,
|
||||
@@ -531,7 +531,7 @@ export const { use: useServerSync, provider: ServerSyncProvider } = createSimple
|
||||
return createMemo<ServerSync>(() => {
|
||||
const conn = props.server?.() ?? server.current
|
||||
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
|
||||
return global.createServerCtx(conn).sync
|
||||
return global.ensureServerCtx(conn).sync
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -113,7 +113,7 @@ export const useSync = () => {
|
||||
const serverSync = useServerSync()
|
||||
const sdk = useSDK()
|
||||
|
||||
return createMemo(() => serverSync().createDirSyncContext(sdk().directory))
|
||||
return createMemo(() => serverSync().ensureDirSyncContext(sdk().directory))
|
||||
}
|
||||
|
||||
export type DirectorySync = ReturnType<ReturnType<typeof useSync>>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createRoot, type Owner } from "solid-js"
|
||||
|
||||
type Entry = {
|
||||
value: unknown
|
||||
dispose: VoidFunction
|
||||
}
|
||||
|
||||
export function createTabMemory(owner: Owner | null) {
|
||||
const entries = new Map<string, Map<string, Entry>>()
|
||||
|
||||
const remove = (key: string) => {
|
||||
const state = entries.get(key)
|
||||
if (!state) return
|
||||
for (const entry of state.values()) entry.dispose()
|
||||
entries.delete(key)
|
||||
}
|
||||
|
||||
return {
|
||||
ensure<T>(key: string, name: string, init: () => T) {
|
||||
const state = entries.get(key) ?? new Map<string, Entry>()
|
||||
if (!entries.has(key)) entries.set(key, state)
|
||||
const existing = state.get(name)
|
||||
if (existing) return existing.value as T
|
||||
const entry = createRoot((dispose) => ({ value: init(), dispose }), owner)
|
||||
state.set(name, entry)
|
||||
return entry.value
|
||||
},
|
||||
remove,
|
||||
dispose() {
|
||||
for (const key of entries.keys()) remove(key)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { createTabMemory } from "./tab-memory"
|
||||
|
||||
describe("tab memory", () => {
|
||||
test("keeps state until its tab is removed", () => {
|
||||
createRoot((dispose) => {
|
||||
const memory = createTabMemory(getOwner())
|
||||
let disposed = 0
|
||||
const first = memory.ensure("tab", "prompt", () => {
|
||||
onCleanup(() => disposed++)
|
||||
return { value: "prompt" }
|
||||
})
|
||||
|
||||
expect(memory.ensure("tab", "prompt", () => ({ value: "other" }))).toBe(first)
|
||||
expect(memory.ensure("other", "prompt", () => ({ value: "other" }))).not.toBe(first)
|
||||
|
||||
memory.remove("tab")
|
||||
expect(disposed).toBe(1)
|
||||
expect(memory.ensure("tab", "prompt", () => ({ value: "new" }))).not.toBe(first)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,12 +3,13 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
|
||||
import { ServerConnection, useServer } from "./server"
|
||||
import { createEffect, startTransition } from "solid-js"
|
||||
import { createEffect, getOwner, onCleanup, startTransition } from "solid-js"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { usePlatform } from "./platform"
|
||||
import { uuid } from "@/utils/uuid"
|
||||
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
|
||||
import { sessionHref } from "@/utils/session-route"
|
||||
import { createTabMemory } from "./tab-memory"
|
||||
|
||||
export type SessionTab = {
|
||||
type: "session"
|
||||
@@ -66,6 +67,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const memory = createTabMemory(getOwner())
|
||||
|
||||
const closing = new Set<string>()
|
||||
let recentWrite = 0
|
||||
@@ -89,11 +91,18 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
|
||||
}
|
||||
|
||||
onCleanup(memory.dispose)
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !recentReady()) return
|
||||
const servers = new Set(server.list.map(ServerConnection.key))
|
||||
const next = store.filter((tab) => servers.has(tab.server))
|
||||
if (next.length !== store.length) setStore(() => next)
|
||||
if (next.length !== store.length) {
|
||||
for (const tab of store) {
|
||||
if (!servers.has(tab.server)) memory.remove(tabKey(tab))
|
||||
}
|
||||
setStore(() => next)
|
||||
}
|
||||
if (recent.key && !next.some((tab) => tabKey(tab) === recent.key)) setRecentKey(undefined)
|
||||
})
|
||||
|
||||
@@ -106,13 +115,15 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
const actions = {
|
||||
addSessionTab: (tab: Omit<SessionTab, "type">) => {
|
||||
const next = { type: "session" as const, ...tab }
|
||||
if (closing.has(tabKey(next))) return
|
||||
const existing = store.find((item) => tabKey(item) === tabKey(next))
|
||||
if (existing) return existing
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
if (tabs.some((item) => tabKey(item) === tabKey(next))) return
|
||||
tabs.push(next)
|
||||
}),
|
||||
)
|
||||
return next
|
||||
},
|
||||
draft(draftID: string) {
|
||||
const tab = store.find((item) => item.type === "draft" && item.draftID === draftID)
|
||||
@@ -151,6 +162,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
if (recent.key === `draft:${draftID}`) setRecentKey(tabKey(next))
|
||||
if (active) navigateTab(next)
|
||||
})
|
||||
memory.remove(`draft:${draftID}`)
|
||||
removeDraftPersisted(draftID)
|
||||
},
|
||||
removeTab: (index: number) => {
|
||||
@@ -170,12 +182,14 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
if (nextTab) navigateTab(nextTab)
|
||||
else navigate("/")
|
||||
}).finally(() => closing.delete(key))
|
||||
memory.remove(key)
|
||||
if (draftID) removeDraftPersisted(draftID)
|
||||
},
|
||||
removeServer(key: ServerConnection.Key) {
|
||||
const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : []))
|
||||
const removed = store.filter((tab) => tab.server === key).map(tabKey)
|
||||
setStore((tabs) => tabs.filter((tab) => tab.server !== key))
|
||||
for (const key of removed) memory.remove(key)
|
||||
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
|
||||
for (const draftID of drafts) removeDraftPersisted(draftID)
|
||||
if (server.key === key) navigate("/")
|
||||
@@ -227,6 +241,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
)
|
||||
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
|
||||
})
|
||||
for (const key of removed) memory.remove(key)
|
||||
},
|
||||
select: navigateTab,
|
||||
remember(tab: Tab) {
|
||||
@@ -246,6 +261,9 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
}
|
||||
navigate("/")
|
||||
},
|
||||
state<T>(tab: Tab, name: string, init: () => T) {
|
||||
return memory.ensure(tabKey(tab), name, init)
|
||||
},
|
||||
}
|
||||
|
||||
return { ...actions, store, ready, recentReady }
|
||||
|
||||
@@ -15,6 +15,13 @@
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@media (display-mode: standalone) {
|
||||
/* WebKit excludes safe-area insets from dvh in installed apps. */
|
||||
#root {
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
@keyframes session-progress-whip {
|
||||
0% {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { decode64 } from "@/utils/base64"
|
||||
import { Schema } from "effect"
|
||||
import type { ServerConnection } from "@/context/server"
|
||||
import { sessionHref } from "@/utils/session-route"
|
||||
import { useGlobal } from "@/context/global"
|
||||
|
||||
export function DirectoryDataProvider(
|
||||
props: ParentProps<{
|
||||
@@ -23,6 +24,7 @@ export function DirectoryDataProvider(
|
||||
const navigate = useNavigate()
|
||||
const params = useParams()
|
||||
const sync = useSync()
|
||||
const global = useGlobal()
|
||||
const directory = () => (typeof props.directory === "function" ? props.directory() : props.directory)
|
||||
const slug = createMemo(() => base64Encode(directory()))
|
||||
const href = (sessionID: string) => {
|
||||
@@ -52,7 +54,11 @@ export function DirectoryDataProvider(
|
||||
<DataProvider
|
||||
data={sync().data}
|
||||
directory={directory()}
|
||||
onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
|
||||
onNavigateToSession={(sessionID: string) => {
|
||||
const server = props.server?.()
|
||||
if (server && params.id) global.sessionPlacement.inherit(server, params.id, sessionID)
|
||||
navigate(href(sessionID))
|
||||
}}
|
||||
onSessionHref={href}
|
||||
>
|
||||
<LocalProvider>{props.children}</LocalProvider>
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { batch, createEffect, createMemo, For, Match, on, onCleanup, onMount, Show, Switch } from "solid-js"
|
||||
import {
|
||||
batch,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createRoot,
|
||||
For,
|
||||
Match,
|
||||
on,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
startTransition,
|
||||
Switch,
|
||||
} from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
@@ -32,13 +45,12 @@ import {
|
||||
displayName,
|
||||
getProjectAvatarSource,
|
||||
homeProjectDirectories,
|
||||
homeProjectNavigation,
|
||||
type HomeProjectSelection,
|
||||
projectForSession,
|
||||
sortedRootSessions,
|
||||
toggleHomeProjectSelection,
|
||||
} from "@/pages/layout/helpers"
|
||||
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
|
||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { useGlobal } from "@/context/global"
|
||||
@@ -47,6 +59,8 @@ import { ServerRowMenu } from "@/components/server/server-row-menu"
|
||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||
import { type ServerHealth } from "@/utils/server-health"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { useMarked } from "@opencode-ai/ui/context/marked"
|
||||
import { preloadMarkdown } from "@opencode-ai/ui/markdown-cache"
|
||||
|
||||
const HOME_SESSION_LIMIT = 64
|
||||
const HOME_ROW_LAYOUT =
|
||||
@@ -123,8 +137,10 @@ export function NewHome() {
|
||||
const server = useServer()
|
||||
const language = useLanguage()
|
||||
const global = useGlobal()
|
||||
const tabs = useTabs()
|
||||
const command = useCommand()
|
||||
const notification = useNotification()
|
||||
const marked = useMarked()
|
||||
let focusSessionSearch: (() => void) | undefined
|
||||
const [state, setState] = createStore({
|
||||
search: "",
|
||||
@@ -138,7 +154,7 @@ export function NewHome() {
|
||||
const focusedServerCtx = createMemo(() => {
|
||||
const conn = focusedServer()
|
||||
if (!conn) return
|
||||
return global.createServerCtx(conn)
|
||||
return global.ensureServerCtx(conn)
|
||||
})
|
||||
const focusedSync = () => focusedServerCtx()?.sync ?? sync()
|
||||
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
|
||||
@@ -187,6 +203,41 @@ export function NewHome() {
|
||||
})
|
||||
const searchOpen = createMemo(() => state.searchFocused && search().length > 0)
|
||||
const groups = createMemo(() => groupSessions(records(), language))
|
||||
const prefetched = new Set<string>()
|
||||
|
||||
createEffect(() => {
|
||||
const ctx = focusedServerCtx()
|
||||
if (!ctx) return
|
||||
records()
|
||||
.slice(0, 2)
|
||||
.forEach((record) => {
|
||||
const key = `${ServerConnection.key(focusedServer()!)}\0${record.session.id}`
|
||||
if (prefetched.has(key)) return
|
||||
prefetched.add(key)
|
||||
createRoot((dispose) => {
|
||||
try {
|
||||
const directory = ctx.sync.ensureDirSyncContext(record.session.directory)
|
||||
void directory.session
|
||||
.sync(record.session.id)
|
||||
.then(() => {
|
||||
const store = ctx.sync.child(record.session.directory)[0]
|
||||
return Promise.all(
|
||||
(store.message[record.session.id] ?? []).flatMap((message) =>
|
||||
(store.part[message.id] ?? []).flatMap((part) => {
|
||||
if (part.type !== "text" || !part.text) return []
|
||||
return preloadMarkdown(part.text, part.id, marked)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(dispose)
|
||||
} catch {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function setSelection(next: HomeProjectSelection) {
|
||||
batch(() => {
|
||||
@@ -237,7 +288,7 @@ export function NewHome() {
|
||||
const key = ServerConnection.key(conn)
|
||||
if (
|
||||
!global
|
||||
.createServerCtx(conn)
|
||||
.ensureServerCtx(conn)
|
||||
.projects.list()
|
||||
.some((project) => project.worktree === directory)
|
||||
)
|
||||
@@ -248,7 +299,7 @@ export function NewHome() {
|
||||
function addProjects(conn: ServerConnection.Any, directories: string[]) {
|
||||
const directory = directories[0]
|
||||
if (!directory) return
|
||||
const ctx = global.createServerCtx(conn)
|
||||
const ctx = global.ensureServerCtx(conn)
|
||||
directories.forEach(ctx.projects.open)
|
||||
ctx.projects.touch(directory)
|
||||
setSelection({ server: ServerConnection.key(conn), directory })
|
||||
@@ -261,21 +312,11 @@ export function NewHome() {
|
||||
openProjectNewSession(conn, project.worktree)
|
||||
}
|
||||
|
||||
function navigateOnServer(conn: ServerConnection.Any, href: string) {
|
||||
const next = homeProjectNavigation(server.key, ServerConnection.key(conn), href)
|
||||
if (!next.server) {
|
||||
navigate(next.href)
|
||||
return
|
||||
}
|
||||
pendingHomeNavigation = next
|
||||
server.setActive(next.server)
|
||||
}
|
||||
|
||||
function openProjectNewSession(conn: ServerConnection.Any, directory: string) {
|
||||
const ctx = global.createServerCtx(conn)
|
||||
const ctx = global.ensureServerCtx(conn)
|
||||
ctx.projects.open(directory)
|
||||
ctx.projects.touch(directory)
|
||||
navigateOnServer(conn, `/${base64Encode(directory)}/session`)
|
||||
tabs.newDraft({ server: ServerConnection.key(conn), directory })
|
||||
}
|
||||
|
||||
function editProject(conn: ServerConnection.Any, project: LocalProject) {
|
||||
@@ -301,10 +342,19 @@ export function NewHome() {
|
||||
const conn = focusedServer()
|
||||
if (!conn) return
|
||||
const directory = project?.worktree ?? session.directory
|
||||
const ctx = global.createServerCtx(conn)
|
||||
const ctx = global.ensureServerCtx(conn)
|
||||
global.sessionPlacement.set({
|
||||
server: ServerConnection.key(conn),
|
||||
leafID: session.id,
|
||||
rootID: session.id,
|
||||
directory: session.directory,
|
||||
})
|
||||
ctx.projects.open(directory)
|
||||
ctx.projects.touch(directory)
|
||||
navigateOnServer(conn, `/server/${base64Encode(ServerConnection.key(conn))}/session/${session.id}`)
|
||||
startTransition(() => {
|
||||
const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
|
||||
tabs.select(tab)
|
||||
})
|
||||
}
|
||||
|
||||
function chooseProject(conn: ServerConnection.Any) {
|
||||
@@ -312,8 +362,6 @@ export function NewHome() {
|
||||
addProjects(conn, homeProjectDirectories(result))
|
||||
}
|
||||
|
||||
const server = global.createServerCtx(conn)
|
||||
|
||||
pickDirectory({
|
||||
server: conn,
|
||||
title: language.t("command.project.open"),
|
||||
@@ -343,7 +391,7 @@ export function NewHome() {
|
||||
const next = closeHomeProject(
|
||||
state.selection,
|
||||
ServerConnection.key(conn),
|
||||
global.createServerCtx(conn).projects,
|
||||
global.ensureServerCtx(conn).projects,
|
||||
directory,
|
||||
)
|
||||
if (next) setSelection(next)
|
||||
@@ -480,7 +528,7 @@ function HomeProjectColumn(props: {
|
||||
{(item) => {
|
||||
const key = ServerConnection.key(item)
|
||||
const healthy = () => !!global.servers.health[key]?.healthy
|
||||
const serverCtx = global.createServerCtx(item)
|
||||
const serverCtx = global.ensureServerCtx(item)
|
||||
const collapsed = () => !!state.collapsed[key]
|
||||
return (
|
||||
<div class="flex max-h-[min(572px,calc(100vh_-_300px))] min-w-0 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
@@ -755,21 +803,6 @@ function HomeProjectAvatar(props: { project: LocalProject }) {
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionAvatar(props: { project: LocalProject; session: Session; activeServer: boolean }) {
|
||||
const directory = () => props.session.directory
|
||||
const sessionId = () => props.session.id
|
||||
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
|
||||
return (
|
||||
<ProjectAvatar
|
||||
fallback={displayName(props.project)}
|
||||
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
unread={state.unread()}
|
||||
loading={state.loading()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionLeading(props: {
|
||||
project: LocalProject
|
||||
session: Session
|
||||
@@ -787,7 +820,12 @@ function HomeSessionLeading(props: {
|
||||
style={{ right: "calc(100% + 12px)" }}
|
||||
/>
|
||||
</Show>
|
||||
<HomeSessionAvatar project={props.project} session={props.session} activeServer={props.activeServer} />
|
||||
<SessionTabAvatar
|
||||
project={props.project}
|
||||
directory={props.session.directory}
|
||||
sessionId={props.session.id}
|
||||
activeServer={props.activeServer}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1166,7 +1204,7 @@ export function LegacyHome() {
|
||||
})
|
||||
|
||||
function openProject(server: ServerConnection.Any, directory: string) {
|
||||
const serverCtx = global.createServerCtx(server)
|
||||
const serverCtx = global.ensureServerCtx(server)
|
||||
serverCtx.projects.open(directory)
|
||||
serverCtx.projects.touch(directory)
|
||||
navigate(`/${base64Encode(directory)}`)
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import { createEffect, type ParentProps } from "solid-js"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createEffect, Suspense, type ParentProps } from "solid-js"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { DebugBar } from "@/components/debug-bar"
|
||||
import { HelpButton } from "@/components/help-button"
|
||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { setNavigate } from "@/utils/notification-click"
|
||||
import { setV2Toast, ToastRegion } from "@/utils/toast"
|
||||
|
||||
export default function NewLayout(props: ParentProps) {
|
||||
const platform = usePlatform()
|
||||
const notification = useNotification()
|
||||
const navigate = useNavigate()
|
||||
const params = useParams<{ id?: string }>()
|
||||
setNavigate(navigate)
|
||||
|
||||
createEffect(() => setV2Toast(true))
|
||||
createEffect(() => {
|
||||
if (!notification.ready() || !params.id) return
|
||||
notification.session.markViewed(params.id)
|
||||
})
|
||||
|
||||
const update: TitlebarUpdate = {
|
||||
version: () => {
|
||||
@@ -25,10 +32,16 @@ export default function NewLayout(props: ParentProps) {
|
||||
}
|
||||
|
||||
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">
|
||||
<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"
|
||||
style={{
|
||||
"padding-top": "env(safe-area-inset-top, 0px)",
|
||||
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
|
||||
}}
|
||||
>
|
||||
<Titlebar update={update} />
|
||||
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
|
||||
{props.children}
|
||||
<Suspense>{props.children}</Suspense>
|
||||
</main>
|
||||
{import.meta.env.DEV && <DebugBar />}
|
||||
<HelpButton />
|
||||
|
||||
@@ -1299,15 +1299,15 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
}
|
||||
const openSession = async (target: { directory: string; id: string }) => {
|
||||
if (!canOpen(target.directory)) return false
|
||||
const [data] = serverSync().child(target.directory, { bootstrap: false })
|
||||
if (data.session.some((item) => item.id === target.id)) {
|
||||
const sync = serverSync().ensureDirSyncContext(target.directory)
|
||||
if (sync.session.get(target.id)) {
|
||||
setStore("lastProjectSession", root, { directory: target.directory, id: target.id, at: Date.now() })
|
||||
navigateWithSidebarReset(`/${base64Encode(target.directory)}/session/${target.id}`)
|
||||
return true
|
||||
}
|
||||
const resolved = await serverSDK()
|
||||
.client.session.get({ sessionID: target.id })
|
||||
.then((x) => x.data)
|
||||
const resolved = await sync.session
|
||||
.sync(target.id)
|
||||
.then(() => sync.session.get(target.id))
|
||||
.catch(() => undefined)
|
||||
if (!resolved?.directory) return false
|
||||
if (!canOpen(resolved.directory)) return false
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { LocalProject } from "@/context/layout"
|
||||
import { getProjectAvatarVariant } from "@/context/layout"
|
||||
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||
import { SessionProgressIndicatorV2 } from "@opencode-ai/ui/v2/session-progress-indicator-v2"
|
||||
import { Show } from "solid-js"
|
||||
|
||||
export function SessionTabAvatar(props: {
|
||||
project?: LocalProject
|
||||
directory: string
|
||||
sessionId: string
|
||||
activeServer: boolean
|
||||
}) {
|
||||
const directory = () => props.directory
|
||||
const sessionId = () => props.sessionId
|
||||
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
|
||||
return (
|
||||
<Show
|
||||
when={state.loading()}
|
||||
fallback={
|
||||
<ProjectAvatar
|
||||
fallback={displayName(props.project ?? { worktree: props.directory })}
|
||||
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
|
||||
variant={getProjectAvatarVariant(props.project?.icon?.color)}
|
||||
unread={state.unread()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SessionProgressIndicatorV2 class="shrink-0" />
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -287,7 +287,7 @@ export default function Page() {
|
||||
})
|
||||
}
|
||||
return key
|
||||
}, sessionKey())
|
||||
})
|
||||
|
||||
let reviewFrame: number | undefined
|
||||
let todoFrame: number | undefined
|
||||
@@ -693,6 +693,7 @@ export default function Page() {
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!sync().project) return
|
||||
const list = changesOptions()
|
||||
if (list.includes(store.changes)) return
|
||||
const next = list[0]
|
||||
@@ -1128,13 +1129,35 @@ export default function Page() {
|
||||
|
||||
let captureHistoryAnchor = () => {}
|
||||
let restoreHistoryAnchor = (_done: boolean) => {}
|
||||
const loadOlder = () =>
|
||||
timeline.history.loadOlder({ before: () => captureHistoryAnchor(), after: restoreHistoryAnchor })
|
||||
let historyRequest = false
|
||||
let historyContinuationFrame: number | undefined
|
||||
const loadOlder = async () => {
|
||||
if (historyRequest || historyLoading()) return
|
||||
historyRequest = true
|
||||
const before = timeline.messages().length
|
||||
try {
|
||||
await timeline.history.loadOlder({ before: () => captureHistoryAnchor(), after: restoreHistoryAnchor })
|
||||
} finally {
|
||||
historyRequest = false
|
||||
}
|
||||
if (timeline.messages().length <= before) return
|
||||
if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200 || !historyMore()) return
|
||||
if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame)
|
||||
historyContinuationFrame = requestAnimationFrame(() => {
|
||||
historyContinuationFrame = undefined
|
||||
onHistoryScroll()
|
||||
})
|
||||
}
|
||||
const onHistoryScroll = () => {
|
||||
if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200) return
|
||||
if (historyRequest || historyLoading() || !autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200)
|
||||
return
|
||||
void loadOlder()
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame)
|
||||
})
|
||||
|
||||
fill = () => {
|
||||
if (fillFrame !== undefined) return
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Show, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { Show, createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useNavigate, useSearchParams } from "@solidjs/router"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
@@ -25,11 +25,12 @@ import { pathKey } from "@/utils/path-key"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { type DraftTab, useTabs } from "@/context/tabs"
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
||||
import { useGlobal } from "@/context/global"
|
||||
|
||||
export function SessionComposerRegion(props: {
|
||||
state: SessionComposerState
|
||||
@@ -73,26 +74,52 @@ export function SessionComposerRegion(props: {
|
||||
const settings = useSettings()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const global = useGlobal()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const view = layout.view(route.sessionKey)
|
||||
|
||||
const draft = createMemo(() => {
|
||||
if (!search.draftId) return
|
||||
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
|
||||
})
|
||||
const projectServer = createMemo(() => {
|
||||
if (!search.draftId) return server.current
|
||||
const target = draft()?.server
|
||||
if (!target) return
|
||||
return server.list.find((conn) => ServerConnection.key(conn) === target)
|
||||
})
|
||||
const projectServerCtx = createMemo(() => {
|
||||
const conn = projectServer()
|
||||
if (conn) return global.ensureServerCtx(conn)
|
||||
})
|
||||
const projects = createMemo(() =>
|
||||
search.draftId ? (projectServerCtx()?.projects.list() ?? []) : layout.projects.list(),
|
||||
)
|
||||
|
||||
const agentsQuery = createQuery(() => queryOptions().agents(pathKey(sdk().directory)))
|
||||
const globalProvidersQuery = createQuery(() => queryOptions().providers(null))
|
||||
const providersQuery = createQuery(() => queryOptions().providers(pathKey(sdk().directory)))
|
||||
const selectProject = (worktree: string) => {
|
||||
layout.projects.open(worktree)
|
||||
server.projects.touch(worktree)
|
||||
const conn = projectServer()
|
||||
const target = projectServerCtx()
|
||||
if (search.draftId) {
|
||||
tabs.updateDraft(search.draftId, { server: server.key, directory: worktree })
|
||||
if (!conn || !target) return
|
||||
target.projects.open(worktree)
|
||||
target.projects.touch(worktree)
|
||||
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree })
|
||||
return
|
||||
}
|
||||
|
||||
layout.projects.open(worktree)
|
||||
server.projects.touch(worktree)
|
||||
navigate(`/${base64Encode(worktree)}/session`)
|
||||
}
|
||||
const addProject = (title: string) => {
|
||||
if (!server.current) return
|
||||
const conn = projectServer()
|
||||
if (!conn) return
|
||||
pickDirectory({
|
||||
server: server.current,
|
||||
server: conn,
|
||||
title,
|
||||
onSelect: (result) => {
|
||||
const directory = Array.isArray(result) ? result[0] : result
|
||||
@@ -115,7 +142,7 @@ export function SessionComposerRegion(props: {
|
||||
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
|
||||
},
|
||||
projects: {
|
||||
available: layout.projects.list(),
|
||||
available: projects(),
|
||||
directory: sdk().directory,
|
||||
select: selectProject,
|
||||
add: addProject,
|
||||
@@ -216,6 +243,12 @@ export function SessionComposerRegion(props: {
|
||||
update()
|
||||
})
|
||||
|
||||
const ready = Promise.resolve()
|
||||
const [promptReadyResource] = createResource(
|
||||
() => prompt.ready.promise ?? ready,
|
||||
(promise) => promise.then(() => true),
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={props.setPromptDockRef}
|
||||
@@ -258,7 +291,7 @@ export function SessionComposerRegion(props: {
|
||||
|
||||
<Show when={showComposer()}>
|
||||
<Show
|
||||
when={prompt.ready()}
|
||||
when={promptReadyResource()}
|
||||
fallback={
|
||||
<>
|
||||
<Show when={rolled()} keyed>
|
||||
|
||||
@@ -15,37 +15,29 @@ describe("timeline model", () => {
|
||||
expect(selectVisibleUserMessages(users)).toBe(users)
|
||||
})
|
||||
|
||||
test("loads pages until a visible user turn is added", async () => {
|
||||
let loaded = 10
|
||||
let visible = 2
|
||||
test("loads exactly one opaque cursor page", async () => {
|
||||
let calls = 0
|
||||
const anchors: Array<string | boolean> = []
|
||||
|
||||
await loadOlderTimeline({
|
||||
sessionID: () => "ses_test",
|
||||
loaded: () => loaded,
|
||||
visible: () => visible,
|
||||
more: () => true,
|
||||
loading: () => false,
|
||||
loadMore: async () => {
|
||||
calls += 1
|
||||
loaded += 3
|
||||
if (calls === 2) visible += 1
|
||||
},
|
||||
before: () => anchors.push("before"),
|
||||
after: (done) => anchors.push("after", done),
|
||||
})
|
||||
|
||||
expect(calls).toBe(2)
|
||||
expect(anchors).toEqual(["before", "after", false, "after", true])
|
||||
expect(calls).toBe(1)
|
||||
expect(anchors).toEqual(["before", "after", true])
|
||||
})
|
||||
|
||||
test("stops when a page adds no raw messages", async () => {
|
||||
let calls = 0
|
||||
await loadOlderTimeline({
|
||||
sessionID: () => "ses_test",
|
||||
loaded: () => 10,
|
||||
visible: () => 2,
|
||||
more: () => true,
|
||||
loading: () => false,
|
||||
loadMore: async () => {
|
||||
@@ -62,8 +54,6 @@ describe("timeline model", () => {
|
||||
|
||||
await loadOlderTimeline({
|
||||
sessionID: () => sessionID,
|
||||
loaded: () => 10,
|
||||
visible: () => 2,
|
||||
more: () => true,
|
||||
loading: () => false,
|
||||
loadMore: async () => {
|
||||
@@ -83,8 +73,6 @@ describe("timeline model", () => {
|
||||
await expect(
|
||||
loadOlderTimeline({
|
||||
sessionID: () => "ses_test",
|
||||
loaded: () => 10,
|
||||
visible: () => 2,
|
||||
more: () => true,
|
||||
loading: () => false,
|
||||
loadMore: async () => {
|
||||
|
||||
@@ -74,8 +74,6 @@ export function createTimelineModel(input: {
|
||||
const loadOlder = async (options?: { before?: () => void; after?: (done: boolean) => void }) => {
|
||||
return loadOlderTimeline({
|
||||
sessionID: input.sessionID,
|
||||
loaded: () => messages().length,
|
||||
visible: () => visibleUserMessages().length,
|
||||
more,
|
||||
loading,
|
||||
loadMore: (sessionID) => sync().session.history.loadMore(sessionID),
|
||||
@@ -115,8 +113,6 @@ export function selectVisibleUserMessages(messages: UserMessage[], revertMessage
|
||||
|
||||
export async function loadOlderTimeline(input: {
|
||||
sessionID: Accessor<string | undefined>
|
||||
loaded: Accessor<number>
|
||||
visible: Accessor<number>
|
||||
more: Accessor<boolean>
|
||||
loading: Accessor<boolean>
|
||||
loadMore: (sessionID: string) => Promise<void>
|
||||
@@ -126,23 +122,11 @@ export async function loadOlderTimeline(input: {
|
||||
const id = input.sessionID()
|
||||
if (!id || !input.more() || input.loading()) return
|
||||
|
||||
// A history page may contain only assistant messages or user turns hidden by a revert boundary.
|
||||
const beforeVisible = input.visible()
|
||||
let loaded = input.loaded()
|
||||
input.before?.()
|
||||
while (true) {
|
||||
await input.loadMore(id).catch((error) => {
|
||||
if (input.sessionID() === id) input.after?.(true)
|
||||
throw error
|
||||
})
|
||||
if (input.sessionID() !== id) return
|
||||
|
||||
const nextLoaded = input.loaded()
|
||||
const growth = input.visible() - beforeVisible
|
||||
const raw = nextLoaded - loaded
|
||||
loaded = nextLoaded
|
||||
const done = growth > 0 || raw <= 0 || !input.more()
|
||||
input.after?.(done)
|
||||
if (done) return
|
||||
}
|
||||
await input.loadMore(id).catch((error) => {
|
||||
if (input.sessionID() === id) input.after?.(true)
|
||||
throw error
|
||||
})
|
||||
if (input.sessionID() !== id) return
|
||||
input.after?.(true)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { createSessionPlacementStore } from "./session-placement"
|
||||
|
||||
describe("session placement", () => {
|
||||
const local = ServerConnection.Key.make("http://localhost:4096")
|
||||
const remote = ServerConnection.Key.make("https://example.com")
|
||||
|
||||
test("aliases a leaf and root without crossing servers", () => {
|
||||
const store = createSessionPlacementStore()
|
||||
store.set({ server: local, leafID: "child", rootID: "root", directory: "/repo" })
|
||||
store.set({ server: remote, leafID: "child", rootID: "other", directory: "/remote" })
|
||||
|
||||
expect(store.get(local, "child")).toEqual({ rootID: "root", directory: "/repo" })
|
||||
expect(store.get(local, "root")).toEqual({ rootID: "root", directory: "/repo" })
|
||||
expect(store.get(remote, "child")).toEqual({ rootID: "other", directory: "/remote" })
|
||||
})
|
||||
|
||||
test("inherits known placement for in-app child navigation", () => {
|
||||
const store = createSessionPlacementStore()
|
||||
store.set({ server: local, leafID: "parent", rootID: "root", directory: "/repo" })
|
||||
|
||||
expect(store.inherit(local, "parent", "child")).toEqual({ rootID: "root", directory: "/repo" })
|
||||
expect(store.get(local, "child")).toEqual({ rootID: "root", directory: "/repo" })
|
||||
expect(store.inherit(local, "missing", "unknown")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("bounds retained placement aliases", () => {
|
||||
const store = createSessionPlacementStore()
|
||||
for (let index = 0; index < 300; index++) {
|
||||
store.set({ server: local, leafID: `leaf-${index}`, rootID: `root-${index}`, directory: `/repo/${index}` })
|
||||
}
|
||||
|
||||
expect(store.size()).toBeLessThanOrEqual(256)
|
||||
expect(store.get(local, "leaf-0")).toBeUndefined()
|
||||
expect(store.get(local, "leaf-299")).toEqual({ rootID: "root-299", directory: "/repo/299" })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ServerConnection } from "@/context/server"
|
||||
|
||||
export type SessionPlacement = {
|
||||
rootID: string
|
||||
directory: string
|
||||
}
|
||||
|
||||
export function createSessionPlacementStore() {
|
||||
const placements = new Map<string, SessionPlacement>()
|
||||
const limit = 256
|
||||
const key = (server: ServerConnection.Key, sessionID: string) => `${server}\0${sessionID}`
|
||||
const write = (id: string, placement: SessionPlacement) => {
|
||||
placements.delete(id)
|
||||
placements.set(id, placement)
|
||||
while (placements.size > limit) placements.delete(placements.keys().next().value!)
|
||||
}
|
||||
|
||||
return {
|
||||
get(server: ServerConnection.Key, sessionID: string) {
|
||||
const id = key(server, sessionID)
|
||||
const placement = placements.get(id)
|
||||
if (placement) write(id, placement)
|
||||
return placement
|
||||
},
|
||||
set(input: SessionPlacement & { server: ServerConnection.Key; leafID: string }) {
|
||||
const placement = { rootID: input.rootID, directory: input.directory }
|
||||
write(key(input.server, input.leafID), placement)
|
||||
write(key(input.server, input.rootID), placement)
|
||||
return placement
|
||||
},
|
||||
inherit(server: ServerConnection.Key, sourceID: string, leafID: string) {
|
||||
const placement = placements.get(key(server, sourceID))
|
||||
if (!placement) return
|
||||
write(key(server, leafID), placement)
|
||||
return placement
|
||||
},
|
||||
size() {
|
||||
return placements.size
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { beforeAll, describe, expect, mock, test } from "bun:test"
|
||||
import type { AsyncStorage } from "@solid-primitives/storage"
|
||||
import { createEffect, createRoot } from "solid-js"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
|
||||
let Prompt: typeof import("@/context/prompt")
|
||||
let read: ((value: string | null) => void) | undefined
|
||||
|
||||
const storage: AsyncStorage = {
|
||||
getItem: () => new Promise((resolve) => (read = resolve)),
|
||||
setItem: async () => undefined,
|
||||
removeItem: async () => undefined,
|
||||
clear: async () => undefined,
|
||||
key: async () => null,
|
||||
getLength: async () => 0,
|
||||
length: Promise.resolve(0),
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
mock.module("@solidjs/router", () => ({
|
||||
useParams: () => ({}),
|
||||
useSearchParams: () => [{}],
|
||||
useLocation: () => ({ pathname: "", query: {} }),
|
||||
useNavigate: () => () => undefined,
|
||||
}))
|
||||
mock.module("@opencode-ai/ui/context", () => ({
|
||||
createSimpleContext: () => ({
|
||||
use: () => undefined,
|
||||
provider: () => undefined,
|
||||
}),
|
||||
}))
|
||||
mock.module("@/context/platform", () => ({
|
||||
usePlatform: () => ({ platform: "desktop", storage: () => storage }),
|
||||
}))
|
||||
|
||||
Prompt = await import("@/context/prompt")
|
||||
})
|
||||
|
||||
describe("prompt persistence", () => {
|
||||
test("waits for an async draft to hydrate before reporting ready", async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
createRoot((dispose) => {
|
||||
const session = Prompt.createPromptSession(ServerScope.local, { draftID: "draft-async" })
|
||||
const ready = Prompt.createPromptReady(() => session)
|
||||
|
||||
expect(ready()).toBe(false)
|
||||
expect(session.current()[0]).toMatchObject({ type: "text", content: "" })
|
||||
|
||||
read?.(
|
||||
JSON.stringify({
|
||||
prompt: [{ type: "text", content: "persisted draft", start: 0, end: 15 }],
|
||||
cursor: 15,
|
||||
context: { items: [] },
|
||||
}),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
try {
|
||||
expect(session.current()[0]).toMatchObject({ type: "text", content: "persisted draft" })
|
||||
dispose()
|
||||
resolve()
|
||||
} catch (error) {
|
||||
dispose()
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,22 @@ declare const OPENCODE_CLI_NAME: string | undefined
|
||||
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
|
||||
description: "OpenCode 2.0 preview command line interface",
|
||||
commands: [
|
||||
Spec.make("api", {
|
||||
description: "Make a request to the running server",
|
||||
params: {
|
||||
request: Argument.string("operation | method path").pipe(
|
||||
Argument.withDescription("OpenAPI operation ID, or an HTTP method followed by a path"),
|
||||
Argument.variadic({ min: 1, max: 2 }),
|
||||
),
|
||||
data: Flag.string("data").pipe(Flag.withAlias("d"), Flag.withDescription("Request body"), Flag.optional),
|
||||
header: Flag.string("header").pipe(
|
||||
Flag.withAlias("H"),
|
||||
Flag.withDescription("Request header in name:value form"),
|
||||
Flag.atMost(100),
|
||||
),
|
||||
param: Flag.keyValuePair("param").pipe(Flag.withDescription("OpenAPI path or query parameter"), Flag.optional),
|
||||
},
|
||||
}),
|
||||
Spec.make("debug", {
|
||||
description: "Debugging and troubleshooting tools",
|
||||
commands: [Spec.make("agents", { description: "List all agents" })],
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { rawRequest, resolveOperation } from "./api"
|
||||
|
||||
describe("api request resolution", () => {
|
||||
test("resolves an operation ID with path and query parameters", () => {
|
||||
expect(
|
||||
resolveOperation(
|
||||
{
|
||||
paths: {
|
||||
"/api/session/{sessionID}": {
|
||||
get: { operationId: "v2.session.get" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"v2.session.get",
|
||||
{ sessionID: "ses/a", workspace: "work" },
|
||||
),
|
||||
).toEqual({ method: "GET", path: "/api/session/ses%2Fa?workspace=work" })
|
||||
})
|
||||
|
||||
test("rejects a missing path parameter", () => {
|
||||
expect(() =>
|
||||
resolveOperation(
|
||||
{ paths: { "/api/session/{sessionID}": { get: { operationId: "v2.session.get" } } } },
|
||||
"v2.session.get",
|
||||
{},
|
||||
),
|
||||
).toThrow("Missing path parameter: sessionID")
|
||||
})
|
||||
|
||||
test("resolves curl-like method and path input", () => {
|
||||
expect(rawRequest(["post", "/api/foo"])).toEqual({ method: "POST", path: "/api/foo" })
|
||||
expect(rawRequest(["v2.session.list"])).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Daemon } from "../../services/daemon"
|
||||
|
||||
const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"])
|
||||
|
||||
type Operation = {
|
||||
operationId?: string
|
||||
}
|
||||
|
||||
type OpenApi = {
|
||||
paths?: Record<string, Record<string, Operation>>
|
||||
}
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.api,
|
||||
Effect.fn("cli.api")(function* (input) {
|
||||
const daemon = yield* Daemon.Service
|
||||
const transport = yield* daemon.transport()
|
||||
const params = Option.getOrElse(input.param, () => ({}))
|
||||
const request = yield* resolveRequest(transport, input.request, params)
|
||||
const headers = new Headers(transport.headers)
|
||||
for (const header of input.header) {
|
||||
const index = header.indexOf(":")
|
||||
if (index < 1) return yield* Effect.fail(new Error(`Invalid header, expected name:value: ${header}`))
|
||||
headers.set(header.slice(0, index).trim(), header.slice(index + 1).trim())
|
||||
}
|
||||
const body = Option.getOrUndefined(input.data)
|
||||
if (body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")
|
||||
|
||||
const response = yield* Effect.tryPromise(() =>
|
||||
fetch(new URL(request.path, transport.url), {
|
||||
method: request.method,
|
||||
headers,
|
||||
body,
|
||||
}),
|
||||
)
|
||||
const output = yield* Effect.promise(() => response.text())
|
||||
if (output) process.stdout.write(output + (output.endsWith(EOL) ? "" : EOL))
|
||||
}),
|
||||
)
|
||||
|
||||
export function resolveOperation(spec: OpenApi, operationID: string, params: Record<string, string>) {
|
||||
for (const [path, operations] of Object.entries(spec.paths ?? {})) {
|
||||
for (const [method, operation] of Object.entries(operations)) {
|
||||
if (!methods.has(method) || operation.operationId !== operationID) continue
|
||||
return { method: method.toUpperCase(), path: interpolate(path, params) }
|
||||
}
|
||||
}
|
||||
throw new Error(`Operation not found: ${operationID}`)
|
||||
}
|
||||
|
||||
export function rawRequest(input: readonly string[]) {
|
||||
if (input.length !== 2 || !methods.has(input[0].toLowerCase()) || !input[1].startsWith("/")) return
|
||||
return { method: input[0].toUpperCase(), path: input[1] }
|
||||
}
|
||||
|
||||
function resolveRequest(
|
||||
transport: { url: string; headers: RequestInit["headers"] },
|
||||
input: readonly string[],
|
||||
params: Record<string, string>,
|
||||
) {
|
||||
const raw = rawRequest(input)
|
||||
if (raw) return Effect.succeed(raw)
|
||||
if (input.length !== 1) return Effect.fail(new Error("Expected an operation name or an HTTP method and path"))
|
||||
return Effect.tryPromise(async () => {
|
||||
const response = await fetch(new URL("/openapi.json", transport.url), { headers: transport.headers })
|
||||
if (!response.ok) throw new Error(`Failed to load OpenAPI document: HTTP ${response.status}`)
|
||||
return resolveOperation((await response.json()) as OpenApi, input[0], params)
|
||||
})
|
||||
}
|
||||
|
||||
function interpolate(path: string, params: Record<string, string>) {
|
||||
const used = new Set<string>()
|
||||
const pathname = path.replaceAll(/\{([^}]+)\}/g, (_, name: string) => {
|
||||
const value = params[name]
|
||||
if (value === undefined) throw new Error(`Missing path parameter: ${name}`)
|
||||
used.add(name)
|
||||
return encodeURIComponent(value)
|
||||
})
|
||||
const query = new URLSearchParams(Object.entries(params).filter(([name]) => !used.has(name))).toString()
|
||||
return query ? `${pathname}?${query}` : pathname
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { Daemon } from "./services/daemon"
|
||||
|
||||
const Handlers = Runtime.handlers(Commands, {
|
||||
$: () => import("./commands/handlers/default"),
|
||||
api: () => import("./commands/handlers/api"),
|
||||
debug: {
|
||||
agents: () => import("./commands/handlers/debug/agents"),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { Referral } from "@opencode-ai/console-core/referral.js"
|
||||
import { safeEqual } from "@opencode-ai/console-core/util/crypto.js"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import z from "zod"
|
||||
|
||||
const Body = z.object({
|
||||
inviterWorkspaceID: z.string().startsWith("wrk_"),
|
||||
inviteeWorkspaceID: z.string().startsWith("wrk_"),
|
||||
})
|
||||
|
||||
export async function POST(event: APIEvent) {
|
||||
if (!safeEqual(event.request.headers.get("authorization") ?? "", `Bearer ${Resource.SUPPORT_API_KEY.value}`)) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = Body.safeParse(await event.request.json().catch(() => undefined))
|
||||
if (!body.success) {
|
||||
return Response.json({ error: "Invalid request", issues: body.error.issues }, { status: 400 })
|
||||
}
|
||||
return Referral.create(body.data)
|
||||
.then((result) => Response.json({ success: true, message: "Referral created", result }))
|
||||
.catch((error) => Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 }))
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { Account } from "@opencode-ai/console-core/account.js"
|
||||
import { safeEqual } from "@opencode-ai/console-core/util/crypto.js"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import z from "zod"
|
||||
|
||||
const Body = z.object({ email: z.email() })
|
||||
|
||||
export async function DELETE(event: APIEvent) {
|
||||
if (!safeEqual(event.request.headers.get("authorization") ?? "", `Bearer ${Resource.SUPPORT_API_KEY.value}`)) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = Body.safeParse(await event.request.json().catch(() => undefined))
|
||||
if (!body.success) {
|
||||
return Response.json({ error: "Invalid request", issues: body.error.issues }, { status: 400 })
|
||||
}
|
||||
return Account.remove(body.data.email)
|
||||
.then(() => Response.json({ success: true, message: "Account deleted" }))
|
||||
.catch((error) => Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 }))
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { safeEqual } from "@opencode-ai/console-core/util/crypto.js"
|
||||
|
||||
const DISCORD_ALERT_ROLE_ID = "1501447160175136838"
|
||||
const DISCORD_ALERT_ROLE_ID = "1511795723262365887"
|
||||
|
||||
const basePayload = z.object({
|
||||
name: z.string().optional(),
|
||||
|
||||
@@ -697,6 +697,7 @@ export async function handler(
|
||||
logger.metric({
|
||||
api_key: data.apiKey,
|
||||
workspace: data.workspaceID,
|
||||
user_id: data.user.id,
|
||||
...(() => {
|
||||
if (data.billing.subscription)
|
||||
return {
|
||||
|
||||
@@ -37,7 +37,6 @@
|
||||
"update-limits": "script/update-limits.ts",
|
||||
"promote-limits-to-dev": "script/promote-limits.ts dev",
|
||||
"promote-limits-to-prod": "script/promote-limits.ts production",
|
||||
"referral-backfill": "script/referral-backfill.ts",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
import { and, Database, eq, inArray, isNull } from "../src/drizzle/index.js"
|
||||
import { Identifier } from "../src/identifier.js"
|
||||
import { Referral } from "../src/referral.js"
|
||||
import { LiteTable } from "../src/schema/billing.sql.js"
|
||||
import { ReferralRewardTable, ReferralTable } from "../src/schema/referral.sql.js"
|
||||
import { UserTable } from "../src/schema/user.sql.js"
|
||||
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
|
||||
|
||||
const backfills = [
|
||||
{
|
||||
inviterWorkspaceID: "wrk_00000000000000000000000000",
|
||||
inviteeWorkspaceID: "wrk_00000000000000000000000000",
|
||||
inviteeAccountID: "acc_00000000000000000000000000",
|
||||
},
|
||||
]
|
||||
|
||||
console.log(`Backfilling ${backfills.length} referrals`)
|
||||
|
||||
for (const [index, backfill] of backfills.entries()) {
|
||||
console.log(`[${index + 1}/${backfills.length}] ${backfill.inviterWorkspaceID} -> ${backfill.inviteeWorkspaceID}`)
|
||||
console.log(` invitee account: ${backfill.inviteeAccountID}`)
|
||||
|
||||
const result = await Database.transaction(async (tx) => {
|
||||
if (backfill.inviterWorkspaceID === backfill.inviteeWorkspaceID) throw new Error("Self-referral workspace mismatch")
|
||||
|
||||
const inviterWorkspace = await tx
|
||||
.select({ id: WorkspaceTable.id })
|
||||
.from(WorkspaceTable)
|
||||
.where(and(eq(WorkspaceTable.id, backfill.inviterWorkspaceID), isNull(WorkspaceTable.timeDeleted)))
|
||||
.then((rows) => rows[0])
|
||||
if (!inviterWorkspace) throw new Error(`Inviter workspace not found: ${backfill.inviterWorkspaceID}`)
|
||||
|
||||
const inviteeWorkspace = await tx
|
||||
.select({ id: WorkspaceTable.id })
|
||||
.from(WorkspaceTable)
|
||||
.where(and(eq(WorkspaceTable.id, backfill.inviteeWorkspaceID), isNull(WorkspaceTable.timeDeleted)))
|
||||
.then((rows) => rows[0])
|
||||
if (!inviteeWorkspace) throw new Error(`Invitee workspace not found: ${backfill.inviteeWorkspaceID}`)
|
||||
|
||||
const inviteeUser = await tx
|
||||
.select({ id: UserTable.id })
|
||||
.from(UserTable)
|
||||
.where(
|
||||
and(
|
||||
eq(UserTable.workspaceID, backfill.inviteeWorkspaceID),
|
||||
eq(UserTable.accountID, backfill.inviteeAccountID),
|
||||
eq(UserTable.role, "admin"),
|
||||
isNull(UserTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0])
|
||||
if (!inviteeUser) throw new Error(`Invitee workspace owner not found: ${backfill.inviteeAccountID}`)
|
||||
|
||||
const inviterUser = await tx
|
||||
.select({ id: UserTable.id })
|
||||
.from(UserTable)
|
||||
.where(
|
||||
and(
|
||||
eq(UserTable.workspaceID, backfill.inviterWorkspaceID),
|
||||
eq(UserTable.accountID, backfill.inviteeAccountID),
|
||||
isNull(UserTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0])
|
||||
if (inviterUser) throw new Error(`Self-referral is not allowed: ${backfill.inviteeAccountID}`)
|
||||
|
||||
const lite = await tx
|
||||
.select({ id: LiteTable.id })
|
||||
.from(LiteTable)
|
||||
.where(
|
||||
and(
|
||||
eq(LiteTable.workspaceID, backfill.inviteeWorkspaceID),
|
||||
eq(LiteTable.userID, inviteeUser.id),
|
||||
isNull(LiteTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0])
|
||||
if (!lite) throw new Error(`Invitee Lite subscription not found: ${backfill.inviteeWorkspaceID}`)
|
||||
|
||||
const existingReferral = await tx
|
||||
.select({ id: ReferralTable.id, workspaceID: ReferralTable.workspaceID })
|
||||
.from(ReferralTable)
|
||||
.where(and(eq(ReferralTable.inviteeAccountID, backfill.inviteeAccountID), isNull(ReferralTable.timeDeleted)))
|
||||
.then((rows) => rows[0])
|
||||
if (existingReferral && existingReferral.workspaceID !== backfill.inviterWorkspaceID) {
|
||||
throw new Error(`Referral already belongs to ${existingReferral.workspaceID}: ${existingReferral.id}`)
|
||||
}
|
||||
|
||||
const referralID = existingReferral?.id ?? Identifier.create("referral")
|
||||
if (!existingReferral) {
|
||||
await tx.insert(ReferralTable).ignore().values({
|
||||
workspaceID: backfill.inviterWorkspaceID,
|
||||
id: referralID,
|
||||
inviteeAccountID: backfill.inviteeAccountID,
|
||||
})
|
||||
|
||||
const referral = await tx
|
||||
.select({ id: ReferralTable.id })
|
||||
.from(ReferralTable)
|
||||
.where(and(eq(ReferralTable.inviteeAccountID, backfill.inviteeAccountID), isNull(ReferralTable.timeDeleted)))
|
||||
.then((rows) => rows[0])
|
||||
if (!referral) throw new Error(`Referral not created: ${backfill.inviteeAccountID}`)
|
||||
if (referral.id !== referralID) throw new Error(`Referral already redeemed: ${referral.id}`)
|
||||
}
|
||||
|
||||
const rewardInsert = await tx
|
||||
.insert(ReferralRewardTable)
|
||||
.ignore()
|
||||
.values([
|
||||
{
|
||||
workspaceID: backfill.inviterWorkspaceID,
|
||||
referralID,
|
||||
amount: Referral.REWARD_AMOUNT,
|
||||
},
|
||||
{
|
||||
workspaceID: backfill.inviteeWorkspaceID,
|
||||
referralID,
|
||||
amount: Referral.REWARD_AMOUNT,
|
||||
},
|
||||
])
|
||||
|
||||
const rewards = await tx
|
||||
.select({ workspaceID: ReferralRewardTable.workspaceID, amount: ReferralRewardTable.amount })
|
||||
.from(ReferralRewardTable)
|
||||
.where(
|
||||
and(
|
||||
eq(ReferralRewardTable.referralID, referralID),
|
||||
inArray(ReferralRewardTable.workspaceID, [backfill.inviterWorkspaceID, backfill.inviteeWorkspaceID]),
|
||||
isNull(ReferralRewardTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
if (rewards.length !== 2) throw new Error(`Referral rewards not created: ${referralID}`)
|
||||
if (rewards.some((reward) => reward.amount !== Referral.REWARD_AMOUNT)) {
|
||||
throw new Error(`Referral reward amount mismatch: ${referralID}`)
|
||||
}
|
||||
|
||||
return {
|
||||
referralID,
|
||||
createdReferral: !existingReferral,
|
||||
createdRewards: rewardInsert.rowsAffected,
|
||||
inviteeUserID: inviteeUser.id,
|
||||
liteID: lite.id,
|
||||
rewardWorkspaces: rewards.map((reward) => reward.workspaceID),
|
||||
}
|
||||
})
|
||||
|
||||
console.log(` invitee user: ${result.inviteeUserID}`)
|
||||
console.log(` lite: ${result.liteID}`)
|
||||
console.log(` referral: ${result.referralID} (${result.createdReferral ? "created" : "existing"})`)
|
||||
console.log(` rewards: ${result.rewardWorkspaces.join(", ")} (${result.createdRewards} inserted)`)
|
||||
}
|
||||
|
||||
console.log("Referral backfill complete")
|
||||
@@ -1,9 +1,13 @@
|
||||
import { z } from "zod"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { and, eq, inArray, sql } from "drizzle-orm"
|
||||
import { fn } from "./util/fn"
|
||||
import { Database } from "./drizzle"
|
||||
import { Identifier } from "./identifier"
|
||||
import { AccountTable } from "./schema/account.sql"
|
||||
import { AuthTable } from "./schema/auth.sql"
|
||||
import { UserTable } from "./schema/user.sql"
|
||||
import { KeyTable } from "./schema/key.sql"
|
||||
import { CouponTable } from "./schema/billing.sql"
|
||||
|
||||
export namespace Account {
|
||||
export const create = fn(
|
||||
@@ -20,6 +24,52 @@ export namespace Account {
|
||||
}),
|
||||
)
|
||||
|
||||
export const remove = fn(z.email(), async (email) => {
|
||||
await Database.transaction(async (tx) => {
|
||||
const account = await tx
|
||||
.select({ id: AccountTable.id })
|
||||
.from(AuthTable)
|
||||
.innerJoin(AccountTable, eq(AccountTable.id, AuthTable.accountID))
|
||||
.where(and(eq(AuthTable.provider, "email"), eq(AuthTable.subject, email)))
|
||||
.then((rows) => rows[0])
|
||||
if (!account) throw new Error("Account not found")
|
||||
|
||||
const emails = await tx
|
||||
.select({ email: AuthTable.subject })
|
||||
.from(AuthTable)
|
||||
.where(and(eq(AuthTable.accountID, account.id), eq(AuthTable.provider, "email")))
|
||||
const users = await tx.select({ id: UserTable.id }).from(UserTable).where(eq(UserTable.accountID, account.id))
|
||||
if (users.length > 0) {
|
||||
await tx
|
||||
.update(KeyTable)
|
||||
.set({ timeDeleted: sql`now()` })
|
||||
.where(
|
||||
inArray(
|
||||
KeyTable.userID,
|
||||
users.map((user) => user.id),
|
||||
),
|
||||
)
|
||||
}
|
||||
await tx
|
||||
.update(UserTable)
|
||||
.set({ accountID: null, email: null, name: "", timeDeleted: sql`now()` })
|
||||
.where(eq(UserTable.accountID, account.id))
|
||||
if (emails.length > 0) {
|
||||
await tx.delete(CouponTable).where(
|
||||
inArray(
|
||||
CouponTable.email,
|
||||
emails.map((row) => row.email),
|
||||
),
|
||||
)
|
||||
}
|
||||
await tx.delete(AuthTable).where(eq(AuthTable.accountID, account.id))
|
||||
await tx
|
||||
.update(AccountTable)
|
||||
.set({ timeDeleted: sql`now()` })
|
||||
.where(eq(AccountTable.id, account.id))
|
||||
})
|
||||
})
|
||||
|
||||
export const fromID = fn(z.string(), async (id) =>
|
||||
Database.use((tx) =>
|
||||
tx
|
||||
|
||||
@@ -362,6 +362,119 @@ export namespace Referral {
|
||||
})
|
||||
}
|
||||
|
||||
export async function create(input: { inviterWorkspaceID: string; inviteeWorkspaceID: string }) {
|
||||
return Database.transaction(async (tx) => {
|
||||
if (input.inviterWorkspaceID === input.inviteeWorkspaceID) throw new Error("Self-referral workspace mismatch")
|
||||
|
||||
const inviterWorkspace = await tx
|
||||
.select({ id: WorkspaceTable.id })
|
||||
.from(WorkspaceTable)
|
||||
.where(and(eq(WorkspaceTable.id, input.inviterWorkspaceID), isNull(WorkspaceTable.timeDeleted)))
|
||||
.then((rows) => rows[0])
|
||||
if (!inviterWorkspace) throw new Error(`Inviter workspace not found: ${input.inviterWorkspaceID}`)
|
||||
|
||||
const inviteeWorkspace = await tx
|
||||
.select({ id: WorkspaceTable.id })
|
||||
.from(WorkspaceTable)
|
||||
.where(and(eq(WorkspaceTable.id, input.inviteeWorkspaceID), isNull(WorkspaceTable.timeDeleted)))
|
||||
.then((rows) => rows[0])
|
||||
if (!inviteeWorkspace) throw new Error(`Invitee workspace not found: ${input.inviteeWorkspaceID}`)
|
||||
|
||||
const invitee = await tx
|
||||
.select({ accountID: UserTable.accountID, userID: UserTable.id, liteID: LiteTable.id })
|
||||
.from(UserTable)
|
||||
.innerJoin(
|
||||
LiteTable,
|
||||
and(
|
||||
eq(LiteTable.workspaceID, UserTable.workspaceID),
|
||||
eq(LiteTable.userID, UserTable.id),
|
||||
isNull(LiteTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(UserTable.workspaceID, input.inviteeWorkspaceID),
|
||||
eq(UserTable.role, "admin"),
|
||||
isNull(UserTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0])
|
||||
if (!invitee?.accountID) throw new Error(`Invitee Lite workspace owner not found: ${input.inviteeWorkspaceID}`)
|
||||
|
||||
const inviterUser = await tx
|
||||
.select({ id: UserTable.id })
|
||||
.from(UserTable)
|
||||
.where(
|
||||
and(
|
||||
eq(UserTable.workspaceID, input.inviterWorkspaceID),
|
||||
eq(UserTable.accountID, invitee.accountID),
|
||||
isNull(UserTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0])
|
||||
if (inviterUser) throw new Error(`Self-referral is not allowed: ${invitee.accountID}`)
|
||||
|
||||
const existingReferral = await tx
|
||||
.select({ id: ReferralTable.id, workspaceID: ReferralTable.workspaceID })
|
||||
.from(ReferralTable)
|
||||
.where(and(eq(ReferralTable.inviteeAccountID, invitee.accountID), isNull(ReferralTable.timeDeleted)))
|
||||
.then((rows) => rows[0])
|
||||
if (existingReferral && existingReferral.workspaceID !== input.inviterWorkspaceID) {
|
||||
throw new Error(`Referral already belongs to ${existingReferral.workspaceID}: ${existingReferral.id}`)
|
||||
}
|
||||
|
||||
const referralID = existingReferral?.id ?? Identifier.create("referral")
|
||||
if (!existingReferral) {
|
||||
await tx.insert(ReferralTable).ignore().values({
|
||||
workspaceID: input.inviterWorkspaceID,
|
||||
id: referralID,
|
||||
inviteeAccountID: invitee.accountID,
|
||||
})
|
||||
|
||||
const referral = await tx
|
||||
.select({ id: ReferralTable.id })
|
||||
.from(ReferralTable)
|
||||
.where(and(eq(ReferralTable.inviteeAccountID, invitee.accountID), isNull(ReferralTable.timeDeleted)))
|
||||
.then((rows) => rows[0])
|
||||
if (!referral) throw new Error(`Referral not created: ${invitee.accountID}`)
|
||||
if (referral.id !== referralID) throw new Error(`Referral already redeemed: ${referral.id}`)
|
||||
}
|
||||
|
||||
const rewardInsert = await tx
|
||||
.insert(ReferralRewardTable)
|
||||
.ignore()
|
||||
.values([
|
||||
{ workspaceID: input.inviterWorkspaceID, referralID, amount: REWARD_AMOUNT },
|
||||
{ workspaceID: input.inviteeWorkspaceID, referralID, amount: REWARD_AMOUNT },
|
||||
])
|
||||
|
||||
const rewards = await tx
|
||||
.select({ workspaceID: ReferralRewardTable.workspaceID, amount: ReferralRewardTable.amount })
|
||||
.from(ReferralRewardTable)
|
||||
.where(
|
||||
and(
|
||||
eq(ReferralRewardTable.referralID, referralID),
|
||||
inArray(ReferralRewardTable.workspaceID, [input.inviterWorkspaceID, input.inviteeWorkspaceID]),
|
||||
isNull(ReferralRewardTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
if (rewards.length !== 2) throw new Error(`Referral rewards not created: ${referralID}`)
|
||||
if (rewards.some((reward) => reward.amount !== REWARD_AMOUNT)) {
|
||||
throw new Error(`Referral reward amount mismatch: ${referralID}`)
|
||||
}
|
||||
|
||||
return {
|
||||
referralID,
|
||||
createdReferral: !existingReferral,
|
||||
createdRewards: rewardInsert.rowsAffected,
|
||||
inviteeAccountID: invitee.accountID,
|
||||
inviteeUserID: invitee.userID,
|
||||
liteID: invitee.liteID,
|
||||
rewardWorkspaces: rewards.map((reward) => reward.workspaceID),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function completeFromLiteSubscription(input: { workspaceID: string; userID: string }) {
|
||||
return Database.transaction(async (tx) => {
|
||||
const invitee = await tx
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Key } from "./key"
|
||||
import { KeyTable } from "./schema/key.sql"
|
||||
import { WorkspaceTable } from "./schema/workspace.sql"
|
||||
import { AuthTable } from "./schema/auth.sql"
|
||||
import { AccountTable } from "./schema/account.sql"
|
||||
|
||||
export namespace User {
|
||||
const assertNotSelf = (id: string) => {
|
||||
@@ -161,6 +162,13 @@ export namespace User {
|
||||
export const joinInvitedWorkspaces = fn(z.void(), async () => {
|
||||
const account = Actor.assert("account")
|
||||
const invitations = await Database.use(async (tx) => {
|
||||
const active = await tx
|
||||
.select({ id: AccountTable.id })
|
||||
.from(AccountTable)
|
||||
.where(and(eq(AccountTable.id, account.properties.accountID), isNull(AccountTable.timeDeleted)))
|
||||
.then((rows) => rows[0])
|
||||
if (!active) throw new Error("Account is not active")
|
||||
|
||||
const invitations = await tx
|
||||
.select({
|
||||
id: UserTable.id,
|
||||
|
||||
@@ -6,8 +6,9 @@ import { Identifier } from "./identifier"
|
||||
import { UserTable } from "./schema/user.sql"
|
||||
import { BillingTable } from "./schema/billing.sql"
|
||||
import { WorkspaceTable } from "./schema/workspace.sql"
|
||||
import { AccountTable } from "./schema/account.sql"
|
||||
import { Key } from "./key"
|
||||
import { eq, sql } from "drizzle-orm"
|
||||
import { and, eq, isNull, sql } from "drizzle-orm"
|
||||
|
||||
export namespace Workspace {
|
||||
export const create = fn(
|
||||
@@ -19,6 +20,13 @@ export namespace Workspace {
|
||||
const workspaceID = Identifier.create("workspace")
|
||||
const userID = Identifier.create("user")
|
||||
await Database.transaction(async (tx) => {
|
||||
const active = await tx
|
||||
.select({ id: AccountTable.id })
|
||||
.from(AccountTable)
|
||||
.where(and(eq(AccountTable.id, account.properties.accountID), isNull(AccountTable.timeDeleted)))
|
||||
.then((rows) => rows[0])
|
||||
if (!active) throw new Error("Account is not active")
|
||||
|
||||
await tx.insert(WorkspaceTable).values({
|
||||
id: workspaceID,
|
||||
name,
|
||||
|
||||
@@ -132,6 +132,7 @@ function toLakeEvent(time: string, data: Record<string, unknown>) {
|
||||
error_cause2: string(data, "error.cause2"),
|
||||
api_key: string(data, "api_key"),
|
||||
workspace: string(data, "workspace"),
|
||||
user_id: string(data, "user_id"),
|
||||
is_subscription: boolean(data, "isSubscription"), // removed
|
||||
subscription: string(data, "subscription"),
|
||||
response_length: integer(data, "response_length"),
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
||||
"@opencode-ai/llm": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "2.6.1",
|
||||
|
||||
@@ -1,46 +1,17 @@
|
||||
export * as AgentV2 from "./agent"
|
||||
|
||||
import { Array, Context, Effect, Layer, Schema, Scope, Types } from "effect"
|
||||
import { ModelV2 } from "./model"
|
||||
import { PermissionSchema } from "./permission/schema"
|
||||
import { ProviderV2 } from "./provider"
|
||||
import { PositiveInt } from "./schema"
|
||||
import { Array, Context, Effect, Layer, Types } from "effect"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { State } from "./state"
|
||||
|
||||
export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID"))
|
||||
export const ID = Agent.ID
|
||||
export type ID = typeof ID.Type
|
||||
export const defaultID = ID.make("build")
|
||||
|
||||
export const Color = Schema.Union([
|
||||
Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)),
|
||||
Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
|
||||
])
|
||||
export const Color = Agent.Color
|
||||
|
||||
export class Info extends Schema.Class<Info>("AgentV2.Info")({
|
||||
id: ID,
|
||||
model: ModelV2.Ref.pipe(Schema.optional),
|
||||
request: ProviderV2.Request,
|
||||
system: Schema.String.pipe(Schema.optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
mode: Schema.Literals(["subagent", "primary", "all"]),
|
||||
hidden: Schema.Boolean,
|
||||
color: Color.pipe(Schema.optional),
|
||||
steps: PositiveInt.pipe(Schema.optional),
|
||||
permissions: PermissionSchema.Ruleset,
|
||||
}) {
|
||||
static empty(id: ID) {
|
||||
return new Info({
|
||||
id,
|
||||
request: {
|
||||
headers: {},
|
||||
body: {},
|
||||
},
|
||||
mode: "all",
|
||||
hidden: false,
|
||||
permissions: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
export const Info = Agent.Info
|
||||
export type Info = Agent.Info
|
||||
|
||||
export interface Selection {
|
||||
readonly id: ID
|
||||
|
||||
@@ -73,7 +73,7 @@ export const layer = Layer.effect(
|
||||
if (provider.disabled) return false
|
||||
if (typeof provider.request.body.apiKey === "string") return true
|
||||
if (integration?.connections.length) return true
|
||||
return !integration
|
||||
return provider.integrationID === undefined && !integration
|
||||
}
|
||||
|
||||
const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => {
|
||||
@@ -89,7 +89,7 @@ export const layer = Layer.effect(
|
||||
...ModelRequest.merge({ ...provider.request, generation: {}, options: {} }, model.request),
|
||||
variant: model.request.variant,
|
||||
}
|
||||
return new ModelV2.Info({
|
||||
return ModelV2.Info.make({
|
||||
...model,
|
||||
api,
|
||||
request,
|
||||
@@ -184,7 +184,7 @@ export const layer = Layer.effect(
|
||||
available: Effect.fn("CatalogV2.provider.available")(function* () {
|
||||
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
|
||||
return (yield* result.provider.all()).filter((provider) =>
|
||||
available(provider, active.get(Integration.ID.make(provider.id))),
|
||||
available(provider, active.get(provider.integrationID ?? Integration.ID.make(provider.id))),
|
||||
)
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
export * as CommandV2 from "./command"
|
||||
|
||||
import { Context, Effect, Layer, Schema, Types } from "effect"
|
||||
import { ModelV2 } from "./model"
|
||||
import { Context, Effect, Layer, Types } from "effect"
|
||||
import { Command } from "@opencode-ai/schema/command"
|
||||
import { State } from "./state"
|
||||
|
||||
export class Info extends Schema.Class<Info>("CommandV2.Info")({
|
||||
name: Schema.String,
|
||||
template: Schema.String,
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
agent: Schema.String.pipe(Schema.optional),
|
||||
model: ModelV2.Ref.pipe(Schema.optional),
|
||||
subtask: Schema.Boolean.pipe(Schema.optional),
|
||||
}) {}
|
||||
export const Info = Command.Info
|
||||
export type Info = Command.Info
|
||||
|
||||
export type Data = {
|
||||
commands: Map<string, Types.DeepMutable<Info>>
|
||||
@@ -40,7 +34,7 @@ export const layer = Layer.effect(
|
||||
list: () => Array.from(draft.commands.values()) as Info[],
|
||||
get: (name) => draft.commands.get(name),
|
||||
update: (name, update) => {
|
||||
const current = draft.commands.get(name) ?? (new Info({ name, template: "" }) as Types.DeepMutable<Info>)
|
||||
const current = draft.commands.get(name) ?? ({ name, template: "" } as Types.DeepMutable<Info>)
|
||||
if (!draft.commands.has(name)) draft.commands.set(name, current)
|
||||
update(current)
|
||||
current.name = name
|
||||
|
||||
@@ -3,10 +3,10 @@ export * as Config from "./config"
|
||||
import path from "path"
|
||||
import { type ParseError, parse } from "jsonc-parser"
|
||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Global } from "./global"
|
||||
import { Location } from "./location"
|
||||
import { PermissionSchema } from "./permission/schema"
|
||||
import { Policy } from "./policy"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { ConfigAgent } from "./config/agent"
|
||||
@@ -56,7 +56,7 @@ export class Info extends Schema.Class<Info>("Config.Info")({
|
||||
username: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "Username displayed in conversations and used for telemetry identity",
|
||||
}),
|
||||
permissions: PermissionSchema.Ruleset.pipe(Schema.optional).annotate({
|
||||
permissions: Permission.Ruleset.pipe(Schema.optional).annotate({
|
||||
description: "Ordered tool permission rules applied to agent tool use",
|
||||
}),
|
||||
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(Schema.optional).annotate({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as ConfigAgent from "./agent"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { PermissionSchema } from "../permission/schema"
|
||||
import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { ConfigProvider } from "./provider"
|
||||
import { PositiveInt } from "../schema"
|
||||
|
||||
@@ -21,5 +21,5 @@ export class Info extends Schema.Class<Info>("ConfigV2.Agent")({
|
||||
color: Color.pipe(Schema.optional),
|
||||
steps: PositiveInt.pipe(Schema.optional),
|
||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||
permissions: PermissionSchema.Ruleset.pipe(Schema.optional),
|
||||
permissions: Permission.Ruleset.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
@@ -28,7 +28,7 @@ export const Plugin = define({
|
||||
entries.set(
|
||||
name,
|
||||
local(entry)
|
||||
? new Reference.LocalSource({
|
||||
? Reference.LocalSource.make({
|
||||
type: "local",
|
||||
path: AbsolutePath.make(
|
||||
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
|
||||
@@ -36,7 +36,7 @@ export const Plugin = define({
|
||||
description: typeof entry === "string" ? undefined : entry.description,
|
||||
hidden: typeof entry === "string" ? undefined : entry.hidden,
|
||||
})
|
||||
: new Reference.GitSource({
|
||||
: Reference.GitSource.make({
|
||||
type: "git",
|
||||
repository: typeof entry === "string" ? entry : entry.repository,
|
||||
branch: typeof entry === "string" ? undefined : entry.branch,
|
||||
|
||||
@@ -22,20 +22,23 @@ export const Plugin = define({
|
||||
const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
|
||||
for (const directory of directories) {
|
||||
draft.source(
|
||||
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
|
||||
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
|
||||
)
|
||||
draft.source(
|
||||
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
|
||||
SkillV2.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join(directory, "skills")),
|
||||
}),
|
||||
)
|
||||
}
|
||||
for (const item of items) {
|
||||
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
|
||||
draft.source(new SkillV2.UrlSource({ type: "url", url: item }))
|
||||
draft.source(SkillV2.UrlSource.make({ type: "url", url: item }))
|
||||
continue
|
||||
}
|
||||
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
|
||||
draft.source(
|
||||
new SkillV2.DirectorySource({
|
||||
SkillV2.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
|
||||
}),
|
||||
|
||||
@@ -2,41 +2,26 @@ export * as Credential from "./credential"
|
||||
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Database } from "./database/database"
|
||||
import { IntegrationSchema } from "./integration/schema"
|
||||
import { NonNegativeInt, withStatics } from "./schema"
|
||||
import { Identifier } from "./util/identifier"
|
||||
import { CredentialTable } from "./credential/sql"
|
||||
|
||||
export const ID = Schema.String.pipe(
|
||||
Schema.brand("Credential.ID"),
|
||||
withStatics((schema) => ({ create: () => schema.make("cred_" + Identifier.ascending()) })),
|
||||
)
|
||||
export type ID = typeof ID.Type
|
||||
export const ID = Credential.ID
|
||||
export type ID = Credential.ID
|
||||
|
||||
export class OAuth extends Schema.Class<OAuth>("Credential.OAuth")({
|
||||
type: Schema.Literal("oauth"),
|
||||
methodID: IntegrationSchema.MethodID,
|
||||
refresh: Schema.String,
|
||||
access: Schema.String,
|
||||
expires: NonNegativeInt,
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
}) {}
|
||||
export const OAuth = Credential.OAuth
|
||||
export type OAuth = Credential.OAuth
|
||||
|
||||
export class Key extends Schema.Class<Key>("Credential.Key")({
|
||||
type: Schema.Literal("key"),
|
||||
key: Schema.String,
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
}) {}
|
||||
export const Key = Credential.Key
|
||||
export type Key = Credential.Key
|
||||
|
||||
export const Value = Schema.Union([OAuth, Key])
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
.annotate({ identifier: "Credential.Value" })
|
||||
export type Value = Schema.Schema.Type<typeof Value>
|
||||
export const Value = Credential.Value
|
||||
export type Value = Credential.Value
|
||||
|
||||
export class Info extends Schema.Class<Info>("Credential.Info")({
|
||||
id: ID,
|
||||
integrationID: IntegrationSchema.ID,
|
||||
integrationID: Integration.ID,
|
||||
label: Schema.String,
|
||||
value: Value,
|
||||
}) {}
|
||||
@@ -45,12 +30,12 @@ export interface Interface {
|
||||
/** Returns every stored credential. */
|
||||
readonly all: () => Effect.Effect<Info[]>
|
||||
/** Returns stored credentials belonging to one integration. */
|
||||
readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Info[]>
|
||||
readonly list: (integrationID: Integration.ID) => Effect.Effect<Info[]>
|
||||
/** Returns one stored credential by ID. */
|
||||
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||
/** Replaces any credential for an integration and returns the new record. */
|
||||
readonly create: (input: {
|
||||
readonly integrationID: IntegrationSchema.ID
|
||||
readonly integrationID: Integration.ID
|
||||
readonly value: Value
|
||||
readonly label?: string
|
||||
}) => Effect.Effect<Info>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||
import { Timestamps } from "../database/schema.sql"
|
||||
import type { IntegrationSchema } from "../integration/schema"
|
||||
import type { Credential } from "../credential"
|
||||
|
||||
export const CredentialTable = sqliteTable("credential", {
|
||||
id: text().$type<Credential.ID>().primaryKey(),
|
||||
integration_id: text().$type<IntegrationSchema.ID>(),
|
||||
integration_id: text().$type<Credential.Info["integrationID"]>(),
|
||||
label: text().notNull(),
|
||||
value: text({ mode: "json" }).$type<Credential.Value>().notNull(),
|
||||
connector_id: text(),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { and, asc, eq, gt } from "drizzle-orm"
|
||||
import { Database } from "./database/database"
|
||||
import { EventSequenceTable, EventTable } from "./event/sql"
|
||||
import { Location } from "./location"
|
||||
import { externalID, type ExternalID, withStatics } from "./schema"
|
||||
import { withStatics } from "./schema"
|
||||
import { Identifier } from "./util/identifier"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
@@ -14,7 +14,6 @@ export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe(
|
||||
Schema.brand("Event.ID"),
|
||||
withStatics((schema) => ({
|
||||
create: () => schema.make("evt_" + Identifier.ascending()),
|
||||
fromExternal: (input: ExternalID) => schema.make(externalID("evt", input)),
|
||||
})),
|
||||
)
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
@@ -7,8 +7,8 @@ import { FSUtil } from "./fs-util"
|
||||
import { Location } from "./location"
|
||||
import { PositiveInt, RelativePath } from "./schema"
|
||||
import { FileSystemSearch } from "./filesystem/search"
|
||||
import { Entry, Match } from "./filesystem/schema"
|
||||
export { Entry, Match, Submatch } from "./filesystem/schema"
|
||||
import { Entry, Match } from "@opencode-ai/schema/filesystem"
|
||||
export { Entry, Match, Submatch } from "@opencode-ai/schema/filesystem"
|
||||
|
||||
export const ReadInput = Schema.Struct({
|
||||
path: RelativePath,
|
||||
@@ -108,10 +108,9 @@ const baseLayer = Layer.effect(
|
||||
const absolute = path.join(target.absolute, item.name)
|
||||
const relative = path.relative(target.directory, absolute)
|
||||
return [
|
||||
new Entry({
|
||||
Entry.make({
|
||||
path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
|
||||
type: item.type,
|
||||
mime: item.type === "directory" ? "application/x-directory" : FSUtil.mimeType(absolute),
|
||||
}),
|
||||
]
|
||||
})
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt, PositiveInt, RelativePath } from "../schema"
|
||||
|
||||
export class Entry extends Schema.Class<Entry>("FileSystem.Entry")({
|
||||
path: RelativePath,
|
||||
type: Schema.Literals(["file", "directory"]),
|
||||
mime: Schema.String,
|
||||
}) {}
|
||||
|
||||
export const Submatch = Schema.Struct({
|
||||
text: Schema.String,
|
||||
start: NonNegativeInt,
|
||||
end: NonNegativeInt,
|
||||
})
|
||||
export type Submatch = typeof Submatch.Type
|
||||
|
||||
export class Match extends Schema.Class<Match>("FileSystem.Match")({
|
||||
entry: Entry,
|
||||
line: PositiveInt,
|
||||
offset: NonNegativeInt,
|
||||
text: Schema.String,
|
||||
submatches: Schema.Array(Submatch),
|
||||
}) {}
|
||||
@@ -59,12 +59,11 @@ export const ripgrepLayer = Layer.effect(
|
||||
})
|
||||
.pipe(
|
||||
Effect.map((result) =>
|
||||
result.map(
|
||||
(entry) =>
|
||||
new FileSystem.Entry({
|
||||
...entry,
|
||||
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
|
||||
}),
|
||||
result.map((entry) =>
|
||||
FileSystem.Entry.make({
|
||||
...entry,
|
||||
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.orDie,
|
||||
@@ -85,15 +84,14 @@ export const ripgrepLayer = Layer.effect(
|
||||
})
|
||||
.pipe(
|
||||
Effect.map((result) =>
|
||||
result.map(
|
||||
(match) =>
|
||||
new FileSystem.Match({
|
||||
...match,
|
||||
entry: new FileSystem.Entry({
|
||||
...match.entry,
|
||||
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, match.entry.path))),
|
||||
}),
|
||||
result.map((match) =>
|
||||
FileSystem.Match.make({
|
||||
...match,
|
||||
entry: FileSystem.Entry.make({
|
||||
...match.entry,
|
||||
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, match.entry.path))),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.orDie,
|
||||
@@ -110,12 +108,9 @@ export const ripgrepLayer = Layer.effect(
|
||||
return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
|
||||
const relative = item.target
|
||||
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
|
||||
const clean = type === "directory" ? relative.slice(0, -path.sep.length) : relative
|
||||
const absolute = path.resolve(location.directory, clean)
|
||||
return new FileSystem.Entry({
|
||||
return FileSystem.Entry.make({
|
||||
path: RelativePath.make(relative),
|
||||
type,
|
||||
mime: type === "directory" ? "application/x-directory" : FSUtil.mimeType(absolute),
|
||||
})
|
||||
})
|
||||
}),
|
||||
@@ -148,14 +143,12 @@ export const fffLayer = Layer.effect(
|
||||
pageSize: input.limit,
|
||||
})
|
||||
if (!found.ok) throw found.error
|
||||
return found.value.items.map((item) => {
|
||||
const absolute = path.resolve(location.directory, item.relativePath)
|
||||
return new FileSystem.Entry({
|
||||
return found.value.items.map((item) =>
|
||||
FileSystem.Entry.make({
|
||||
path: RelativePath.make(item.relativePath.replaceAll("\\", "/")),
|
||||
type: "file",
|
||||
mime: FSUtil.mimeType(absolute),
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
}),
|
||||
grep: (input) =>
|
||||
Effect.sync(() => {
|
||||
@@ -169,11 +162,10 @@ export const fffLayer = Layer.effect(
|
||||
if (!found.ok) throw found.error
|
||||
return found.value.items.map((match) => {
|
||||
const bytes = Buffer.from(match.lineContent)
|
||||
return new FileSystem.Match({
|
||||
entry: new FileSystem.Entry({
|
||||
return FileSystem.Match.make({
|
||||
entry: FileSystem.Entry.make({
|
||||
path: RelativePath.make(match.relativePath.replaceAll("\\", "/")),
|
||||
type: "file",
|
||||
mime: FSUtil.mimeType(match.relativePath),
|
||||
}),
|
||||
line: match.lineNumber,
|
||||
offset: match.byteOffset,
|
||||
@@ -220,11 +212,9 @@ export const fffLayer = Layer.effect(
|
||||
.sort((a, b) => b.score - a.score || a.path.length - b.path.length)
|
||||
.map((item) => {
|
||||
const relative = item.path.replaceAll("\\", "/").replace(/\/$/, "")
|
||||
const absolute = path.resolve(location.directory, relative)
|
||||
return new FileSystem.Entry({
|
||||
return FileSystem.Entry.make({
|
||||
path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
|
||||
type: item.type,
|
||||
mime: item.type === "directory" ? "application/x-directory" : FSUtil.mimeType(absolute),
|
||||
})
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -14,7 +14,12 @@ export namespace FSUtil {
|
||||
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
|
||||
method: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
}) {
|
||||
override get message() {
|
||||
const detail = this.cause instanceof Error ? this.cause.message : this.cause && String(this.cause)
|
||||
return `Filesystem operation failed: ${this.method}${detail ? `: ${detail}` : ""}`
|
||||
}
|
||||
}
|
||||
|
||||
export type Error = PlatformError | FileSystemError
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { randomBytes } from "crypto"
|
||||
import { create as createIdentifier } from "@opencode-ai/schema/identifier"
|
||||
|
||||
const prefixes = {
|
||||
job: "job",
|
||||
@@ -13,12 +13,6 @@ const prefixes = {
|
||||
workspace: "wrk",
|
||||
} as const
|
||||
|
||||
const LENGTH = 26
|
||||
|
||||
// State for monotonic ID generation
|
||||
let lastTimestamp = 0
|
||||
let counter = 0
|
||||
|
||||
export function ascending(prefix: keyof typeof prefixes, given?: string) {
|
||||
return generateID(prefix, "ascending", given)
|
||||
}
|
||||
@@ -38,35 +32,8 @@ function generateID(prefix: keyof typeof prefixes, direction: "descending" | "as
|
||||
return given
|
||||
}
|
||||
|
||||
function randomBase62(length: number): string {
|
||||
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
let result = ""
|
||||
const bytes = randomBytes(length)
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars[bytes[i] % 62]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function create(prefix: string, direction: "descending" | "ascending", timestamp?: number): string {
|
||||
const currentTimestamp = timestamp ?? Date.now()
|
||||
|
||||
if (currentTimestamp !== lastTimestamp) {
|
||||
lastTimestamp = currentTimestamp
|
||||
counter = 0
|
||||
}
|
||||
counter++
|
||||
|
||||
let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)
|
||||
|
||||
now = direction === "descending" ? ~now : now
|
||||
|
||||
const timeBytes = Buffer.alloc(6)
|
||||
for (let i = 0; i < 6; i++) {
|
||||
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
|
||||
}
|
||||
|
||||
return prefix + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12)
|
||||
return prefix + "_" + createIdentifier(direction === "descending", timestamp)
|
||||
}
|
||||
|
||||
/** Extract timestamp from an ascending ID. Does not work with descending IDs. */
|
||||
|
||||
@@ -14,19 +14,19 @@ import {
|
||||
SynchronizedRef,
|
||||
Types,
|
||||
} from "effect"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Credential } from "./credential"
|
||||
import { IntegrationSchema } from "./integration/schema"
|
||||
import { withStatics } from "./schema"
|
||||
import { State } from "./state"
|
||||
import { Identifier } from "./util/identifier"
|
||||
import { EventV2 } from "./event"
|
||||
import { IntegrationConnection } from "./integration/connection"
|
||||
|
||||
export const ID = IntegrationSchema.ID
|
||||
export type ID = IntegrationSchema.ID
|
||||
export const ID = Integration.ID
|
||||
export type ID = Integration.ID
|
||||
|
||||
export const MethodID = IntegrationSchema.MethodID
|
||||
export type MethodID = IntegrationSchema.MethodID
|
||||
export const MethodID = Integration.MethodID
|
||||
export type MethodID = Integration.MethodID
|
||||
|
||||
export const AttemptID = Schema.String.pipe(
|
||||
Schema.brand("Integration.AttemptID"),
|
||||
@@ -34,64 +34,29 @@ export const AttemptID = Schema.String.pipe(
|
||||
)
|
||||
export type AttemptID = typeof AttemptID.Type
|
||||
|
||||
export const When = Schema.Struct({
|
||||
key: Schema.String,
|
||||
op: Schema.Literals(["eq", "neq"]),
|
||||
value: Schema.String,
|
||||
}).annotate({ identifier: "Integration.When" })
|
||||
export type When = typeof When.Type
|
||||
export const When = Integration.When
|
||||
export type When = Integration.When
|
||||
|
||||
export const TextPrompt = Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
key: Schema.String,
|
||||
message: Schema.String,
|
||||
placeholder: Schema.optional(Schema.String),
|
||||
when: Schema.optional(When),
|
||||
}).annotate({ identifier: "Integration.TextPrompt" })
|
||||
export type TextPrompt = typeof TextPrompt.Type
|
||||
export const TextPrompt = Integration.TextPrompt
|
||||
export type TextPrompt = Integration.TextPrompt
|
||||
|
||||
export const SelectPrompt = Schema.Struct({
|
||||
type: Schema.Literal("select"),
|
||||
key: Schema.String,
|
||||
message: Schema.String,
|
||||
options: Schema.mutable(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
label: Schema.String,
|
||||
value: Schema.String,
|
||||
hint: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
),
|
||||
when: Schema.optional(When),
|
||||
}).annotate({ identifier: "Integration.SelectPrompt" })
|
||||
export type SelectPrompt = typeof SelectPrompt.Type
|
||||
export const SelectPrompt = Integration.SelectPrompt
|
||||
export type SelectPrompt = Integration.SelectPrompt
|
||||
|
||||
export const Prompt = Schema.Union([TextPrompt, SelectPrompt]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type Prompt = typeof Prompt.Type
|
||||
export const Prompt = Integration.Prompt
|
||||
export type Prompt = Integration.Prompt
|
||||
|
||||
export const OAuthMethod = Schema.Struct({
|
||||
id: MethodID,
|
||||
type: Schema.Literal("oauth"),
|
||||
label: Schema.String,
|
||||
prompts: Schema.optional(Schema.mutable(Schema.Array(Prompt))),
|
||||
}).annotate({ identifier: "Integration.OAuthMethod" })
|
||||
export type OAuthMethod = typeof OAuthMethod.Type
|
||||
export const OAuthMethod = Integration.OAuthMethod
|
||||
export type OAuthMethod = Integration.OAuthMethod
|
||||
|
||||
export const KeyMethod = Schema.Struct({
|
||||
type: Schema.Literal("key"),
|
||||
label: Schema.optional(Schema.String),
|
||||
}).annotate({ identifier: "Integration.KeyMethod" })
|
||||
export type KeyMethod = typeof KeyMethod.Type
|
||||
export const KeyMethod = Integration.KeyMethod
|
||||
export type KeyMethod = Integration.KeyMethod
|
||||
|
||||
export const EnvMethod = Schema.Struct({
|
||||
type: Schema.Literal("env"),
|
||||
names: Schema.mutable(Schema.Array(Schema.String)),
|
||||
}).annotate({ identifier: "Integration.EnvMethod" })
|
||||
export type EnvMethod = typeof EnvMethod.Type
|
||||
export const EnvMethod = Integration.EnvMethod
|
||||
export type EnvMethod = Integration.EnvMethod
|
||||
|
||||
export const Method = Schema.Union([OAuthMethod, KeyMethod, EnvMethod]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type Method = typeof Method.Type
|
||||
export const Method = Integration.Method
|
||||
export type Method = Integration.Method
|
||||
|
||||
export class Info extends Schema.Class<Info>("Integration.Info")({
|
||||
id: ID,
|
||||
@@ -100,7 +65,8 @@ export class Info extends Schema.Class<Info>("Integration.Info")({
|
||||
connections: Schema.mutable(Schema.Array(IntegrationConnection.Info)),
|
||||
}) {}
|
||||
|
||||
export type Inputs = Readonly<{ [key: string]: string }>
|
||||
export const Inputs = Integration.Inputs
|
||||
export type Inputs = Integration.Inputs
|
||||
|
||||
export type OAuthAuthorization = {
|
||||
readonly url: string
|
||||
@@ -108,11 +74,11 @@ export type OAuthAuthorization = {
|
||||
} & (
|
||||
| {
|
||||
readonly mode: "auto"
|
||||
readonly callback: Effect.Effect<Credential.Value, unknown>
|
||||
readonly callback: Effect.Effect<Credential.OAuth, unknown>
|
||||
}
|
||||
| {
|
||||
readonly mode: "code"
|
||||
readonly callback: (code: string) => Effect.Effect<Credential.Value, unknown>
|
||||
readonly callback: (code: string) => Effect.Effect<Credential.OAuth, unknown>
|
||||
}
|
||||
)
|
||||
|
||||
@@ -121,6 +87,7 @@ export interface OAuthImplementation {
|
||||
readonly method: OAuthMethod
|
||||
readonly authorize: (inputs: Inputs) => Effect.Effect<OAuthAuthorization, unknown, Scope.Scope>
|
||||
readonly refresh?: (credential: Credential.OAuth) => Effect.Effect<Credential.OAuth, unknown>
|
||||
readonly label?: (credential: Credential.OAuth) => string | undefined
|
||||
}
|
||||
|
||||
export interface KeyImplementation {
|
||||
@@ -135,10 +102,6 @@ export interface EnvImplementation {
|
||||
|
||||
export type Implementation = OAuthImplementation | KeyImplementation | EnvImplementation
|
||||
|
||||
function isOAuthImplementation(implementation: Implementation): implementation is OAuthImplementation {
|
||||
return implementation.method.type === "oauth"
|
||||
}
|
||||
|
||||
export class Attempt extends Schema.Class<Attempt>("Integration.Attempt")({
|
||||
attemptID: AttemptID,
|
||||
url: Schema.String,
|
||||
@@ -178,12 +141,14 @@ export const Event = {
|
||||
type: "integration.updated",
|
||||
schema: {},
|
||||
}),
|
||||
ConnectionUpdated: EventV2.define({
|
||||
type: "integration.connection.updated",
|
||||
schema: { integrationID: ID },
|
||||
}),
|
||||
}
|
||||
|
||||
export type Ref = {
|
||||
id: ID
|
||||
name: string
|
||||
}
|
||||
export const Ref = Integration.Ref
|
||||
export type Ref = Integration.Ref
|
||||
|
||||
type Entry = {
|
||||
ref: Types.DeepMutable<Ref>
|
||||
@@ -215,7 +180,11 @@ export interface Interface extends State.Transformable<Draft> {
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
readonly connection: {
|
||||
/** Returns the active connection for one integration. */
|
||||
readonly forIntegration: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined>
|
||||
readonly active: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined>
|
||||
/** Resolves a connection into usable credential material. */
|
||||
readonly resolve: (
|
||||
connection: IntegrationConnection.Info,
|
||||
) => Effect.Effect<Credential.Value | undefined, AuthorizationError>
|
||||
/** Runs a key method and stores the resulting credential. */
|
||||
readonly key: (input: {
|
||||
/** Integration receiving the credential. */
|
||||
@@ -385,7 +354,7 @@ export const locationLayer = Layer.effect(
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.Value, unknown>) {
|
||||
const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.OAuth, unknown>) {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const result = yield* SynchronizedRef.modify(attempts, (current) => {
|
||||
const attempt = current.get(attemptID)
|
||||
@@ -397,14 +366,13 @@ export const locationLayer = Layer.effect(
|
||||
})
|
||||
if (!result) return
|
||||
if (Exit.isSuccess(exit)) {
|
||||
const implementation = state.get().integrations.get(result.integrationID)?.implementations.get(result.methodID)
|
||||
yield* credentials.create({
|
||||
integrationID: result.integrationID,
|
||||
label: result.label,
|
||||
value:
|
||||
exit.value.type === "oauth"
|
||||
? new Credential.OAuth({ ...exit.value, methodID: result.methodID })
|
||||
: exit.value,
|
||||
label: result.label ?? implementation?.label?.(exit.value),
|
||||
value: exit.value,
|
||||
})
|
||||
yield* events.publish(Event.ConnectionUpdated, { integrationID: result.integrationID })
|
||||
yield* events.publish(Event.Updated, {})
|
||||
}
|
||||
yield* close(result.scope)
|
||||
@@ -445,10 +413,29 @@ export const locationLayer = Layer.effect(
|
||||
).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
}),
|
||||
connection: {
|
||||
forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) {
|
||||
active: Effect.fn("Integration.connection.active")(function* (id) {
|
||||
const entry = state.get().integrations.get(id)
|
||||
return resolveConnections(entry, yield* credentials.list(id))[0]
|
||||
}),
|
||||
resolve: Effect.fn("Integration.connection.resolve")(function* (connection) {
|
||||
if (connection.type === "env") {
|
||||
const key = process.env[connection.name]
|
||||
return key ? Credential.Key.make({ type: "key", key }) : undefined
|
||||
}
|
||||
const credential = yield* credentials.get(connection.id)
|
||||
if (!credential) return undefined
|
||||
if (credential.value.type === "key") return credential.value
|
||||
const implementation = state
|
||||
.get()
|
||||
.integrations.get(credential.integrationID)
|
||||
?.implementations.get(credential.value.methodID)
|
||||
if (!implementation?.refresh) return credential.value
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
if (credential.value.expires > now + Duration.toMillis(Duration.minutes(5))) return credential.value
|
||||
const value = yield* authorize(implementation.refresh(credential.value))
|
||||
yield* credentials.update(credential.id, { value })
|
||||
return value
|
||||
}),
|
||||
key: Effect.fn("Integration.connection.key")(function* (input) {
|
||||
const method = state
|
||||
.get()
|
||||
@@ -458,8 +445,9 @@ export const locationLayer = Layer.effect(
|
||||
yield* credentials.create({
|
||||
integrationID: input.integrationID,
|
||||
label: input.label,
|
||||
value: new Credential.Key({ type: "key", key: input.key }),
|
||||
value: Credential.Key.make({ type: "key", key: input.key }),
|
||||
})
|
||||
yield* events.publish(Event.ConnectionUpdated, { integrationID: input.integrationID })
|
||||
yield* events.publish(Event.Updated, {})
|
||||
}),
|
||||
oauth: Effect.fn("Integration.connection.oauth")(function* (input) {
|
||||
@@ -503,11 +491,19 @@ export const locationLayer = Layer.effect(
|
||||
})
|
||||
}),
|
||||
update: Effect.fn("Integration.connection.update")(function* (credentialID, updates) {
|
||||
const credential = yield* credentials.get(credentialID)
|
||||
yield* credentials.update(credentialID, updates)
|
||||
if (credential) {
|
||||
yield* events.publish(Event.ConnectionUpdated, { integrationID: credential.integrationID })
|
||||
}
|
||||
yield* events.publish(Event.Updated, {})
|
||||
}),
|
||||
remove: Effect.fn("Integration.connection.remove")(function* (credentialID) {
|
||||
const credential = yield* credentials.get(credentialID)
|
||||
yield* credentials.remove(credentialID)
|
||||
if (credential) {
|
||||
yield* events.publish(Event.ConnectionUpdated, { integrationID: credential.integrationID })
|
||||
}
|
||||
yield* events.publish(Event.Updated, {})
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
export * as IntegrationConnection from "./connection"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Credential } from "../credential"
|
||||
import { Connection } from "@opencode-ai/schema/connection"
|
||||
|
||||
export const CredentialInfo = Schema.Struct({
|
||||
type: Schema.Literal("credential"),
|
||||
id: Credential.ID,
|
||||
label: Schema.String,
|
||||
}).annotate({ identifier: "Connection.CredentialInfo" })
|
||||
export type CredentialInfo = typeof CredentialInfo.Type
|
||||
export const CredentialInfo = Connection.CredentialInfo
|
||||
export type CredentialInfo = Connection.CredentialInfo
|
||||
|
||||
export const EnvInfo = Schema.Struct({
|
||||
type: Schema.Literal("env"),
|
||||
name: Schema.String,
|
||||
}).annotate({ identifier: "Connection.EnvInfo" })
|
||||
export type EnvInfo = typeof EnvInfo.Type
|
||||
export const EnvInfo = Connection.EnvInfo
|
||||
export type EnvInfo = Connection.EnvInfo
|
||||
|
||||
export const Info = Schema.Union([CredentialInfo, EnvInfo])
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
.annotate({ identifier: "Connection.Info" })
|
||||
export type Info = typeof Info.Type
|
||||
export const Info = Connection.Info
|
||||
export type Info = Connection.Info
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
export * as IntegrationSchema from "./schema"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const ID = Schema.String.pipe(Schema.brand("Integration.ID"))
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export const MethodID = Schema.String.pipe(Schema.brand("Integration.MethodID"))
|
||||
export type MethodID = typeof MethodID.Type
|
||||
@@ -1,14 +1,12 @@
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Ref } from "@opencode-ai/schema/location"
|
||||
import { Project } from "./project"
|
||||
import { AbsolutePath, optionalOmitUndefined } from "./schema"
|
||||
import { WorkspaceV2 } from "./workspace"
|
||||
|
||||
export * as Location from "./location"
|
||||
|
||||
export class Ref extends Schema.Class<Ref>("Location.Ref")({
|
||||
directory: AbsolutePath,
|
||||
workspaceID: Schema.optional(WorkspaceV2.ID).pipe(Schema.withConstructorDefault(Effect.succeed(undefined))),
|
||||
}) {}
|
||||
export { Ref }
|
||||
|
||||
export class Info extends Schema.Class<Info>("Location.Info")({
|
||||
directory: AbsolutePath,
|
||||
|
||||
@@ -1,34 +1,12 @@
|
||||
export * as ModelRequest from "./model-request"
|
||||
|
||||
import { Effect, Schema } from "effect"
|
||||
import { ModelRequest } from "@opencode-ai/schema/model-request"
|
||||
|
||||
export const Generation = Schema.Struct({
|
||||
maxTokens: Schema.Number.pipe(Schema.optional),
|
||||
temperature: Schema.Number.pipe(Schema.optional),
|
||||
topP: Schema.Number.pipe(Schema.optional),
|
||||
topK: Schema.Number.pipe(Schema.optional),
|
||||
frequencyPenalty: Schema.Number.pipe(Schema.optional),
|
||||
presencePenalty: Schema.Number.pipe(Schema.optional),
|
||||
seed: Schema.Number.pipe(Schema.optional),
|
||||
stop: Schema.String.pipe(Schema.Array, Schema.mutable, Schema.optional),
|
||||
})
|
||||
export type Generation = typeof Generation.Type
|
||||
export const Generation = ModelRequest.Generation
|
||||
export type Generation = ModelRequest.Generation
|
||||
|
||||
export const Request = Schema.Struct({
|
||||
headers: Schema.Record(Schema.String, Schema.String),
|
||||
body: Schema.Record(Schema.String, Schema.Any),
|
||||
generation: Generation.pipe(
|
||||
Schema.optionalKey,
|
||||
Schema.withConstructorDefault(Effect.succeed({})),
|
||||
Schema.withDecodingDefaultKey(Effect.succeed({})),
|
||||
),
|
||||
options: Schema.Record(Schema.String, Schema.Any).pipe(
|
||||
Schema.optionalKey,
|
||||
Schema.withConstructorDefault(Effect.succeed({})),
|
||||
Schema.withDecodingDefaultKey(Effect.succeed({})),
|
||||
),
|
||||
})
|
||||
export type Request = typeof Request.Type
|
||||
export const Request = ModelRequest.Request
|
||||
export type Request = ModelRequest.Request
|
||||
|
||||
interface MutableRequest {
|
||||
headers: Record<string, string>
|
||||
|
||||
+14
-103
@@ -1,119 +1,30 @@
|
||||
import { Schema, Types } from "effect"
|
||||
import { Types } from "effect"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { ProviderV2 } from "./provider"
|
||||
import { ModelRequest } from "./model-request"
|
||||
|
||||
export const ID = Schema.String.pipe(Schema.brand("ModelV2.ID"))
|
||||
export const ID = Model.ID
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export const VariantID = Schema.String.pipe(Schema.brand("VariantID"))
|
||||
export const VariantID = Model.VariantID
|
||||
export type VariantID = typeof VariantID.Type
|
||||
|
||||
// Grouping of models, eg claude opus, claude sonnet
|
||||
export const Family = Schema.String.pipe(Schema.brand("Family"))
|
||||
export type Family = typeof Family.Type
|
||||
export const Family = Model.Family
|
||||
export type Family = Model.Family
|
||||
|
||||
export const Capabilities = Schema.Struct({
|
||||
tools: Schema.Boolean,
|
||||
// mime patterns, image, audio, video/*, text/*
|
||||
input: Schema.String.pipe(Schema.Array, Schema.mutable),
|
||||
output: Schema.String.pipe(Schema.Array, Schema.mutable),
|
||||
})
|
||||
export type Capabilities = typeof Capabilities.Type
|
||||
export const Capabilities = Model.Capabilities
|
||||
export type Capabilities = Model.Capabilities
|
||||
|
||||
export const Cost = Schema.Struct({
|
||||
tier: Schema.Struct({
|
||||
type: Schema.Literal("context"),
|
||||
size: Schema.Int,
|
||||
}).pipe(Schema.optional),
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
cache: Schema.Struct({
|
||||
read: Schema.Finite,
|
||||
write: Schema.Finite,
|
||||
}),
|
||||
})
|
||||
export const Cost = Model.Cost
|
||||
|
||||
export const Ref = Schema.Struct({
|
||||
id: ID,
|
||||
providerID: ProviderV2.ID,
|
||||
variant: VariantID.pipe(Schema.optional),
|
||||
})
|
||||
export const Ref = Model.Ref
|
||||
export type Ref = typeof Ref.Type
|
||||
|
||||
export const Api = Schema.Union([
|
||||
Schema.Struct({
|
||||
id: ID,
|
||||
...ProviderV2.AISDK.fields,
|
||||
}),
|
||||
Schema.Struct({
|
||||
id: ID,
|
||||
...ProviderV2.Native.fields,
|
||||
}),
|
||||
]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type Api = typeof Api.Type
|
||||
export const Api = Model.Api
|
||||
export type Api = Model.Api
|
||||
|
||||
export class Info extends Schema.Class<Info>("ModelV2.Info")({
|
||||
id: ID,
|
||||
providerID: ProviderV2.ID,
|
||||
family: Family.pipe(Schema.optional),
|
||||
name: Schema.String,
|
||||
api: Api,
|
||||
capabilities: Capabilities,
|
||||
request: Schema.Struct({
|
||||
...ModelRequest.Request.fields,
|
||||
variant: Schema.String.pipe(Schema.optional),
|
||||
}),
|
||||
variants: Schema.Struct({
|
||||
id: VariantID,
|
||||
...ModelRequest.Request.fields,
|
||||
}).pipe(Schema.Array, Schema.mutable),
|
||||
time: Schema.Struct({
|
||||
released: Schema.Finite,
|
||||
}),
|
||||
cost: Cost.pipe(Schema.Array, Schema.mutable),
|
||||
status: Schema.Literals(["alpha", "beta", "deprecated", "active"]),
|
||||
enabled: Schema.Boolean,
|
||||
limit: Schema.Struct({
|
||||
context: Schema.Int,
|
||||
input: Schema.Int.pipe(Schema.optional),
|
||||
output: Schema.Int,
|
||||
}),
|
||||
}) {
|
||||
static empty(providerID: ProviderV2.ID, modelID: ID): Info {
|
||||
return new Info({
|
||||
id: modelID,
|
||||
providerID,
|
||||
name: modelID,
|
||||
api: {
|
||||
id: modelID,
|
||||
type: "native",
|
||||
settings: {},
|
||||
},
|
||||
capabilities: {
|
||||
tools: false,
|
||||
input: [],
|
||||
output: [],
|
||||
},
|
||||
request: {
|
||||
headers: {},
|
||||
body: {},
|
||||
generation: {},
|
||||
options: {},
|
||||
},
|
||||
variants: [],
|
||||
time: {
|
||||
released: 0,
|
||||
},
|
||||
cost: [],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: {
|
||||
context: 0,
|
||||
output: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
export const Info = Model.Info
|
||||
export type Info = Model.Info
|
||||
|
||||
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & {
|
||||
api: ProviderV2.MutableApi<Api>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as PermissionV2 from "./permission"
|
||||
|
||||
import { Context, Deferred, Effect as EffectRuntime, Layer, Schema } from "effect"
|
||||
import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { EventV2 } from "./event"
|
||||
import { Location } from "./location"
|
||||
import { AgentV2 } from "./agent"
|
||||
@@ -9,14 +10,10 @@ import { SessionStore } from "./session/store"
|
||||
import { withStatics } from "./schema"
|
||||
import { Identifier } from "./util/identifier"
|
||||
import { Wildcard } from "./util/wildcard"
|
||||
import { PermissionSchema } from "./permission/schema"
|
||||
import { PermissionSaved } from "./permission/saved"
|
||||
|
||||
export { Effect, Rule, Ruleset } from "./permission/schema"
|
||||
type Effect = PermissionSchema.Effect
|
||||
type Rule = PermissionSchema.Rule
|
||||
type Ruleset = PermissionSchema.Ruleset
|
||||
const missingAgentPermissions: Ruleset = [{ action: "*", resource: "*", effect: "deny" }]
|
||||
export { Effect, Rule, Ruleset } from "@opencode-ai/schema/permission"
|
||||
const missingAgentPermissions: Permission.Ruleset = [{ action: "*", resource: "*", effect: "deny" }]
|
||||
|
||||
export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe(
|
||||
Schema.brand("PermissionV2.ID"),
|
||||
@@ -67,7 +64,7 @@ export type ReplyInput = typeof ReplyInput.Type
|
||||
|
||||
export const AskResult = Schema.Struct({
|
||||
id: ID,
|
||||
effect: PermissionSchema.Effect,
|
||||
effect: Permission.Effect,
|
||||
}).annotate({ identifier: "PermissionV2.AskResult" })
|
||||
export type AskResult = typeof AskResult.Type
|
||||
|
||||
@@ -90,7 +87,7 @@ export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("P
|
||||
}) {}
|
||||
|
||||
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionV2.DeniedError", {
|
||||
rules: PermissionSchema.Ruleset,
|
||||
rules: Permission.Ruleset,
|
||||
}) {}
|
||||
|
||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("PermissionV2.NotFoundError", {
|
||||
@@ -99,7 +96,7 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Per
|
||||
|
||||
export type Error = DeniedError | RejectedError | CorrectedError
|
||||
|
||||
export function evaluate(action: string, resource: string, ...rulesets: Ruleset[]): Rule {
|
||||
export function evaluate(action: string, resource: string, ...rulesets: Permission.Ruleset[]): Permission.Rule {
|
||||
return (
|
||||
rulesets
|
||||
.flat()
|
||||
@@ -111,7 +108,7 @@ export function evaluate(action: string, resource: string, ...rulesets: Ruleset[
|
||||
)
|
||||
}
|
||||
|
||||
export function merge(...rulesets: Ruleset[]): Ruleset {
|
||||
export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
|
||||
return rulesets.flat()
|
||||
}
|
||||
|
||||
@@ -156,7 +153,7 @@ export const layer = Layer.effect(
|
||||
|
||||
const savedRules = EffectRuntime.fnUntraced(function* () {
|
||||
return (yield* saved.list({ projectID: location.project.id })).map(
|
||||
(item): Rule => ({ action: item.action, resource: item.resource, effect: "allow" }),
|
||||
(item): Permission.Rule => ({ action: item.action, resource: item.resource, effect: "allow" }),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -170,11 +167,11 @@ export const layer = Layer.effect(
|
||||
return agent?.permissions ?? missingAgentPermissions
|
||||
})
|
||||
|
||||
function denied(input: AssertInput, rules: Ruleset) {
|
||||
function denied(input: AssertInput, rules: Permission.Ruleset) {
|
||||
return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny")
|
||||
}
|
||||
|
||||
function relevant(input: AssertInput, rules: Ruleset) {
|
||||
function relevant(input: AssertInput, rules: Permission.Ruleset) {
|
||||
return rules.filter((rule) => Wildcard.match(input.action, rule.action))
|
||||
}
|
||||
|
||||
@@ -183,7 +180,7 @@ export const layer = Layer.effect(
|
||||
if (denied(input, rules)) return { effect: "deny" as const, rules }
|
||||
const all = [...rules, ...(yield* savedRules())]
|
||||
const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect)
|
||||
const effect: Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow"
|
||||
const effect: Permission.Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow"
|
||||
return { effect, rules: all }
|
||||
})
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user