mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-16 01:19:19 -04:00
Compare commits
56 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 |
@@ -2,4 +2,4 @@ blank_issues_enabled: false
|
|||||||
contact_links:
|
contact_links:
|
||||||
- name: 💬 Discord Community
|
- name: 💬 Discord Community
|
||||||
url: https://discord.gg/opencode
|
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/effect-sqlite-node": "workspace:*",
|
||||||
"@opencode-ai/llm": "workspace:*",
|
"@opencode-ai/llm": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@openrouter/ai-sdk-provider": "2.9.0",
|
"@openrouter/ai-sdk-provider": "2.9.0",
|
||||||
"@opentelemetry/api": "1.9.0",
|
"@opentelemetry/api": "1.9.0",
|
||||||
"@opentelemetry/context-async-hooks": "2.6.1",
|
"@opentelemetry/context-async-hooks": "2.6.1",
|
||||||
@@ -480,6 +481,7 @@
|
|||||||
"name": "@opencode-ai/llm",
|
"name": "@opencode-ai/llm",
|
||||||
"version": "1.17.9",
|
"version": "1.17.9",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@smithy/eventstream-codec": "4.2.14",
|
"@smithy/eventstream-codec": "4.2.14",
|
||||||
"@smithy/util-utf8": "4.2.2",
|
"@smithy/util-utf8": "4.2.2",
|
||||||
"aws4fetch": "1.0.20",
|
"aws4fetch": "1.0.20",
|
||||||
@@ -641,9 +643,9 @@
|
|||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@opentui/core": ">=0.3.4",
|
"@opentui/core": ">=0.4.2",
|
||||||
"@opentui/keymap": ">=0.3.4",
|
"@opentui/keymap": ">=0.4.2",
|
||||||
"@opentui/solid": ">=0.3.4",
|
"@opentui/solid": ">=0.4.2",
|
||||||
},
|
},
|
||||||
"optionalPeers": [
|
"optionalPeers": [
|
||||||
"@opentui/core",
|
"@opentui/core",
|
||||||
@@ -651,6 +653,17 @@
|
|||||||
"@opentui/solid",
|
"@opentui/solid",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
"packages/schema": {
|
||||||
|
"name": "@opencode-ai/schema",
|
||||||
|
"dependencies": {
|
||||||
|
"effect": "catalog:",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tsconfig/bun": "catalog:",
|
||||||
|
"@types/bun": "catalog:",
|
||||||
|
"@typescript/native-preview": "catalog:",
|
||||||
|
},
|
||||||
|
},
|
||||||
"packages/script": {
|
"packages/script": {
|
||||||
"name": "@opencode-ai/script",
|
"name": "@opencode-ai/script",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -947,9 +960,9 @@
|
|||||||
"@npmcli/arborist": "9.4.0",
|
"@npmcli/arborist": "9.4.0",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||||
"@opentui/core": "0.3.4",
|
"@opentui/core": "0.4.2",
|
||||||
"@opentui/keymap": "0.3.4",
|
"@opentui/keymap": "0.4.2",
|
||||||
"@opentui/solid": "0.3.4",
|
"@opentui/solid": "0.4.2",
|
||||||
"@pierre/diffs": "1.2.10",
|
"@pierre/diffs": "1.2.10",
|
||||||
"@playwright/test": "1.59.1",
|
"@playwright/test": "1.59.1",
|
||||||
"@sentry/solid": "10.36.0",
|
"@sentry/solid": "10.36.0",
|
||||||
@@ -1804,6 +1817,8 @@
|
|||||||
|
|
||||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
|
"@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/script": ["@opencode-ai/script@workspace:packages/script"],
|
||||||
|
|
||||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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": ["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=="],
|
"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,
|
SECRET.UpstashRedisRestToken,
|
||||||
AUTH_API_URL,
|
AUTH_API_URL,
|
||||||
STRIPE_WEBHOOK_SECRET,
|
STRIPE_WEBHOOK_SECRET,
|
||||||
|
SECRET.SupportApiKey,
|
||||||
DISCORD_INCIDENT_WEBHOOK_URL,
|
DISCORD_INCIDENT_WEBHOOK_URL,
|
||||||
SECRET.HoneycombWebhookSecret,
|
SECRET.HoneycombWebhookSecret,
|
||||||
STRIPE_SECRET_KEY,
|
STRIPE_SECRET_KEY,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ new sst.cloudflare.x.SolidStart("Teams", {
|
|||||||
domain: shortDomain,
|
domain: shortDomain,
|
||||||
path: "packages/enterprise",
|
path: "packages/enterprise",
|
||||||
buildCommand: "bun run build:cloudflare",
|
buildCommand: "bun run build:cloudflare",
|
||||||
|
link: [SECRET.SupportApiKey],
|
||||||
environment: {
|
environment: {
|
||||||
OPENCODE_STORAGE_ADAPTER: "r2",
|
OPENCODE_STORAGE_ADAPTER: "r2",
|
||||||
OPENCODE_STORAGE_ACCOUNT_ID: sst.cloudflare.DEFAULT_ACCOUNT_ID,
|
OPENCODE_STORAGE_ACCOUNT_ID: sst.cloudflare.DEFAULT_ACCOUNT_ID,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const SECRET = {
|
|||||||
R2SecretKey: new sst.Secret("R2SecretKey", "unknown"),
|
R2SecretKey: new sst.Secret("R2SecretKey", "unknown"),
|
||||||
HoneycombApiKey: new sst.Secret("HONEYCOMB_API_KEY"),
|
HoneycombApiKey: new sst.Secret("HONEYCOMB_API_KEY"),
|
||||||
HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }),
|
HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }),
|
||||||
|
SupportApiKey: new sst.Secret("SUPPORT_API_KEY"),
|
||||||
UpstashRedisRestUrl: new sst.Secret("UpstashRedisRestUrl"),
|
UpstashRedisRestUrl: new sst.Secret("UpstashRedisRestUrl"),
|
||||||
UpstashRedisRestToken: new sst.Secret("UpstashRedisRestToken"),
|
UpstashRedisRestToken: new sst.Secret("UpstashRedisRestToken"),
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-oWSGu+SP66Aquy/0Vaq7Bgp8404ZdOWbQX+O7h3jxHU=",
|
"x86_64-linux": "sha256-JJnJVqP2+NiiRVoTKXjGD09lPRyfz8YMfx0m3jBaUzU=",
|
||||||
"aarch64-linux": "sha256-UsS0+c+GwtIukmWwQeFbY/3Oaz3t4Q7C6cFMGkmlyAY=",
|
"aarch64-linux": "sha256-tY3/IA+iZbOQQGxrcHMvQ1xqByskZyVI+4LZkU0wVjs=",
|
||||||
"aarch64-darwin": "sha256-CArz92ewPmXO+ORFCBkCH8LzMpU/DjyaO4ic7QL0UpI=",
|
"aarch64-darwin": "sha256-9qMRrQKKOgQuOcAgAq8oZacVFD7G4nbAfZawmCRuJdY=",
|
||||||
"x86_64-darwin": "sha256-rhnz9gmG6L06wIzfMhTaXDDEf6IbMD32CavqwXoqcUs="
|
"x86_64-darwin": "sha256-dR2VGxDLoAPEmk8SsEF4dhlPBcb9ubztAuVfwxuAD4M="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -39,9 +39,9 @@
|
|||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@hono/standard-validator": "0.2.0",
|
"@hono/standard-validator": "0.2.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"@opentui/core": "0.3.4",
|
"@opentui/core": "0.4.2",
|
||||||
"@opentui/keymap": "0.3.4",
|
"@opentui/keymap": "0.4.2",
|
||||||
"@opentui/solid": "0.3.4",
|
"@opentui/solid": "0.4.2",
|
||||||
"@tanstack/solid-virtual": "3.13.28",
|
"@tanstack/solid-virtual": "3.13.28",
|
||||||
"@shikijs/stream": "4.2.0",
|
"@shikijs/stream": "4.2.0",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ bun run test:bench
|
|||||||
The suite contains:
|
The suite contains:
|
||||||
|
|
||||||
- cold and hot session-tab timing
|
- 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
|
- cached session repaint and mutation tracing
|
||||||
- streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics
|
- 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))
|
report(compressCachedRepaintTrace(result))
|
||||||
expect(result.samples.length).toBeGreaterThan(0)
|
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 sourceID = "ses_smoke_source"
|
||||||
const targetID = "ses_smoke_target"
|
const targetID = "ses_smoke_target"
|
||||||
|
const childID = "ses_smoke_child"
|
||||||
const directory = "C:/OpenCode/SmokeProject"
|
const directory = "C:/OpenCode/SmokeProject"
|
||||||
const projectID = "proj_smoke_timeline"
|
const projectID = "proj_smoke_timeline"
|
||||||
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||||
@@ -126,9 +127,11 @@ function toolPart(
|
|||||||
tool: string,
|
tool: string,
|
||||||
input: Record<string, unknown>,
|
input: Record<string, unknown>,
|
||||||
outputLength = 160,
|
outputLength = 160,
|
||||||
|
metadataOverride?: Record<string, unknown>,
|
||||||
): MessagePart {
|
): MessagePart {
|
||||||
const metadata =
|
const metadata =
|
||||||
tool === "apply_patch"
|
metadataOverride ??
|
||||||
|
(tool === "apply_patch"
|
||||||
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
|
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
|
||||||
: tool === "edit" || tool === "write"
|
: tool === "edit" || tool === "write"
|
||||||
? {
|
? {
|
||||||
@@ -138,7 +141,7 @@ function toolPart(
|
|||||||
}
|
}
|
||||||
: tool === "question"
|
: tool === "question"
|
||||||
? { answers: [["Proceed"], ["Keep sample output"]] }
|
? { answers: [["Proceed"], ["Keep sample output"]] }
|
||||||
: {}
|
: {})
|
||||||
return {
|
return {
|
||||||
id: id(`prt_tool_${tool}_${partIndex}`, index),
|
id: id(`prt_tool_${tool}_${partIndex}`, index),
|
||||||
type: "tool",
|
type: "tool",
|
||||||
@@ -244,7 +247,25 @@ function turn(index: number): Message[] {
|
|||||||
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
|
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
|
||||||
const sourceMessages = Array.from({ length: 12 }, (_, index) => [
|
const sourceMessages = Array.from({ length: 12 }, (_, index) => [
|
||||||
userMessage(sourceID, index + 1000, 120),
|
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()
|
]).flat()
|
||||||
|
|
||||||
function renderable(part: MessagePart) {
|
function renderable(part: MessagePart) {
|
||||||
@@ -298,19 +319,32 @@ export const fixture = {
|
|||||||
version: "dev",
|
version: "dev",
|
||||||
time: { created: 1700000001000, updated: 1700000001000 },
|
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,
|
sourceID,
|
||||||
targetID,
|
targetID,
|
||||||
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages },
|
childID,
|
||||||
|
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages, [childID]: childMessages },
|
||||||
expected: {
|
expected: {
|
||||||
sourceTitle: "Uncommitted changes inquiry",
|
sourceTitle: "Uncommitted changes inquiry",
|
||||||
targetTitle: "Example Game: sample jump movement & sample physics analysis",
|
targetTitle: "Example Game: sample jump movement & sample physics analysis",
|
||||||
|
childTitle: "Inspect child navigation",
|
||||||
sourceMessageIDs: sourceMessages
|
sourceMessageIDs: sourceMessages
|
||||||
.filter((message) => message.info.role === "user")
|
.filter((message) => message.info.role === "user")
|
||||||
.map((message) => message.info.id),
|
.map((message) => message.info.id),
|
||||||
targetMessageIDs: targetMessages
|
targetMessageIDs: targetMessages
|
||||||
.filter((message) => message.info.role === "user")
|
.filter((message) => message.info.role === "user")
|
||||||
.map((message) => message.info.id),
|
.map((message) => message.info.id),
|
||||||
|
childMessageIDs: childMessages.filter((message) => message.info.role === "user").map((message) => message.info.id),
|
||||||
targetPartIDs: targetMessages.flatMap((message) =>
|
targetPartIDs: targetMessages.flatMap((message) =>
|
||||||
orderedParts(message)
|
orderedParts(message)
|
||||||
.filter(renderable)
|
.filter(renderable)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Page } from "@playwright/test"
|
import type { Page } from "@playwright/test"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
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) {
|
export async function installTimelineSettings(page: Page) {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
@@ -9,6 +9,7 @@ export async function installTimelineSettings(page: Page) {
|
|||||||
"settings.v3",
|
"settings.v3",
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
general: {
|
general: {
|
||||||
|
newLayoutDesigns: true,
|
||||||
editToolPartsExpanded: true,
|
editToolPartsExpanded: true,
|
||||||
shellToolPartsExpanded: true,
|
shellToolPartsExpanded: true,
|
||||||
showReasoningSummaries: 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, {
|
return mockOpenCodeServer(page, {
|
||||||
sessions: fixture.sessions,
|
sessions: fixture.sessions,
|
||||||
provider: fixture.provider,
|
provider: fixture.provider,
|
||||||
directory: fixture.directory,
|
directory: fixture.directory,
|
||||||
project: fixture.project,
|
project: fixture.project,
|
||||||
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
|
pageMessages,
|
||||||
|
onMessages: input?.onMessages,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function installStressSessionTabs(page: Page) {
|
export async function installStressSessionTabs(page: Page, input?: { draftID?: string; sessionIDs?: string[] }) {
|
||||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
const server = stressServer()
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ directory, sourceID, targetID, dirBase64, server }) => {
|
({ directory, sessionIDs, dirBase64, server, draftID }) => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.global.dat:server",
|
"opencode.global.dat:server",
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -42,26 +47,35 @@ export async function installStressSessionTabs(page: Page) {
|
|||||||
)
|
)
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.global.dat:tabs",
|
"opencode.global.dat:tabs",
|
||||||
JSON.stringify(
|
JSON.stringify([
|
||||||
[sourceID, targetID].map((sessionId) => ({
|
...sessionIDs.map((sessionId) => ({
|
||||||
type: "session",
|
type: "session",
|
||||||
server,
|
server,
|
||||||
dirBase64,
|
dirBase64,
|
||||||
sessionId,
|
sessionId,
|
||||||
})),
|
})),
|
||||||
),
|
...(draftID ? [{ type: "draft", draftID, server, directory }] : []),
|
||||||
|
]),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
directory: fixture.directory,
|
directory: fixture.directory,
|
||||||
sourceID: fixture.sourceID,
|
sessionIDs: input?.sessionIDs ?? [fixture.sourceID, fixture.targetID],
|
||||||
targetID: fixture.targetID,
|
|
||||||
dirBase64: base64Encode(fixture.directory),
|
dirBase64: base64Encode(fixture.directory),
|
||||||
server,
|
server,
|
||||||
|
draftID: input?.draftID,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stressSessionHref(sessionID: string) {
|
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 page.mouse.move(0, 0)
|
||||||
await lineNumber.hover()
|
await lineNumber.hover()
|
||||||
await expect(comment).toBeVisible({ timeout: 500 })
|
await expect(comment).toBeVisible({ timeout: 500 })
|
||||||
|
await comment.click({ timeout: 500 })
|
||||||
}).toPass()
|
}).toPass()
|
||||||
await comment.click()
|
|
||||||
await expect(review.getByRole("textbox")).toBeVisible()
|
await expect(review.getByRole("textbox")).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,18 @@ test.describe("smoke: session timeline", () => {
|
|||||||
await page.mouse.wheel(0, -120)
|
await page.mouse.wheel(0, -120)
|
||||||
await page.waitForTimeout(20)
|
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 = () =>
|
const positions = () =>
|
||||||
scroller.evaluate((element, keys) => {
|
scroller.evaluate((element, keys) => {
|
||||||
const top = element.getBoundingClientRect().top
|
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) {
|
async function switchTitlebarSession(page: Page, sessionID: string, title: string) {
|
||||||
console.log(process.env)
|
|
||||||
const href = `/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`
|
const href = `/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`
|
||||||
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
||||||
await expect(tab).toBeVisible()
|
await expect(tab).toBeVisible()
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export interface MockServerConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||||
|
const cursors = new Map<string, string>()
|
||||||
|
let nextCursor = 0
|
||||||
const staticRoutes: Record<string, unknown> = {
|
const staticRoutes: Record<string, unknown> = {
|
||||||
"/provider": config.provider,
|
"/provider": config.provider,
|
||||||
"/path": {
|
"/path": {
|
||||||
@@ -68,13 +70,18 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
|
|
||||||
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
||||||
if (messagesMatch) {
|
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" })
|
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
|
||||||
if (config.messageDelay) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
if (config.messageDelay) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||||
const limit = Number(url.searchParams.get("limit") ?? 80)
|
const limit = Number(url.searchParams.get("limit") ?? 80)
|
||||||
const pageData = config.pageMessages(messagesMatch[1], limit, before)
|
const pageData = config.pageMessages(messagesMatch[1], limit, before)
|
||||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
|
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, {})
|
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({
|
return route.fulfill({
|
||||||
status: 200,
|
status,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
headers: {
|
headers: {
|
||||||
"access-control-allow-origin": "*",
|
"access-control-allow-origin": "*",
|
||||||
|
|||||||
+131
-135
@@ -10,7 +10,7 @@ import { Splash } from "@opencode-ai/ui/logo"
|
|||||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||||
import { MetaProvider } from "@solidjs/meta"
|
import { MetaProvider } from "@solidjs/meta"
|
||||||
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
|
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 { Effect } from "effect"
|
||||||
import {
|
import {
|
||||||
type Component,
|
type Component,
|
||||||
@@ -32,7 +32,7 @@ import { CommentsProvider } from "@/context/comments"
|
|||||||
import { FileProvider } from "@/context/file"
|
import { FileProvider } from "@/context/file"
|
||||||
import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk"
|
import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk"
|
||||||
import { ServerSyncProvider } from "@/context/server-sync"
|
import { ServerSyncProvider } from "@/context/server-sync"
|
||||||
import { GlobalProvider } from "@/context/global"
|
import { GlobalProvider, useGlobal } from "@/context/global"
|
||||||
import { HighlightsProvider } from "@/context/highlights"
|
import { HighlightsProvider } from "@/context/highlights"
|
||||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||||
import { LayoutProvider } from "@/context/layout"
|
import { LayoutProvider } from "@/context/layout"
|
||||||
@@ -53,13 +53,12 @@ import { ErrorPage } from "./pages/error"
|
|||||||
import { useCheckServerHealth } from "./utils/server-health"
|
import { useCheckServerHealth } from "./utils/server-health"
|
||||||
import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./utils/session-route"
|
import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./utils/session-route"
|
||||||
|
|
||||||
const LegacyHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.LegacyHome })))
|
import Session from "@/pages/session"
|
||||||
const NewHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.NewHome })))
|
import { NewHome, LegacyHome } from "@/pages/home"
|
||||||
const Session = lazy(() => import("@/pages/session"))
|
|
||||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||||
|
|
||||||
const SessionRoute = Object.assign(
|
const SessionRoute = () => {
|
||||||
() => {
|
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||||
@@ -85,12 +84,86 @@ const SessionRoute = Object.assign(
|
|||||||
<Session />
|
<Session />
|
||||||
</SessionProviders>
|
</SessionProviders>
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
{ preload: Session.preload },
|
|
||||||
)
|
|
||||||
|
|
||||||
const TargetSessionRoute = Object.assign(
|
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 (
|
||||||
|
<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 sdk = useSDK()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
return (
|
return (
|
||||||
@@ -100,9 +173,7 @@ const TargetSessionRoute = Object.assign(
|
|||||||
</SessionProviders>
|
</SessionProviders>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
{ preload: Session.preload },
|
|
||||||
)
|
|
||||||
|
|
||||||
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
// 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/
|
// 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() {
|
function DraftRoute() {
|
||||||
const [search] = useSearchParams<{ draftId?: string }>()
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
return (
|
return (
|
||||||
<Show when={tabs.ready()}>
|
<Show when={tabs.ready()}>
|
||||||
<Show when={search.draftId} keyed fallback={<Navigate href="/" />}>
|
<Show
|
||||||
<ResolvedDraftRoute />
|
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>
|
||||||
</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 (
|
return (
|
||||||
|
<ServerSDKProvider server={conn}>
|
||||||
|
<ServerSyncProvider server={conn}>
|
||||||
|
<TargetServerScopedProviders directory={directory}>
|
||||||
|
<SDKProvider directory={directory}>
|
||||||
|
<DirectoryDataProvider directory={directory} server={serverKey}>
|
||||||
<DraftProviders>
|
<DraftProviders>
|
||||||
<NewSession />
|
<NewSession />
|
||||||
</DraftProviders>
|
</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
|
// Server-scoped providers shared by the legacy shell and the top-level new shell.
|
||||||
// each per-route server layout so they resolve to that route's server (selected vs
|
|
||||||
// draft). The Layout remounts when crossing between those groups.
|
|
||||||
type ServerScopedShellProps = ParentProps<{
|
type ServerScopedShellProps = ParentProps<{
|
||||||
directory?: () => string | undefined
|
directory?: () => string | undefined
|
||||||
sessionID?: () => string | undefined
|
sessionID?: () => string | undefined
|
||||||
@@ -330,11 +320,23 @@ function LegacyServerScopedShell(props: ServerScopedShellProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NewServerScopedShell(props: ServerScopedShellProps) {
|
function NewAppLayout(props: ParentProps) {
|
||||||
return (
|
return (
|
||||||
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID}>
|
<SelectedServerProviders>
|
||||||
|
<ServerScopedProviders>
|
||||||
<NewLayout>{props.children}</NewLayout>
|
<NewLayout>{props.children}</NewLayout>
|
||||||
</ServerScopedProviders>
|
</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>
|
router?: Component<BaseRouterProps>
|
||||||
disableHealthCheck?: boolean
|
disableHealthCheck?: boolean
|
||||||
}) {
|
}) {
|
||||||
// The shared shell holds only server-agnostic providers (QueryClient + Settings/
|
// The visual new layout lives in the router root so it remains mounted across
|
||||||
// Command/Highlights) and stays mounted across every route. The server-scoped
|
// route changes. Draft and session routes override only their server-bound data
|
||||||
// providers and the visual Layout live in the per-route layouts below, so they
|
// providers beneath it.
|
||||||
// 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.
|
|
||||||
const ServerShell = (shellProps: ParentProps) => (
|
const ServerShell = (shellProps: ParentProps) => (
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<SharedProviders>
|
<SharedProviders>
|
||||||
@@ -548,7 +548,11 @@ export function AppInterface(props: {
|
|||||||
component={props.router ?? Router}
|
component={props.router ?? Router}
|
||||||
root={(routerProps) => (
|
root={(routerProps) => (
|
||||||
<TabsProvider>
|
<TabsProvider>
|
||||||
<ServerShell>{routerProps.children}</ServerShell>
|
<ServerShell>
|
||||||
|
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||||
|
<NewAppLayout>{routerProps.children}</NewAppLayout>
|
||||||
|
</Show>
|
||||||
|
</ServerShell>
|
||||||
</TabsProvider>
|
</TabsProvider>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -574,14 +578,10 @@ function Routes() {
|
|||||||
<Route path="/session/:id?" component={SessionRoute} />
|
<Route path="/session/:id?" component={SessionRoute} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
<Route component={TargetServerLayout}>
|
|
||||||
<Show when={settings.general.newLayoutDesigns()}>
|
<Show when={settings.general.newLayoutDesigns()}>
|
||||||
{
|
|
||||||
<>
|
|
||||||
<Route path="/" component={NewHome} />
|
<Route path="/" component={NewHome} />
|
||||||
<Route path="/:dir" component={DirectoryLayout}>
|
|
||||||
<Route
|
<Route
|
||||||
path="/session/:id"
|
path="/:dir/session/:id"
|
||||||
component={() => {
|
component={() => {
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
@@ -589,13 +589,9 @@ function Routes() {
|
|||||||
return <Navigate href={`/server/${server.key}/session/${id}`} />
|
return <Navigate href={`/server/${server.key}/session/${id}`} />
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Route>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
</Show>
|
</Show>
|
||||||
<Route path="/new-session" component={DraftRoute} />
|
<Route path="/new-session" component={DraftRoute} />
|
||||||
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
||||||
</Route>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const serverCtx = createMemo(() => global.createServerCtx(props.server))
|
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
|
||||||
const serverSDK = () => serverCtx().sdk
|
const serverSDK = () => serverCtx().sdk
|
||||||
const serverSync = () => serverCtx().sync
|
const serverSync = () => serverCtx().sync
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
pickerMode,
|
pickerMode,
|
||||||
preloadTreeDirectories,
|
preloadTreeDirectories,
|
||||||
cleanPickerInput,
|
cleanPickerInput,
|
||||||
|
createPriorityTaskQueue,
|
||||||
createDirectorySearch,
|
createDirectorySearch,
|
||||||
currentPickerSuggestions,
|
currentPickerSuggestions,
|
||||||
displayPickerPath,
|
displayPickerPath,
|
||||||
@@ -38,7 +39,7 @@ interface DialogSelectDirectoryV2Props {
|
|||||||
|
|
||||||
export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const { sync, sdk } = global.createServerCtx(props.server)
|
const { sync, sdk } = global.ensureServerCtx(props.server)
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const policy = pickerMode(props.mode ?? "directory", props.start)
|
const policy = pickerMode(props.mode ?? "directory", props.start)
|
||||||
@@ -55,6 +56,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
|||||||
const [error, setError] = createSignal(false)
|
const [error, setError] = createSignal(false)
|
||||||
const [rootValid, setRootValid] = createSignal(false)
|
const [rootValid, setRootValid] = createSignal(false)
|
||||||
const listings = new Map<string, Promise<Array<{ name: string; type: "file" | "directory" }> | undefined>>()
|
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>()
|
const advanced = new Set<string>()
|
||||||
let tree: FileTree | undefined
|
let tree: FileTree | undefined
|
||||||
let container: HTMLDivElement | undefined
|
let container: HTMLDivElement | undefined
|
||||||
@@ -102,16 +104,21 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
|||||||
})
|
})
|
||||||
const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions(), input()))
|
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(/\/+$/, "")
|
const key = path.replace(/\/+$/, "")
|
||||||
setError(false)
|
setError(false)
|
||||||
const absolute = absoluteTreePath(root(), key)
|
const absolute = absoluteTreePath(root(), key)
|
||||||
|
const existing = listings.get(key)
|
||||||
|
if (existing && !eager) loads.promote(`${generation}:${key}`)
|
||||||
const request =
|
const request =
|
||||||
listings.get(key) ??
|
existing ??
|
||||||
sdk.client.file
|
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
||||||
|
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
||||||
|
return sdk.client.file
|
||||||
.list({ directory: absolute, path: "" })
|
.list({ directory: absolute, path: "" })
|
||||||
.then((result) => result.data ?? [])
|
.then((result) => result.data ?? [])
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
|
})
|
||||||
listings.set(key, request)
|
listings.set(key, request)
|
||||||
const nodes = await request
|
const nodes = await request
|
||||||
if (!activeTreeNavigation(generation, navigation)) return false
|
if (!activeTreeNavigation(generation, navigation)) return false
|
||||||
@@ -121,8 +128,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
tree?.batch(policy.entries(key, nodes).map((item) => ({ type: "add", path: item })))
|
tree?.batch(policy.entries(key, nodes).map((item) => ({ type: "add", path: item })))
|
||||||
if (preload && advanceTreePreload(advanced, key)) {
|
if (!eager && advanceTreePreload(advanced, key)) {
|
||||||
void Promise.all(preloadTreeDirectories(key, nodes).map((directory) => load(directory, generation, false)))
|
for (const directory of preloadTreeDirectories(key, nodes)) void load(directory, generation, true)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ function uniqueRows(rows: Row[]) {
|
|||||||
|
|
||||||
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const { sync, sdk, ...serverCtx } = global.createServerCtx(props.server)
|
const { sync, sdk, ...serverCtx } = global.ensureServerCtx(props.server)
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
treePathWithin,
|
treePathWithin,
|
||||||
currentPickerSuggestions,
|
currentPickerSuggestions,
|
||||||
createDirectorySearch,
|
createDirectorySearch,
|
||||||
|
createPriorityTaskQueue,
|
||||||
displayPickerPath,
|
displayPickerPath,
|
||||||
pickerParent,
|
pickerParent,
|
||||||
pickerRoot,
|
pickerRoot,
|
||||||
@@ -168,6 +169,41 @@ test("advances preloading once for every expanded directory", () => {
|
|||||||
expect(advanceTreePreload(advanced, "repos/")).toBeTrue()
|
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", () => {
|
test("clamps bridged tree wheel scrolling", () => {
|
||||||
expect(nextTreeScrollTop(100, 40, 500, 200)).toBe(140)
|
expect(nextTreeScrollTop(100, 40, 500, 200)).toBe(140)
|
||||||
expect(nextTreeScrollTop(10, -40, 500, 200)).toBe(0)
|
expect(nextTreeScrollTop(10, -40, 500, 200)).toBe(0)
|
||||||
|
|||||||
@@ -138,6 +138,79 @@ export function activeTreeNavigation(request: number, current: number) {
|
|||||||
return request === current
|
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) {
|
export function nextTreeScrollTop(current: number, delta: number, scrollHeight: number, clientHeight: number) {
|
||||||
return Math.min(Math.max(0, scrollHeight - clientHeight), Math.max(0, current + delta))
|
return Math.min(Math.max(0, scrollHeight - clientHeight), Math.max(0, current + delta))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1379,7 +1379,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
|
const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
|
||||||
|
|
||||||
const [promptReady] = createResource(
|
const [promptReady] = createResource(
|
||||||
() => prompt.ready().promise,
|
() => prompt.ready.promise,
|
||||||
(p) => p,
|
(p) => p,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ let variant: string | undefined
|
|||||||
|
|
||||||
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||||
const prompt = {
|
const prompt = {
|
||||||
ready: () => Object.assign(() => true, { promise: Promise.resolve(true) }),
|
ready: Object.assign(() => true, { promise: Promise.resolve(true) }),
|
||||||
current: () => promptValue,
|
current: () => promptValue,
|
||||||
cursor: () => 0,
|
cursor: () => 0,
|
||||||
dirty: () => true,
|
dirty: () => true,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function SettingsServerScope(props: ParentProps) {
|
|||||||
|
|
||||||
function SettingsServerDataProviders(props: ParentProps<{ server: ServerConnection.Any }>) {
|
function SettingsServerDataProviders(props: ParentProps<{ server: ServerConnection.Any }>) {
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const serverCtx = () => global.createServerCtx(props.server)
|
const serverCtx = () => global.ensureServerCtx(props.server)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={serverCtx().queryClient}>
|
<QueryClientProvider client={serverCtx().queryClient}>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
createResource,
|
createResource,
|
||||||
|
createRoot,
|
||||||
createSignal,
|
createSignal,
|
||||||
For,
|
For,
|
||||||
Match,
|
Match,
|
||||||
@@ -29,7 +30,6 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { WindowsAppMenu } from "./windows-app-menu"
|
import { WindowsAppMenu } from "./windows-app-menu"
|
||||||
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
|
||||||
import { projectForSession } from "@/pages/layout/helpers"
|
import { projectForSession } from "@/pages/layout/helpers"
|
||||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
@@ -40,8 +40,9 @@ import { useGlobal } from "@/context/global"
|
|||||||
import { ServerConnection, useServer } from "@/context/server"
|
import { ServerConnection, useServer } from "@/context/server"
|
||||||
import { tabHref, useTabs } from "@/context/tabs"
|
import { tabHref, useTabs } from "@/context/tabs"
|
||||||
import "./titlebar.css"
|
import "./titlebar.css"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
|
||||||
import { Session } from "@opencode-ai/sdk/v2"
|
import { Session } from "@opencode-ai/sdk/v2"
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { createTabPromptState } from "@/context/prompt"
|
||||||
|
|
||||||
type TauriDesktopWindow = {
|
type TauriDesktopWindow = {
|
||||||
startDragging?: () => Promise<void>
|
startDragging?: () => Promise<void>
|
||||||
@@ -100,10 +101,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
|
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
|
||||||
const minHeight = () => {
|
const minHeight = () => {
|
||||||
const height = useV2Titlebar() ? v2TitlebarHeight : legacyTitlebarHeight
|
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 (mac()) return `${height / zoom()}px`
|
||||||
if (windows()) return `${height / Math.min(titlebarZoom(), 1)}px`
|
if (windows()) return `${height / Math.min(titlebarZoom(), 1)}px`
|
||||||
return undefined
|
return undefined
|
||||||
@@ -245,8 +242,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
"min-height": minHeight(),
|
"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,
|
"padding-left": mac() && !mobile() ? `${84 / zoom()}px` : 0,
|
||||||
width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
|
width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
|
||||||
"max-width": electronWindows()
|
"max-width": electronWindows()
|
||||||
@@ -261,18 +256,9 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
<Switch>
|
<Switch>
|
||||||
<Match when={useV2Titlebar()}>
|
<Match when={useV2Titlebar()}>
|
||||||
{(_) => {
|
{(_) => {
|
||||||
const serverSdk = useServerSDK()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
|
const global = useGlobal()
|
||||||
const newSessionHref = () => {
|
|
||||||
if (params.dir) return `/${params.dir}/session`
|
|
||||||
|
|
||||||
const project = layout.projects.list()[0]
|
|
||||||
if (!project) return "/"
|
|
||||||
|
|
||||||
return `/${base64Encode(project.worktree)}/session`
|
|
||||||
}
|
|
||||||
|
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const tabsStore = tabs.store
|
const tabsStore = tabs.store
|
||||||
@@ -280,11 +266,15 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
const [session] = createResource(
|
const [session] = createResource(
|
||||||
() => {
|
() => {
|
||||||
const route = layout.route()
|
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) =>
|
({ route, sdk }) =>
|
||||||
serverSdk()
|
sdk.client.session
|
||||||
.client.session.get({ sessionID: route.sessionId })
|
.get({ sessionID: route.sessionId })
|
||||||
.then((x) => x.data)
|
.then((x) => x.data)
|
||||||
.catch(() => {}),
|
.catch(() => {}),
|
||||||
)
|
)
|
||||||
@@ -337,7 +327,28 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
tabsStoreActions.removeSessions(detail)
|
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() })
|
const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() })
|
||||||
|
|
||||||
command.register("titlebar-home", () => [
|
command.register("titlebar-home", () => [
|
||||||
@@ -407,22 +418,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
if (next) tabs.select(next)
|
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)
|
].filter((v) => v !== undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -486,6 +481,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
<For each={tabsStore}>
|
<For each={tabsStore}>
|
||||||
{(tab, i) => {
|
{(tab, i) => {
|
||||||
let ref!: HTMLDivElement
|
let ref!: HTMLDivElement
|
||||||
|
useTabShortcut(i, () => tabs.select(tab))
|
||||||
|
|
||||||
const divider = () =>
|
const divider = () =>
|
||||||
i() !== 0 && (
|
i() !== 0 && (
|
||||||
@@ -511,14 +507,76 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const [session] = createResource(
|
const serverCtx = createMemo(() => {
|
||||||
() => tab.sessionId,
|
const conn = server.list.find((item) => ServerConnection.key(item) === tab.server)
|
||||||
(sessionID) =>
|
return conn ? global.ensureServerCtx(conn) : undefined
|
||||||
serverSdk()
|
})
|
||||||
.client.session.get({ sessionID })
|
const sdk = createMemo(() => serverCtx()?.sdk ?? null)
|
||||||
.then((x) => x.data)
|
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),
|
.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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -592,8 +650,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
size="large"
|
size="large"
|
||||||
class="shrink-0"
|
class="shrink-0"
|
||||||
icon={<IconV2 name="plus" />}
|
icon={<IconV2 name="plus" />}
|
||||||
as="a"
|
onClick={openNewTab}
|
||||||
href={newSessionHref()}
|
|
||||||
aria-label={language.t("command.session.new")}
|
aria-label={language.t("command.session.new")}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -818,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: {
|
function TabNavItem(props: {
|
||||||
ref?: HTMLDivElement
|
ref?: HTMLDivElement
|
||||||
href: string
|
href: string
|
||||||
@@ -840,13 +917,13 @@ function TabNavItem(props: {
|
|||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const serverCtx = createMemo(() => {
|
const serverCtx = createMemo(() => {
|
||||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={props.ref}
|
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}
|
data-active={props.active}
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
if (event.button !== 1) return
|
if (event.button !== 1) return
|
||||||
@@ -920,7 +997,7 @@ function DraftTabItem(props: {
|
|||||||
<div
|
<div
|
||||||
ref={props.ref}
|
ref={props.ref}
|
||||||
data-active={props.active}
|
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) => {
|
onMouseDown={(event) => {
|
||||||
if (event.button !== 1) return
|
if (event.button !== 1) return
|
||||||
closeTab(event)
|
closeTab(event)
|
||||||
@@ -965,7 +1042,7 @@ function NewSessionTabItem(props: { ref?: HTMLDivElement; href: string; title: s
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={props.ref}
|
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) => {
|
onMouseDown={(event) => {
|
||||||
if (event.button !== 1) return
|
if (event.button !== 1) return
|
||||||
closeTab(event)
|
closeTab(event)
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ export const createDirSyncContext = (
|
|||||||
return serverSync.child(directory)
|
return serverSync.child(directory)
|
||||||
}
|
}
|
||||||
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
||||||
const initialMessagePageSize = 80
|
const initialMessagePageSize = 2
|
||||||
const historyMessagePageSize = 200
|
const historyMessagePageSize = 200
|
||||||
const inflight = new Map<string, Promise<void>>()
|
const inflight = new Map<string, Promise<void>>()
|
||||||
const inflightDiff = new Map<string, Promise<void>>()
|
const inflightDiff = new Map<string, Promise<void>>()
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import { createServerSyncContext } from "./server-sync"
|
|||||||
import { getOwner } from "solid-js/web"
|
import { getOwner } from "solid-js/web"
|
||||||
import { QueryClient } from "@tanstack/solid-query"
|
import { QueryClient } from "@tanstack/solid-query"
|
||||||
import type { ServerScope } from "@/utils/server-scope"
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
|
import { createSessionPlacementStore } from "@/utils/session-placement"
|
||||||
|
|
||||||
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
|
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
|
||||||
name: "Global",
|
name: "Global",
|
||||||
init: () => {
|
init: () => {
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
|
const sessionPlacement = createSessionPlacementStore()
|
||||||
const serverHealth = useServerHealth(
|
const serverHealth = useServerHealth(
|
||||||
() => server.list,
|
() => server.list,
|
||||||
() => true,
|
() => true,
|
||||||
@@ -85,7 +87,8 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
createServerCtx(conn: ServerConnection.Any) {
|
sessionPlacement,
|
||||||
|
ensureServerCtx(conn: ServerConnection.Any) {
|
||||||
return ensureServerCtx(conn)
|
return ensureServerCtx(conn)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { useServerSync } from "./server-sync"
|
|||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { Binary } from "@opencode-ai/core/util/binary"
|
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
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) => {
|
const lookup = async (directory: string, sessionID?: string) => {
|
||||||
if (!sessionID) return undefined
|
if (!sessionID) return undefined
|
||||||
const [syncStore] = serverSync().child(directory, { bootstrap: false })
|
const sync = serverSync().ensureDirSyncContext(directory)
|
||||||
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
|
const session = sync.session.get(sessionID)
|
||||||
if (match.found) return syncStore.session[match.index]
|
if (session) return session
|
||||||
return serverSDK()
|
return sync.session
|
||||||
.client.session.get({ directory, sessionID })
|
.sync(sessionID)
|
||||||
.then((x) => x.data)
|
.then(() => sync.session.get(sessionID))
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
|
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
|
||||||
import { useParams, useSearchParams } from "@solidjs/router"
|
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 { createStore, type SetStoreFunction } from "solid-js/store"
|
||||||
import type { FileSelection } from "@/context/file"
|
import type { FileSelection } from "@/context/file"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { useServerSDK } from "./server-sdk"
|
import { useServerSDK } from "./server-sdk"
|
||||||
import type { ServerScope } from "@/utils/server-scope"
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
import { useSDK } from "./sdk"
|
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 {
|
interface PartBase {
|
||||||
content: string
|
content: string
|
||||||
@@ -181,7 +185,7 @@ function promptTarget(serverScope: ServerScope, scope: Scope) {
|
|||||||
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
|
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(
|
const [store, setStore, _, ready] = persisted(
|
||||||
promptTarget(serverScope, scope),
|
promptTarget(serverScope, scope),
|
||||||
createStore<PromptStore>(promptStore()),
|
createStore<PromptStore>(promptStore()),
|
||||||
@@ -190,6 +194,12 @@ function createPromptSession(serverScope: ServerScope, scope: Scope) {
|
|||||||
return { ready, ...createPromptStateValue(store, setStore) }
|
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 {
|
function promptStore(): PromptStore {
|
||||||
return {
|
return {
|
||||||
prompt: clonePrompt(DEFAULT_PROMPT),
|
prompt: clonePrompt(DEFAULT_PROMPT),
|
||||||
@@ -247,19 +257,28 @@ export function createPromptState() {
|
|||||||
const [store, setStore] = createStore<PromptStore>(promptStore())
|
const [store, setStore] = createStore<PromptStore>(promptStore())
|
||||||
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
|
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
|
||||||
return {
|
return {
|
||||||
ready: () => ready,
|
ready,
|
||||||
...createPromptStateValue(store, setStore),
|
...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({
|
export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
|
||||||
name: "Prompt",
|
name: "Prompt",
|
||||||
gate: false,
|
gate: false,
|
||||||
init: () => {
|
init: () => {
|
||||||
const params = useParams()
|
const params = useParams<{ serverKey?: string; id?: string }>()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const [search] = useSearchParams<{ draftId?: string }>()
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
|
const server = useServer()
|
||||||
|
const tabs = useTabs()
|
||||||
|
const settings = useSettings()
|
||||||
const cache = new Map<string, PromptCacheEntry>()
|
const cache = new Map<string, PromptCacheEntry>()
|
||||||
|
|
||||||
const disposeAll = () => {
|
const disposeAll = () => {
|
||||||
@@ -282,7 +301,25 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const owner = getOwner()
|
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 load = (scope: Scope) => {
|
||||||
|
const current = tab()
|
||||||
|
if (current) {
|
||||||
|
return createTabPromptState(tabs, current, serverSDK().scope, scope)
|
||||||
|
}
|
||||||
|
|
||||||
const key = scopeKey(scope)
|
const key = scopeKey(scope)
|
||||||
const existing = cache.get(key)
|
const existing = cache.get(key)
|
||||||
if (existing) {
|
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 }),
|
load(search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }),
|
||||||
)
|
)
|
||||||
const pick = (scope?: Scope) => (scope ? load(scope) : session())
|
const pick = (scope?: Scope) => (scope ? load(scope) : session())
|
||||||
|
const ready = createPromptReady(session)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready: () => session().ready,
|
ready,
|
||||||
current: () => session().current(),
|
current: () => session().current(),
|
||||||
cursor: () => session().cursor(),
|
cursor: () => session().cursor(),
|
||||||
dirty: () => session().dirty(),
|
dirty: () => session().dirty(),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
|||||||
import { type Accessor, createMemo } from "solid-js"
|
import { type Accessor, createMemo } from "solid-js"
|
||||||
import { type ServerSDK, useServerSDK } from "./server-sdk"
|
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({
|
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||||
name: "SDK",
|
name: "SDK",
|
||||||
@@ -11,7 +11,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
return createMemo(() => {
|
return createMemo(() => {
|
||||||
const directory = typeof props.directory === "function" ? props.directory() : props.directory
|
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>
|
type ServerSDKBase = ReturnType<typeof createServerSdkContextBase>
|
||||||
export type ServerSDK = ServerSDKBase & {
|
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 {
|
export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope): ServerSDK {
|
||||||
const sdk = createServerSdkContextBase(server, scope)
|
const sdk = createServerSdkContextBase(server, scope)
|
||||||
return Object.assign(sdk, {
|
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>(() => {
|
return createMemo<ServerSDK>(() => {
|
||||||
const conn = props.server?.() ?? server.current
|
const conn = props.server?.() ?? server.current
|
||||||
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
|
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) {
|
export function createServerSyncContext(serverSDK: ServerSDK) {
|
||||||
const inner = createServerSyncContextInner(serverSDK)
|
const inner = createServerSyncContextInner(serverSDK)
|
||||||
return Object.assign(inner, {
|
return Object.assign(inner, {
|
||||||
createDirSyncContext: createRefCountMap(
|
ensureDirSyncContext: createRefCountMap(
|
||||||
(dir) => createDirSyncContext(dir, inner, serverSDK),
|
(dir) => createDirSyncContext(dir, inner, serverSDK),
|
||||||
(dir) => inner.disableMcp(dir),
|
(dir) => inner.disableMcp(dir),
|
||||||
directoryKey,
|
directoryKey,
|
||||||
@@ -531,7 +531,7 @@ export const { use: useServerSync, provider: ServerSyncProvider } = createSimple
|
|||||||
return createMemo<ServerSync>(() => {
|
return createMemo<ServerSync>(() => {
|
||||||
const conn = props.server?.() ?? server.current
|
const conn = props.server?.() ?? server.current
|
||||||
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
|
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 serverSync = useServerSync()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
|
||||||
return createMemo(() => serverSync().createDirSyncContext(sdk().directory))
|
return createMemo(() => serverSync().ensureDirSyncContext(sdk().directory))
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DirectorySync = ReturnType<ReturnType<typeof useSync>>
|
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 { createStore, produce } from "solid-js/store"
|
||||||
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
|
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
|
||||||
import { ServerConnection, useServer } from "./server"
|
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 { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
import { uuid } from "@/utils/uuid"
|
import { uuid } from "@/utils/uuid"
|
||||||
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
|
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
|
||||||
import { sessionHref } from "@/utils/session-route"
|
import { sessionHref } from "@/utils/session-route"
|
||||||
|
import { createTabMemory } from "./tab-memory"
|
||||||
|
|
||||||
export type SessionTab = {
|
export type SessionTab = {
|
||||||
type: "session"
|
type: "session"
|
||||||
@@ -66,6 +67,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
const params = useParams()
|
const params = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
const memory = createTabMemory(getOwner())
|
||||||
|
|
||||||
const closing = new Set<string>()
|
const closing = new Set<string>()
|
||||||
let recentWrite = 0
|
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)
|
for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onCleanup(memory.dispose)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!ready() || !recentReady()) return
|
if (!ready() || !recentReady()) return
|
||||||
const servers = new Set(server.list.map(ServerConnection.key))
|
const servers = new Set(server.list.map(ServerConnection.key))
|
||||||
const next = store.filter((tab) => servers.has(tab.server))
|
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)
|
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 = {
|
const actions = {
|
||||||
addSessionTab: (tab: Omit<SessionTab, "type">) => {
|
addSessionTab: (tab: Omit<SessionTab, "type">) => {
|
||||||
const next = { type: "session" as const, ...tab }
|
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(
|
setStore(
|
||||||
produce((tabs) => {
|
produce((tabs) => {
|
||||||
if (tabs.some((item) => tabKey(item) === tabKey(next))) return
|
if (tabs.some((item) => tabKey(item) === tabKey(next))) return
|
||||||
tabs.push(next)
|
tabs.push(next)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
return next
|
||||||
},
|
},
|
||||||
draft(draftID: string) {
|
draft(draftID: string) {
|
||||||
const tab = store.find((item) => item.type === "draft" && item.draftID === draftID)
|
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 (recent.key === `draft:${draftID}`) setRecentKey(tabKey(next))
|
||||||
if (active) navigateTab(next)
|
if (active) navigateTab(next)
|
||||||
})
|
})
|
||||||
|
memory.remove(`draft:${draftID}`)
|
||||||
removeDraftPersisted(draftID)
|
removeDraftPersisted(draftID)
|
||||||
},
|
},
|
||||||
removeTab: (index: number) => {
|
removeTab: (index: number) => {
|
||||||
@@ -170,12 +182,14 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
if (nextTab) navigateTab(nextTab)
|
if (nextTab) navigateTab(nextTab)
|
||||||
else navigate("/")
|
else navigate("/")
|
||||||
}).finally(() => closing.delete(key))
|
}).finally(() => closing.delete(key))
|
||||||
|
memory.remove(key)
|
||||||
if (draftID) removeDraftPersisted(draftID)
|
if (draftID) removeDraftPersisted(draftID)
|
||||||
},
|
},
|
||||||
removeServer(key: ServerConnection.Key) {
|
removeServer(key: ServerConnection.Key) {
|
||||||
const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : []))
|
const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : []))
|
||||||
const removed = store.filter((tab) => tab.server === key).map(tabKey)
|
const removed = store.filter((tab) => tab.server === key).map(tabKey)
|
||||||
setStore((tabs) => tabs.filter((tab) => tab.server !== key))
|
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)
|
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
|
||||||
for (const draftID of drafts) removeDraftPersisted(draftID)
|
for (const draftID of drafts) removeDraftPersisted(draftID)
|
||||||
if (server.key === key) navigate("/")
|
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)
|
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
|
||||||
})
|
})
|
||||||
|
for (const key of removed) memory.remove(key)
|
||||||
},
|
},
|
||||||
select: navigateTab,
|
select: navigateTab,
|
||||||
remember(tab: Tab) {
|
remember(tab: Tab) {
|
||||||
@@ -246,6 +261,9 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
navigate("/")
|
navigate("/")
|
||||||
},
|
},
|
||||||
|
state<T>(tab: Tab, name: string, init: () => T) {
|
||||||
|
return memory.ensure(tabKey(tab), name, init)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...actions, store, ready, recentReady }
|
return { ...actions, store, ready, recentReady }
|
||||||
|
|||||||
@@ -15,6 +15,13 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (display-mode: standalone) {
|
||||||
|
/* WebKit excludes safe-area insets from dvh in installed apps. */
|
||||||
|
#root {
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
@keyframes session-progress-whip {
|
@keyframes session-progress-whip {
|
||||||
0% {
|
0% {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { decode64 } from "@/utils/base64"
|
|||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import type { ServerConnection } from "@/context/server"
|
import type { ServerConnection } from "@/context/server"
|
||||||
import { sessionHref } from "@/utils/session-route"
|
import { sessionHref } from "@/utils/session-route"
|
||||||
|
import { useGlobal } from "@/context/global"
|
||||||
|
|
||||||
export function DirectoryDataProvider(
|
export function DirectoryDataProvider(
|
||||||
props: ParentProps<{
|
props: ParentProps<{
|
||||||
@@ -23,6 +24,7 @@ export function DirectoryDataProvider(
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const global = useGlobal()
|
||||||
const directory = () => (typeof props.directory === "function" ? props.directory() : props.directory)
|
const directory = () => (typeof props.directory === "function" ? props.directory() : props.directory)
|
||||||
const slug = createMemo(() => base64Encode(directory()))
|
const slug = createMemo(() => base64Encode(directory()))
|
||||||
const href = (sessionID: string) => {
|
const href = (sessionID: string) => {
|
||||||
@@ -52,7 +54,11 @@ export function DirectoryDataProvider(
|
|||||||
<DataProvider
|
<DataProvider
|
||||||
data={sync().data}
|
data={sync().data}
|
||||||
directory={directory()}
|
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}
|
onSessionHref={href}
|
||||||
>
|
>
|
||||||
<LocalProvider>{props.children}</LocalProvider>
|
<LocalProvider>{props.children}</LocalProvider>
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
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 { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useQuery } from "@tanstack/solid-query"
|
import { useQuery } from "@tanstack/solid-query"
|
||||||
@@ -32,7 +45,6 @@ import {
|
|||||||
displayName,
|
displayName,
|
||||||
getProjectAvatarSource,
|
getProjectAvatarSource,
|
||||||
homeProjectDirectories,
|
homeProjectDirectories,
|
||||||
homeProjectNavigation,
|
|
||||||
type HomeProjectSelection,
|
type HomeProjectSelection,
|
||||||
projectForSession,
|
projectForSession,
|
||||||
sortedRootSessions,
|
sortedRootSessions,
|
||||||
@@ -47,6 +59,8 @@ import { ServerRowMenu } from "@/components/server/server-row-menu"
|
|||||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||||
import { type ServerHealth } from "@/utils/server-health"
|
import { type ServerHealth } from "@/utils/server-health"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
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_SESSION_LIMIT = 64
|
||||||
const HOME_ROW_LAYOUT =
|
const HOME_ROW_LAYOUT =
|
||||||
@@ -123,8 +137,10 @@ export function NewHome() {
|
|||||||
const server = useServer()
|
const server = useServer()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
|
const tabs = useTabs()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const notification = useNotification()
|
const notification = useNotification()
|
||||||
|
const marked = useMarked()
|
||||||
let focusSessionSearch: (() => void) | undefined
|
let focusSessionSearch: (() => void) | undefined
|
||||||
const [state, setState] = createStore({
|
const [state, setState] = createStore({
|
||||||
search: "",
|
search: "",
|
||||||
@@ -138,7 +154,7 @@ export function NewHome() {
|
|||||||
const focusedServerCtx = createMemo(() => {
|
const focusedServerCtx = createMemo(() => {
|
||||||
const conn = focusedServer()
|
const conn = focusedServer()
|
||||||
if (!conn) return
|
if (!conn) return
|
||||||
return global.createServerCtx(conn)
|
return global.ensureServerCtx(conn)
|
||||||
})
|
})
|
||||||
const focusedSync = () => focusedServerCtx()?.sync ?? sync()
|
const focusedSync = () => focusedServerCtx()?.sync ?? sync()
|
||||||
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
|
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 searchOpen = createMemo(() => state.searchFocused && search().length > 0)
|
||||||
const groups = createMemo(() => groupSessions(records(), language))
|
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) {
|
function setSelection(next: HomeProjectSelection) {
|
||||||
batch(() => {
|
batch(() => {
|
||||||
@@ -237,7 +288,7 @@ export function NewHome() {
|
|||||||
const key = ServerConnection.key(conn)
|
const key = ServerConnection.key(conn)
|
||||||
if (
|
if (
|
||||||
!global
|
!global
|
||||||
.createServerCtx(conn)
|
.ensureServerCtx(conn)
|
||||||
.projects.list()
|
.projects.list()
|
||||||
.some((project) => project.worktree === directory)
|
.some((project) => project.worktree === directory)
|
||||||
)
|
)
|
||||||
@@ -248,7 +299,7 @@ export function NewHome() {
|
|||||||
function addProjects(conn: ServerConnection.Any, directories: string[]) {
|
function addProjects(conn: ServerConnection.Any, directories: string[]) {
|
||||||
const directory = directories[0]
|
const directory = directories[0]
|
||||||
if (!directory) return
|
if (!directory) return
|
||||||
const ctx = global.createServerCtx(conn)
|
const ctx = global.ensureServerCtx(conn)
|
||||||
directories.forEach(ctx.projects.open)
|
directories.forEach(ctx.projects.open)
|
||||||
ctx.projects.touch(directory)
|
ctx.projects.touch(directory)
|
||||||
setSelection({ server: ServerConnection.key(conn), directory })
|
setSelection({ server: ServerConnection.key(conn), directory })
|
||||||
@@ -261,21 +312,11 @@ export function NewHome() {
|
|||||||
openProjectNewSession(conn, project.worktree)
|
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) {
|
function openProjectNewSession(conn: ServerConnection.Any, directory: string) {
|
||||||
const ctx = global.createServerCtx(conn)
|
const ctx = global.ensureServerCtx(conn)
|
||||||
ctx.projects.open(directory)
|
ctx.projects.open(directory)
|
||||||
ctx.projects.touch(directory)
|
ctx.projects.touch(directory)
|
||||||
navigateOnServer(conn, `/${base64Encode(directory)}/session`)
|
tabs.newDraft({ server: ServerConnection.key(conn), directory })
|
||||||
}
|
}
|
||||||
|
|
||||||
function editProject(conn: ServerConnection.Any, project: LocalProject) {
|
function editProject(conn: ServerConnection.Any, project: LocalProject) {
|
||||||
@@ -301,10 +342,19 @@ export function NewHome() {
|
|||||||
const conn = focusedServer()
|
const conn = focusedServer()
|
||||||
if (!conn) return
|
if (!conn) return
|
||||||
const directory = project?.worktree ?? session.directory
|
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.open(directory)
|
||||||
ctx.projects.touch(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) {
|
function chooseProject(conn: ServerConnection.Any) {
|
||||||
@@ -312,8 +362,6 @@ export function NewHome() {
|
|||||||
addProjects(conn, homeProjectDirectories(result))
|
addProjects(conn, homeProjectDirectories(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = global.createServerCtx(conn)
|
|
||||||
|
|
||||||
pickDirectory({
|
pickDirectory({
|
||||||
server: conn,
|
server: conn,
|
||||||
title: language.t("command.project.open"),
|
title: language.t("command.project.open"),
|
||||||
@@ -343,7 +391,7 @@ export function NewHome() {
|
|||||||
const next = closeHomeProject(
|
const next = closeHomeProject(
|
||||||
state.selection,
|
state.selection,
|
||||||
ServerConnection.key(conn),
|
ServerConnection.key(conn),
|
||||||
global.createServerCtx(conn).projects,
|
global.ensureServerCtx(conn).projects,
|
||||||
directory,
|
directory,
|
||||||
)
|
)
|
||||||
if (next) setSelection(next)
|
if (next) setSelection(next)
|
||||||
@@ -480,7 +528,7 @@ function HomeProjectColumn(props: {
|
|||||||
{(item) => {
|
{(item) => {
|
||||||
const key = ServerConnection.key(item)
|
const key = ServerConnection.key(item)
|
||||||
const healthy = () => !!global.servers.health[key]?.healthy
|
const healthy = () => !!global.servers.health[key]?.healthy
|
||||||
const serverCtx = global.createServerCtx(item)
|
const serverCtx = global.ensureServerCtx(item)
|
||||||
const collapsed = () => !!state.collapsed[key]
|
const collapsed = () => !!state.collapsed[key]
|
||||||
return (
|
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">
|
<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">
|
||||||
@@ -1156,7 +1204,7 @@ export function LegacyHome() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function openProject(server: ServerConnection.Any, directory: string) {
|
function openProject(server: ServerConnection.Any, directory: string) {
|
||||||
const serverCtx = global.createServerCtx(server)
|
const serverCtx = global.ensureServerCtx(server)
|
||||||
serverCtx.projects.open(directory)
|
serverCtx.projects.open(directory)
|
||||||
serverCtx.projects.touch(directory)
|
serverCtx.projects.touch(directory)
|
||||||
navigate(`/${base64Encode(directory)}`)
|
navigate(`/${base64Encode(directory)}`)
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
import { createEffect, type ParentProps } from "solid-js"
|
import { createEffect, Suspense, type ParentProps } from "solid-js"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate, useParams } from "@solidjs/router"
|
||||||
import { DebugBar } from "@/components/debug-bar"
|
import { DebugBar } from "@/components/debug-bar"
|
||||||
import { HelpButton } from "@/components/help-button"
|
import { HelpButton } from "@/components/help-button"
|
||||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||||
|
import { useNotification } from "@/context/notification"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { setNavigate } from "@/utils/notification-click"
|
import { setNavigate } from "@/utils/notification-click"
|
||||||
import { setV2Toast, ToastRegion } from "@/utils/toast"
|
import { setV2Toast, ToastRegion } from "@/utils/toast"
|
||||||
|
|
||||||
export default function NewLayout(props: ParentProps) {
|
export default function NewLayout(props: ParentProps) {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
const notification = useNotification()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const params = useParams<{ id?: string }>()
|
||||||
setNavigate(navigate)
|
setNavigate(navigate)
|
||||||
|
|
||||||
createEffect(() => setV2Toast(true))
|
createEffect(() => setV2Toast(true))
|
||||||
|
createEffect(() => {
|
||||||
|
if (!notification.ready() || !params.id) return
|
||||||
|
notification.session.markViewed(params.id)
|
||||||
|
})
|
||||||
|
|
||||||
const update: TitlebarUpdate = {
|
const update: TitlebarUpdate = {
|
||||||
version: () => {
|
version: () => {
|
||||||
@@ -25,10 +32,16 @@ export default function NewLayout(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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} />
|
<Titlebar update={update} />
|
||||||
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
|
<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>
|
</main>
|
||||||
{import.meta.env.DEV && <DebugBar />}
|
{import.meta.env.DEV && <DebugBar />}
|
||||||
<HelpButton />
|
<HelpButton />
|
||||||
|
|||||||
@@ -1299,15 +1299,15 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
const openSession = async (target: { directory: string; id: string }) => {
|
const openSession = async (target: { directory: string; id: string }) => {
|
||||||
if (!canOpen(target.directory)) return false
|
if (!canOpen(target.directory)) return false
|
||||||
const [data] = serverSync().child(target.directory, { bootstrap: false })
|
const sync = serverSync().ensureDirSyncContext(target.directory)
|
||||||
if (data.session.some((item) => item.id === target.id)) {
|
if (sync.session.get(target.id)) {
|
||||||
setStore("lastProjectSession", root, { directory: target.directory, id: target.id, at: Date.now() })
|
setStore("lastProjectSession", root, { directory: target.directory, id: target.id, at: Date.now() })
|
||||||
navigateWithSidebarReset(`/${base64Encode(target.directory)}/session/${target.id}`)
|
navigateWithSidebarReset(`/${base64Encode(target.directory)}/session/${target.id}`)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
const resolved = await serverSDK()
|
const resolved = await sync.session
|
||||||
.client.session.get({ sessionID: target.id })
|
.sync(target.id)
|
||||||
.then((x) => x.data)
|
.then(() => sync.session.get(target.id))
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
if (!resolved?.directory) return false
|
if (!resolved?.directory) return false
|
||||||
if (!canOpen(resolved.directory)) return false
|
if (!canOpen(resolved.directory)) return false
|
||||||
|
|||||||
@@ -287,7 +287,7 @@ export default function Page() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
return key
|
return key
|
||||||
}, sessionKey())
|
})
|
||||||
|
|
||||||
let reviewFrame: number | undefined
|
let reviewFrame: number | undefined
|
||||||
let todoFrame: number | undefined
|
let todoFrame: number | undefined
|
||||||
@@ -693,6 +693,7 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
if (!sync().project) return
|
||||||
const list = changesOptions()
|
const list = changesOptions()
|
||||||
if (list.includes(store.changes)) return
|
if (list.includes(store.changes)) return
|
||||||
const next = list[0]
|
const next = list[0]
|
||||||
@@ -1128,13 +1129,35 @@ export default function Page() {
|
|||||||
|
|
||||||
let captureHistoryAnchor = () => {}
|
let captureHistoryAnchor = () => {}
|
||||||
let restoreHistoryAnchor = (_done: boolean) => {}
|
let restoreHistoryAnchor = (_done: boolean) => {}
|
||||||
const loadOlder = () =>
|
let historyRequest = false
|
||||||
timeline.history.loadOlder({ before: () => captureHistoryAnchor(), after: restoreHistoryAnchor })
|
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 = () => {
|
const onHistoryScroll = () => {
|
||||||
if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200) return
|
if (historyRequest || historyLoading() || !autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200)
|
||||||
|
return
|
||||||
void loadOlder()
|
void loadOlder()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame)
|
||||||
|
})
|
||||||
|
|
||||||
fill = () => {
|
fill = () => {
|
||||||
if (fillFrame !== undefined) return
|
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 { createStore } from "solid-js/store"
|
||||||
import { useNavigate, useSearchParams } from "@solidjs/router"
|
import { useNavigate, useSearchParams } from "@solidjs/router"
|
||||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||||
@@ -25,11 +25,12 @@ import { pathKey } from "@/utils/path-key"
|
|||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useServer } from "@/context/server"
|
import { ServerConnection, useServer } from "@/context/server"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { type DraftTab, useTabs } from "@/context/tabs"
|
||||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
||||||
|
import { useGlobal } from "@/context/global"
|
||||||
|
|
||||||
export function SessionComposerRegion(props: {
|
export function SessionComposerRegion(props: {
|
||||||
state: SessionComposerState
|
state: SessionComposerState
|
||||||
@@ -73,26 +74,52 @@ export function SessionComposerRegion(props: {
|
|||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
|
const global = useGlobal()
|
||||||
const pickDirectory = useDirectoryPicker()
|
const pickDirectory = useDirectoryPicker()
|
||||||
const [search] = useSearchParams<{ draftId?: string }>()
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
const view = layout.view(route.sessionKey)
|
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 agentsQuery = createQuery(() => queryOptions().agents(pathKey(sdk().directory)))
|
||||||
const globalProvidersQuery = createQuery(() => queryOptions().providers(null))
|
const globalProvidersQuery = createQuery(() => queryOptions().providers(null))
|
||||||
const providersQuery = createQuery(() => queryOptions().providers(pathKey(sdk().directory)))
|
const providersQuery = createQuery(() => queryOptions().providers(pathKey(sdk().directory)))
|
||||||
const selectProject = (worktree: string) => {
|
const selectProject = (worktree: string) => {
|
||||||
layout.projects.open(worktree)
|
const conn = projectServer()
|
||||||
server.projects.touch(worktree)
|
const target = projectServerCtx()
|
||||||
if (search.draftId) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
layout.projects.open(worktree)
|
||||||
|
server.projects.touch(worktree)
|
||||||
navigate(`/${base64Encode(worktree)}/session`)
|
navigate(`/${base64Encode(worktree)}/session`)
|
||||||
}
|
}
|
||||||
const addProject = (title: string) => {
|
const addProject = (title: string) => {
|
||||||
if (!server.current) return
|
const conn = projectServer()
|
||||||
|
if (!conn) return
|
||||||
pickDirectory({
|
pickDirectory({
|
||||||
server: server.current,
|
server: conn,
|
||||||
title,
|
title,
|
||||||
onSelect: (result) => {
|
onSelect: (result) => {
|
||||||
const directory = Array.isArray(result) ? result[0] : result
|
const directory = Array.isArray(result) ? result[0] : result
|
||||||
@@ -115,7 +142,7 @@ export function SessionComposerRegion(props: {
|
|||||||
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
|
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
|
||||||
},
|
},
|
||||||
projects: {
|
projects: {
|
||||||
available: layout.projects.list(),
|
available: projects(),
|
||||||
directory: sdk().directory,
|
directory: sdk().directory,
|
||||||
select: selectProject,
|
select: selectProject,
|
||||||
add: addProject,
|
add: addProject,
|
||||||
@@ -216,6 +243,12 @@ export function SessionComposerRegion(props: {
|
|||||||
update()
|
update()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const ready = Promise.resolve()
|
||||||
|
const [promptReadyResource] = createResource(
|
||||||
|
() => prompt.ready.promise ?? ready,
|
||||||
|
(promise) => promise.then(() => true),
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={props.setPromptDockRef}
|
ref={props.setPromptDockRef}
|
||||||
@@ -258,7 +291,7 @@ export function SessionComposerRegion(props: {
|
|||||||
|
|
||||||
<Show when={showComposer()}>
|
<Show when={showComposer()}>
|
||||||
<Show
|
<Show
|
||||||
when={prompt.ready()}
|
when={promptReadyResource()}
|
||||||
fallback={
|
fallback={
|
||||||
<>
|
<>
|
||||||
<Show when={rolled()} keyed>
|
<Show when={rolled()} keyed>
|
||||||
|
|||||||
@@ -15,37 +15,29 @@ describe("timeline model", () => {
|
|||||||
expect(selectVisibleUserMessages(users)).toBe(users)
|
expect(selectVisibleUserMessages(users)).toBe(users)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("loads pages until a visible user turn is added", async () => {
|
test("loads exactly one opaque cursor page", async () => {
|
||||||
let loaded = 10
|
|
||||||
let visible = 2
|
|
||||||
let calls = 0
|
let calls = 0
|
||||||
const anchors: Array<string | boolean> = []
|
const anchors: Array<string | boolean> = []
|
||||||
|
|
||||||
await loadOlderTimeline({
|
await loadOlderTimeline({
|
||||||
sessionID: () => "ses_test",
|
sessionID: () => "ses_test",
|
||||||
loaded: () => loaded,
|
|
||||||
visible: () => visible,
|
|
||||||
more: () => true,
|
more: () => true,
|
||||||
loading: () => false,
|
loading: () => false,
|
||||||
loadMore: async () => {
|
loadMore: async () => {
|
||||||
calls += 1
|
calls += 1
|
||||||
loaded += 3
|
|
||||||
if (calls === 2) visible += 1
|
|
||||||
},
|
},
|
||||||
before: () => anchors.push("before"),
|
before: () => anchors.push("before"),
|
||||||
after: (done) => anchors.push("after", done),
|
after: (done) => anchors.push("after", done),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(calls).toBe(2)
|
expect(calls).toBe(1)
|
||||||
expect(anchors).toEqual(["before", "after", false, "after", true])
|
expect(anchors).toEqual(["before", "after", true])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("stops when a page adds no raw messages", async () => {
|
test("stops when a page adds no raw messages", async () => {
|
||||||
let calls = 0
|
let calls = 0
|
||||||
await loadOlderTimeline({
|
await loadOlderTimeline({
|
||||||
sessionID: () => "ses_test",
|
sessionID: () => "ses_test",
|
||||||
loaded: () => 10,
|
|
||||||
visible: () => 2,
|
|
||||||
more: () => true,
|
more: () => true,
|
||||||
loading: () => false,
|
loading: () => false,
|
||||||
loadMore: async () => {
|
loadMore: async () => {
|
||||||
@@ -62,8 +54,6 @@ describe("timeline model", () => {
|
|||||||
|
|
||||||
await loadOlderTimeline({
|
await loadOlderTimeline({
|
||||||
sessionID: () => sessionID,
|
sessionID: () => sessionID,
|
||||||
loaded: () => 10,
|
|
||||||
visible: () => 2,
|
|
||||||
more: () => true,
|
more: () => true,
|
||||||
loading: () => false,
|
loading: () => false,
|
||||||
loadMore: async () => {
|
loadMore: async () => {
|
||||||
@@ -83,8 +73,6 @@ describe("timeline model", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
loadOlderTimeline({
|
loadOlderTimeline({
|
||||||
sessionID: () => "ses_test",
|
sessionID: () => "ses_test",
|
||||||
loaded: () => 10,
|
|
||||||
visible: () => 2,
|
|
||||||
more: () => true,
|
more: () => true,
|
||||||
loading: () => false,
|
loading: () => false,
|
||||||
loadMore: async () => {
|
loadMore: async () => {
|
||||||
|
|||||||
@@ -74,8 +74,6 @@ export function createTimelineModel(input: {
|
|||||||
const loadOlder = async (options?: { before?: () => void; after?: (done: boolean) => void }) => {
|
const loadOlder = async (options?: { before?: () => void; after?: (done: boolean) => void }) => {
|
||||||
return loadOlderTimeline({
|
return loadOlderTimeline({
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
loaded: () => messages().length,
|
|
||||||
visible: () => visibleUserMessages().length,
|
|
||||||
more,
|
more,
|
||||||
loading,
|
loading,
|
||||||
loadMore: (sessionID) => sync().session.history.loadMore(sessionID),
|
loadMore: (sessionID) => sync().session.history.loadMore(sessionID),
|
||||||
@@ -115,8 +113,6 @@ export function selectVisibleUserMessages(messages: UserMessage[], revertMessage
|
|||||||
|
|
||||||
export async function loadOlderTimeline(input: {
|
export async function loadOlderTimeline(input: {
|
||||||
sessionID: Accessor<string | undefined>
|
sessionID: Accessor<string | undefined>
|
||||||
loaded: Accessor<number>
|
|
||||||
visible: Accessor<number>
|
|
||||||
more: Accessor<boolean>
|
more: Accessor<boolean>
|
||||||
loading: Accessor<boolean>
|
loading: Accessor<boolean>
|
||||||
loadMore: (sessionID: string) => Promise<void>
|
loadMore: (sessionID: string) => Promise<void>
|
||||||
@@ -126,23 +122,11 @@ export async function loadOlderTimeline(input: {
|
|||||||
const id = input.sessionID()
|
const id = input.sessionID()
|
||||||
if (!id || !input.more() || input.loading()) return
|
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?.()
|
input.before?.()
|
||||||
while (true) {
|
|
||||||
await input.loadMore(id).catch((error) => {
|
await input.loadMore(id).catch((error) => {
|
||||||
if (input.sessionID() === id) input.after?.(true)
|
if (input.sessionID() === id) input.after?.(true)
|
||||||
throw error
|
throw error
|
||||||
})
|
})
|
||||||
if (input.sessionID() !== id) return
|
if (input.sessionID() !== id) return
|
||||||
|
input.after?.(true)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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", {
|
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
|
||||||
description: "OpenCode 2.0 preview command line interface",
|
description: "OpenCode 2.0 preview command line interface",
|
||||||
commands: [
|
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", {
|
Spec.make("debug", {
|
||||||
description: "Debugging and troubleshooting tools",
|
description: "Debugging and troubleshooting tools",
|
||||||
commands: [Spec.make("agents", { description: "List all agents" })],
|
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, {
|
const Handlers = Runtime.handlers(Commands, {
|
||||||
$: () => import("./commands/handlers/default"),
|
$: () => import("./commands/handlers/default"),
|
||||||
|
api: () => import("./commands/handlers/api"),
|
||||||
debug: {
|
debug: {
|
||||||
agents: () => import("./commands/handlers/debug/agents"),
|
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 { Resource } from "@opencode-ai/console-resource"
|
||||||
import { safeEqual } from "@opencode-ai/console-core/util/crypto.js"
|
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({
|
const basePayload = z.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
|
|||||||
@@ -37,7 +37,6 @@
|
|||||||
"update-limits": "script/update-limits.ts",
|
"update-limits": "script/update-limits.ts",
|
||||||
"promote-limits-to-dev": "script/promote-limits.ts dev",
|
"promote-limits-to-dev": "script/promote-limits.ts dev",
|
||||||
"promote-limits-to-prod": "script/promote-limits.ts production",
|
"promote-limits-to-prod": "script/promote-limits.ts production",
|
||||||
"referral-backfill": "script/referral-backfill.ts",
|
|
||||||
"typecheck": "tsgo --noEmit"
|
"typecheck": "tsgo --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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 { z } from "zod"
|
||||||
import { eq } from "drizzle-orm"
|
import { and, eq, inArray, sql } from "drizzle-orm"
|
||||||
import { fn } from "./util/fn"
|
import { fn } from "./util/fn"
|
||||||
import { Database } from "./drizzle"
|
import { Database } from "./drizzle"
|
||||||
import { Identifier } from "./identifier"
|
import { Identifier } from "./identifier"
|
||||||
import { AccountTable } from "./schema/account.sql"
|
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 namespace Account {
|
||||||
export const create = fn(
|
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) =>
|
export const fromID = fn(z.string(), async (id) =>
|
||||||
Database.use((tx) =>
|
Database.use((tx) =>
|
||||||
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 }) {
|
export async function completeFromLiteSubscription(input: { workspaceID: string; userID: string }) {
|
||||||
return Database.transaction(async (tx) => {
|
return Database.transaction(async (tx) => {
|
||||||
const invitee = await tx
|
const invitee = await tx
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Key } from "./key"
|
|||||||
import { KeyTable } from "./schema/key.sql"
|
import { KeyTable } from "./schema/key.sql"
|
||||||
import { WorkspaceTable } from "./schema/workspace.sql"
|
import { WorkspaceTable } from "./schema/workspace.sql"
|
||||||
import { AuthTable } from "./schema/auth.sql"
|
import { AuthTable } from "./schema/auth.sql"
|
||||||
|
import { AccountTable } from "./schema/account.sql"
|
||||||
|
|
||||||
export namespace User {
|
export namespace User {
|
||||||
const assertNotSelf = (id: string) => {
|
const assertNotSelf = (id: string) => {
|
||||||
@@ -161,6 +162,13 @@ export namespace User {
|
|||||||
export const joinInvitedWorkspaces = fn(z.void(), async () => {
|
export const joinInvitedWorkspaces = fn(z.void(), async () => {
|
||||||
const account = Actor.assert("account")
|
const account = Actor.assert("account")
|
||||||
const invitations = await Database.use(async (tx) => {
|
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
|
const invitations = await tx
|
||||||
.select({
|
.select({
|
||||||
id: UserTable.id,
|
id: UserTable.id,
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import { Identifier } from "./identifier"
|
|||||||
import { UserTable } from "./schema/user.sql"
|
import { UserTable } from "./schema/user.sql"
|
||||||
import { BillingTable } from "./schema/billing.sql"
|
import { BillingTable } from "./schema/billing.sql"
|
||||||
import { WorkspaceTable } from "./schema/workspace.sql"
|
import { WorkspaceTable } from "./schema/workspace.sql"
|
||||||
|
import { AccountTable } from "./schema/account.sql"
|
||||||
import { Key } from "./key"
|
import { Key } from "./key"
|
||||||
import { eq, sql } from "drizzle-orm"
|
import { and, eq, isNull, sql } from "drizzle-orm"
|
||||||
|
|
||||||
export namespace Workspace {
|
export namespace Workspace {
|
||||||
export const create = fn(
|
export const create = fn(
|
||||||
@@ -19,6 +20,13 @@ export namespace Workspace {
|
|||||||
const workspaceID = Identifier.create("workspace")
|
const workspaceID = Identifier.create("workspace")
|
||||||
const userID = Identifier.create("user")
|
const userID = Identifier.create("user")
|
||||||
await Database.transaction(async (tx) => {
|
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({
|
await tx.insert(WorkspaceTable).values({
|
||||||
id: workspaceID,
|
id: workspaceID,
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -91,6 +91,7 @@
|
|||||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
||||||
"@opencode-ai/llm": "workspace:*",
|
"@opencode-ai/llm": "workspace:*",
|
||||||
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
"@opentelemetry/api": "1.9.0",
|
"@opentelemetry/api": "1.9.0",
|
||||||
"@opentelemetry/context-async-hooks": "2.6.1",
|
"@opentelemetry/context-async-hooks": "2.6.1",
|
||||||
|
|||||||
@@ -1,46 +1,17 @@
|
|||||||
export * as AgentV2 from "./agent"
|
export * as AgentV2 from "./agent"
|
||||||
|
|
||||||
import { Array, Context, Effect, Layer, Schema, Scope, Types } from "effect"
|
import { Array, Context, Effect, Layer, Types } from "effect"
|
||||||
import { ModelV2 } from "./model"
|
import { Agent } from "@opencode-ai/schema/agent"
|
||||||
import { PermissionSchema } from "./permission/schema"
|
|
||||||
import { ProviderV2 } from "./provider"
|
|
||||||
import { PositiveInt } from "./schema"
|
|
||||||
import { State } from "./state"
|
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 type ID = typeof ID.Type
|
||||||
export const defaultID = ID.make("build")
|
export const defaultID = ID.make("build")
|
||||||
|
|
||||||
export const Color = Schema.Union([
|
export const Color = Agent.Color
|
||||||
Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)),
|
|
||||||
Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
|
|
||||||
])
|
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("AgentV2.Info")({
|
export const Info = Agent.Info
|
||||||
id: ID,
|
export type Info = Agent.Info
|
||||||
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 interface Selection {
|
export interface Selection {
|
||||||
readonly id: ID
|
readonly id: ID
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export const layer = Layer.effect(
|
|||||||
if (provider.disabled) return false
|
if (provider.disabled) return false
|
||||||
if (typeof provider.request.body.apiKey === "string") return true
|
if (typeof provider.request.body.apiKey === "string") return true
|
||||||
if (integration?.connections.length) return true
|
if (integration?.connections.length) return true
|
||||||
return !integration
|
return provider.integrationID === undefined && !integration
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => {
|
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),
|
...ModelRequest.merge({ ...provider.request, generation: {}, options: {} }, model.request),
|
||||||
variant: model.request.variant,
|
variant: model.request.variant,
|
||||||
}
|
}
|
||||||
return new ModelV2.Info({
|
return ModelV2.Info.make({
|
||||||
...model,
|
...model,
|
||||||
api,
|
api,
|
||||||
request,
|
request,
|
||||||
@@ -184,7 +184,7 @@ export const layer = Layer.effect(
|
|||||||
available: Effect.fn("CatalogV2.provider.available")(function* () {
|
available: Effect.fn("CatalogV2.provider.available")(function* () {
|
||||||
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
|
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
|
||||||
return (yield* result.provider.all()).filter((provider) =>
|
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"
|
export * as CommandV2 from "./command"
|
||||||
|
|
||||||
import { Context, Effect, Layer, Schema, Types } from "effect"
|
import { Context, Effect, Layer, Types } from "effect"
|
||||||
import { ModelV2 } from "./model"
|
import { Command } from "@opencode-ai/schema/command"
|
||||||
import { State } from "./state"
|
import { State } from "./state"
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("CommandV2.Info")({
|
export const Info = Command.Info
|
||||||
name: Schema.String,
|
export type Info = Command.Info
|
||||||
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 type Data = {
|
export type Data = {
|
||||||
commands: Map<string, Types.DeepMutable<Info>>
|
commands: Map<string, Types.DeepMutable<Info>>
|
||||||
@@ -40,7 +34,7 @@ export const layer = Layer.effect(
|
|||||||
list: () => Array.from(draft.commands.values()) as Info[],
|
list: () => Array.from(draft.commands.values()) as Info[],
|
||||||
get: (name) => draft.commands.get(name),
|
get: (name) => draft.commands.get(name),
|
||||||
update: (name, update) => {
|
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)
|
if (!draft.commands.has(name)) draft.commands.set(name, current)
|
||||||
update(current)
|
update(current)
|
||||||
current.name = name
|
current.name = name
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ export * as Config from "./config"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { type ParseError, parse } from "jsonc-parser"
|
import { type ParseError, parse } from "jsonc-parser"
|
||||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||||
|
import { Permission } from "@opencode-ai/schema/permission"
|
||||||
import { FSUtil } from "./fs-util"
|
import { FSUtil } from "./fs-util"
|
||||||
import { Global } from "./global"
|
import { Global } from "./global"
|
||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { PermissionSchema } from "./permission/schema"
|
|
||||||
import { Policy } from "./policy"
|
import { Policy } from "./policy"
|
||||||
import { AbsolutePath } from "./schema"
|
import { AbsolutePath } from "./schema"
|
||||||
import { ConfigAgent } from "./config/agent"
|
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({
|
username: Schema.String.pipe(Schema.optional).annotate({
|
||||||
description: "Username displayed in conversations and used for telemetry identity",
|
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",
|
description: "Ordered tool permission rules applied to agent tool use",
|
||||||
}),
|
}),
|
||||||
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(Schema.optional).annotate({
|
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(Schema.optional).annotate({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export * as ConfigAgent from "./agent"
|
export * as ConfigAgent from "./agent"
|
||||||
|
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { PermissionSchema } from "../permission/schema"
|
import { Permission } from "@opencode-ai/schema/permission"
|
||||||
import { ConfigProvider } from "./provider"
|
import { ConfigProvider } from "./provider"
|
||||||
import { PositiveInt } from "../schema"
|
import { PositiveInt } from "../schema"
|
||||||
|
|
||||||
@@ -21,5 +21,5 @@ export class Info extends Schema.Class<Info>("ConfigV2.Agent")({
|
|||||||
color: Color.pipe(Schema.optional),
|
color: Color.pipe(Schema.optional),
|
||||||
steps: PositiveInt.pipe(Schema.optional),
|
steps: PositiveInt.pipe(Schema.optional),
|
||||||
disabled: Schema.Boolean.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(
|
entries.set(
|
||||||
name,
|
name,
|
||||||
local(entry)
|
local(entry)
|
||||||
? new Reference.LocalSource({
|
? Reference.LocalSource.make({
|
||||||
type: "local",
|
type: "local",
|
||||||
path: AbsolutePath.make(
|
path: AbsolutePath.make(
|
||||||
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
|
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,
|
description: typeof entry === "string" ? undefined : entry.description,
|
||||||
hidden: typeof entry === "string" ? undefined : entry.hidden,
|
hidden: typeof entry === "string" ? undefined : entry.hidden,
|
||||||
})
|
})
|
||||||
: new Reference.GitSource({
|
: Reference.GitSource.make({
|
||||||
type: "git",
|
type: "git",
|
||||||
repository: typeof entry === "string" ? entry : entry.repository,
|
repository: typeof entry === "string" ? entry : entry.repository,
|
||||||
branch: typeof entry === "string" ? undefined : entry.branch,
|
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 ?? []) : []))
|
const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
|
||||||
for (const directory of directories) {
|
for (const directory of directories) {
|
||||||
draft.source(
|
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(
|
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) {
|
for (const item of items) {
|
||||||
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
|
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
|
continue
|
||||||
}
|
}
|
||||||
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
|
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
|
||||||
draft.source(
|
draft.source(
|
||||||
new SkillV2.DirectorySource({
|
SkillV2.DirectorySource.make({
|
||||||
type: "directory",
|
type: "directory",
|
||||||
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
|
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 { asc, eq } from "drizzle-orm"
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
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 { Database } from "./database/database"
|
||||||
import { IntegrationSchema } from "./integration/schema"
|
|
||||||
import { NonNegativeInt, withStatics } from "./schema"
|
|
||||||
import { Identifier } from "./util/identifier"
|
|
||||||
import { CredentialTable } from "./credential/sql"
|
import { CredentialTable } from "./credential/sql"
|
||||||
|
|
||||||
export const ID = Schema.String.pipe(
|
export const ID = Credential.ID
|
||||||
Schema.brand("Credential.ID"),
|
export type ID = Credential.ID
|
||||||
withStatics((schema) => ({ create: () => schema.make("cred_" + Identifier.ascending()) })),
|
|
||||||
)
|
|
||||||
export type ID = typeof ID.Type
|
|
||||||
|
|
||||||
export class OAuth extends Schema.Class<OAuth>("Credential.OAuth")({
|
export const OAuth = Credential.OAuth
|
||||||
type: Schema.Literal("oauth"),
|
export type OAuth = Credential.OAuth
|
||||||
methodID: IntegrationSchema.MethodID,
|
|
||||||
refresh: Schema.String,
|
|
||||||
access: Schema.String,
|
|
||||||
expires: NonNegativeInt,
|
|
||||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class Key extends Schema.Class<Key>("Credential.Key")({
|
export const Key = Credential.Key
|
||||||
type: Schema.Literal("key"),
|
export type Key = Credential.Key
|
||||||
key: Schema.String,
|
|
||||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export const Value = Schema.Union([OAuth, Key])
|
export const Value = Credential.Value
|
||||||
.pipe(Schema.toTaggedUnion("type"))
|
export type Value = Credential.Value
|
||||||
.annotate({ identifier: "Credential.Value" })
|
|
||||||
export type Value = Schema.Schema.Type<typeof Value>
|
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("Credential.Info")({
|
export class Info extends Schema.Class<Info>("Credential.Info")({
|
||||||
id: ID,
|
id: ID,
|
||||||
integrationID: IntegrationSchema.ID,
|
integrationID: Integration.ID,
|
||||||
label: Schema.String,
|
label: Schema.String,
|
||||||
value: Value,
|
value: Value,
|
||||||
}) {}
|
}) {}
|
||||||
@@ -45,12 +30,12 @@ export interface Interface {
|
|||||||
/** Returns every stored credential. */
|
/** Returns every stored credential. */
|
||||||
readonly all: () => Effect.Effect<Info[]>
|
readonly all: () => Effect.Effect<Info[]>
|
||||||
/** Returns stored credentials belonging to one integration. */
|
/** 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. */
|
/** Returns one stored credential by ID. */
|
||||||
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||||
/** Replaces any credential for an integration and returns the new record. */
|
/** Replaces any credential for an integration and returns the new record. */
|
||||||
readonly create: (input: {
|
readonly create: (input: {
|
||||||
readonly integrationID: IntegrationSchema.ID
|
readonly integrationID: Integration.ID
|
||||||
readonly value: Value
|
readonly value: Value
|
||||||
readonly label?: string
|
readonly label?: string
|
||||||
}) => Effect.Effect<Info>
|
}) => Effect.Effect<Info>
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
|
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||||
import { Timestamps } from "../database/schema.sql"
|
import { Timestamps } from "../database/schema.sql"
|
||||||
import type { IntegrationSchema } from "../integration/schema"
|
|
||||||
import type { Credential } from "../credential"
|
import type { Credential } from "../credential"
|
||||||
|
|
||||||
export const CredentialTable = sqliteTable("credential", {
|
export const CredentialTable = sqliteTable("credential", {
|
||||||
id: text().$type<Credential.ID>().primaryKey(),
|
id: text().$type<Credential.ID>().primaryKey(),
|
||||||
integration_id: text().$type<IntegrationSchema.ID>(),
|
integration_id: text().$type<Credential.Info["integrationID"]>(),
|
||||||
label: text().notNull(),
|
label: text().notNull(),
|
||||||
value: text({ mode: "json" }).$type<Credential.Value>().notNull(),
|
value: text({ mode: "json" }).$type<Credential.Value>().notNull(),
|
||||||
connector_id: text(),
|
connector_id: text(),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { and, asc, eq, gt } from "drizzle-orm"
|
|||||||
import { Database } from "./database/database"
|
import { Database } from "./database/database"
|
||||||
import { EventSequenceTable, EventTable } from "./event/sql"
|
import { EventSequenceTable, EventTable } from "./event/sql"
|
||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { externalID, type ExternalID, withStatics } from "./schema"
|
import { withStatics } from "./schema"
|
||||||
import { Identifier } from "./util/identifier"
|
import { Identifier } from "./util/identifier"
|
||||||
import { LayerNode } from "./effect/layer-node"
|
import { LayerNode } from "./effect/layer-node"
|
||||||
import { isDeepStrictEqual } from "node:util"
|
import { isDeepStrictEqual } from "node:util"
|
||||||
@@ -14,7 +14,6 @@ export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe(
|
|||||||
Schema.brand("Event.ID"),
|
Schema.brand("Event.ID"),
|
||||||
withStatics((schema) => ({
|
withStatics((schema) => ({
|
||||||
create: () => schema.make("evt_" + Identifier.ascending()),
|
create: () => schema.make("evt_" + Identifier.ascending()),
|
||||||
fromExternal: (input: ExternalID) => schema.make(externalID("evt", input)),
|
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
export type ID = typeof ID.Type
|
export type ID = typeof ID.Type
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { FSUtil } from "./fs-util"
|
|||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { PositiveInt, RelativePath } from "./schema"
|
import { PositiveInt, RelativePath } from "./schema"
|
||||||
import { FileSystemSearch } from "./filesystem/search"
|
import { FileSystemSearch } from "./filesystem/search"
|
||||||
import { Entry, Match } from "./filesystem/schema"
|
import { Entry, Match } from "@opencode-ai/schema/filesystem"
|
||||||
export { Entry, Match, Submatch } from "./filesystem/schema"
|
export { Entry, Match, Submatch } from "@opencode-ai/schema/filesystem"
|
||||||
|
|
||||||
export const ReadInput = Schema.Struct({
|
export const ReadInput = Schema.Struct({
|
||||||
path: RelativePath,
|
path: RelativePath,
|
||||||
@@ -108,10 +108,9 @@ const baseLayer = Layer.effect(
|
|||||||
const absolute = path.join(target.absolute, item.name)
|
const absolute = path.join(target.absolute, item.name)
|
||||||
const relative = path.relative(target.directory, absolute)
|
const relative = path.relative(target.directory, absolute)
|
||||||
return [
|
return [
|
||||||
new Entry({
|
Entry.make({
|
||||||
path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
|
path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
|
||||||
type: item.type,
|
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,9 +59,8 @@ export const ripgrepLayer = Layer.effect(
|
|||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.map((result) =>
|
Effect.map((result) =>
|
||||||
result.map(
|
result.map((entry) =>
|
||||||
(entry) =>
|
FileSystem.Entry.make({
|
||||||
new FileSystem.Entry({
|
|
||||||
...entry,
|
...entry,
|
||||||
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
|
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
|
||||||
}),
|
}),
|
||||||
@@ -85,11 +84,10 @@ export const ripgrepLayer = Layer.effect(
|
|||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.map((result) =>
|
Effect.map((result) =>
|
||||||
result.map(
|
result.map((match) =>
|
||||||
(match) =>
|
FileSystem.Match.make({
|
||||||
new FileSystem.Match({
|
|
||||||
...match,
|
...match,
|
||||||
entry: new FileSystem.Entry({
|
entry: FileSystem.Entry.make({
|
||||||
...match.entry,
|
...match.entry,
|
||||||
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, match.entry.path))),
|
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, match.entry.path))),
|
||||||
}),
|
}),
|
||||||
@@ -110,12 +108,9 @@ export const ripgrepLayer = Layer.effect(
|
|||||||
return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
|
return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
|
||||||
const relative = item.target
|
const relative = item.target
|
||||||
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
|
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
|
||||||
const clean = type === "directory" ? relative.slice(0, -path.sep.length) : relative
|
return FileSystem.Entry.make({
|
||||||
const absolute = path.resolve(location.directory, clean)
|
|
||||||
return new FileSystem.Entry({
|
|
||||||
path: RelativePath.make(relative),
|
path: RelativePath.make(relative),
|
||||||
type,
|
type,
|
||||||
mime: type === "directory" ? "application/x-directory" : FSUtil.mimeType(absolute),
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
@@ -148,14 +143,12 @@ export const fffLayer = Layer.effect(
|
|||||||
pageSize: input.limit,
|
pageSize: input.limit,
|
||||||
})
|
})
|
||||||
if (!found.ok) throw found.error
|
if (!found.ok) throw found.error
|
||||||
return found.value.items.map((item) => {
|
return found.value.items.map((item) =>
|
||||||
const absolute = path.resolve(location.directory, item.relativePath)
|
FileSystem.Entry.make({
|
||||||
return new FileSystem.Entry({
|
|
||||||
path: RelativePath.make(item.relativePath.replaceAll("\\", "/")),
|
path: RelativePath.make(item.relativePath.replaceAll("\\", "/")),
|
||||||
type: "file",
|
type: "file",
|
||||||
mime: FSUtil.mimeType(absolute),
|
}),
|
||||||
})
|
)
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
grep: (input) =>
|
grep: (input) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
@@ -169,11 +162,10 @@ export const fffLayer = Layer.effect(
|
|||||||
if (!found.ok) throw found.error
|
if (!found.ok) throw found.error
|
||||||
return found.value.items.map((match) => {
|
return found.value.items.map((match) => {
|
||||||
const bytes = Buffer.from(match.lineContent)
|
const bytes = Buffer.from(match.lineContent)
|
||||||
return new FileSystem.Match({
|
return FileSystem.Match.make({
|
||||||
entry: new FileSystem.Entry({
|
entry: FileSystem.Entry.make({
|
||||||
path: RelativePath.make(match.relativePath.replaceAll("\\", "/")),
|
path: RelativePath.make(match.relativePath.replaceAll("\\", "/")),
|
||||||
type: "file",
|
type: "file",
|
||||||
mime: FSUtil.mimeType(match.relativePath),
|
|
||||||
}),
|
}),
|
||||||
line: match.lineNumber,
|
line: match.lineNumber,
|
||||||
offset: match.byteOffset,
|
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)
|
.sort((a, b) => b.score - a.score || a.path.length - b.path.length)
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
const relative = item.path.replaceAll("\\", "/").replace(/\/$/, "")
|
const relative = item.path.replaceAll("\\", "/").replace(/\/$/, "")
|
||||||
const absolute = path.resolve(location.directory, relative)
|
return FileSystem.Entry.make({
|
||||||
return new FileSystem.Entry({
|
|
||||||
path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
|
path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
|
||||||
type: item.type,
|
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", {
|
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
|
||||||
method: Schema.String,
|
method: Schema.String,
|
||||||
cause: Schema.optional(Schema.Defect()),
|
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
|
export type Error = PlatformError | FileSystemError
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { randomBytes } from "crypto"
|
import { create as createIdentifier } from "@opencode-ai/schema/identifier"
|
||||||
|
|
||||||
const prefixes = {
|
const prefixes = {
|
||||||
job: "job",
|
job: "job",
|
||||||
@@ -13,12 +13,6 @@ const prefixes = {
|
|||||||
workspace: "wrk",
|
workspace: "wrk",
|
||||||
} as const
|
} 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) {
|
export function ascending(prefix: keyof typeof prefixes, given?: string) {
|
||||||
return generateID(prefix, "ascending", given)
|
return generateID(prefix, "ascending", given)
|
||||||
}
|
}
|
||||||
@@ -38,35 +32,8 @@ function generateID(prefix: keyof typeof prefixes, direction: "descending" | "as
|
|||||||
return given
|
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 {
|
export function create(prefix: string, direction: "descending" | "ascending", timestamp?: number): string {
|
||||||
const currentTimestamp = timestamp ?? Date.now()
|
return prefix + "_" + createIdentifier(direction === "descending", timestamp)
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extract timestamp from an ascending ID. Does not work with descending IDs. */
|
/** Extract timestamp from an ascending ID. Does not work with descending IDs. */
|
||||||
|
|||||||
@@ -14,19 +14,19 @@ import {
|
|||||||
SynchronizedRef,
|
SynchronizedRef,
|
||||||
Types,
|
Types,
|
||||||
} from "effect"
|
} from "effect"
|
||||||
|
import { Integration } from "@opencode-ai/schema/integration"
|
||||||
import { Credential } from "./credential"
|
import { Credential } from "./credential"
|
||||||
import { IntegrationSchema } from "./integration/schema"
|
|
||||||
import { withStatics } from "./schema"
|
import { withStatics } from "./schema"
|
||||||
import { State } from "./state"
|
import { State } from "./state"
|
||||||
import { Identifier } from "./util/identifier"
|
import { Identifier } from "./util/identifier"
|
||||||
import { EventV2 } from "./event"
|
import { EventV2 } from "./event"
|
||||||
import { IntegrationConnection } from "./integration/connection"
|
import { IntegrationConnection } from "./integration/connection"
|
||||||
|
|
||||||
export const ID = IntegrationSchema.ID
|
export const ID = Integration.ID
|
||||||
export type ID = IntegrationSchema.ID
|
export type ID = Integration.ID
|
||||||
|
|
||||||
export const MethodID = IntegrationSchema.MethodID
|
export const MethodID = Integration.MethodID
|
||||||
export type MethodID = IntegrationSchema.MethodID
|
export type MethodID = Integration.MethodID
|
||||||
|
|
||||||
export const AttemptID = Schema.String.pipe(
|
export const AttemptID = Schema.String.pipe(
|
||||||
Schema.brand("Integration.AttemptID"),
|
Schema.brand("Integration.AttemptID"),
|
||||||
@@ -34,64 +34,29 @@ export const AttemptID = Schema.String.pipe(
|
|||||||
)
|
)
|
||||||
export type AttemptID = typeof AttemptID.Type
|
export type AttemptID = typeof AttemptID.Type
|
||||||
|
|
||||||
export const When = Schema.Struct({
|
export const When = Integration.When
|
||||||
key: Schema.String,
|
export type When = Integration.When
|
||||||
op: Schema.Literals(["eq", "neq"]),
|
|
||||||
value: Schema.String,
|
|
||||||
}).annotate({ identifier: "Integration.When" })
|
|
||||||
export type When = typeof When.Type
|
|
||||||
|
|
||||||
export const TextPrompt = Schema.Struct({
|
export const TextPrompt = Integration.TextPrompt
|
||||||
type: Schema.Literal("text"),
|
export type TextPrompt = Integration.TextPrompt
|
||||||
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 SelectPrompt = Schema.Struct({
|
export const SelectPrompt = Integration.SelectPrompt
|
||||||
type: Schema.Literal("select"),
|
export type SelectPrompt = Integration.SelectPrompt
|
||||||
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 Prompt = Schema.Union([TextPrompt, SelectPrompt]).pipe(Schema.toTaggedUnion("type"))
|
export const Prompt = Integration.Prompt
|
||||||
export type Prompt = typeof Prompt.Type
|
export type Prompt = Integration.Prompt
|
||||||
|
|
||||||
export const OAuthMethod = Schema.Struct({
|
export const OAuthMethod = Integration.OAuthMethod
|
||||||
id: MethodID,
|
export type OAuthMethod = Integration.OAuthMethod
|
||||||
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 KeyMethod = Schema.Struct({
|
export const KeyMethod = Integration.KeyMethod
|
||||||
type: Schema.Literal("key"),
|
export type KeyMethod = Integration.KeyMethod
|
||||||
label: Schema.optional(Schema.String),
|
|
||||||
}).annotate({ identifier: "Integration.KeyMethod" })
|
|
||||||
export type KeyMethod = typeof KeyMethod.Type
|
|
||||||
|
|
||||||
export const EnvMethod = Schema.Struct({
|
export const EnvMethod = Integration.EnvMethod
|
||||||
type: Schema.Literal("env"),
|
export type EnvMethod = Integration.EnvMethod
|
||||||
names: Schema.mutable(Schema.Array(Schema.String)),
|
|
||||||
}).annotate({ identifier: "Integration.EnvMethod" })
|
|
||||||
export type EnvMethod = typeof EnvMethod.Type
|
|
||||||
|
|
||||||
export const Method = Schema.Union([OAuthMethod, KeyMethod, EnvMethod]).pipe(Schema.toTaggedUnion("type"))
|
export const Method = Integration.Method
|
||||||
export type Method = typeof Method.Type
|
export type Method = Integration.Method
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("Integration.Info")({
|
export class Info extends Schema.Class<Info>("Integration.Info")({
|
||||||
id: ID,
|
id: ID,
|
||||||
@@ -100,7 +65,8 @@ export class Info extends Schema.Class<Info>("Integration.Info")({
|
|||||||
connections: Schema.mutable(Schema.Array(IntegrationConnection.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 = {
|
export type OAuthAuthorization = {
|
||||||
readonly url: string
|
readonly url: string
|
||||||
@@ -108,11 +74,11 @@ export type OAuthAuthorization = {
|
|||||||
} & (
|
} & (
|
||||||
| {
|
| {
|
||||||
readonly mode: "auto"
|
readonly mode: "auto"
|
||||||
readonly callback: Effect.Effect<Credential.Value, unknown>
|
readonly callback: Effect.Effect<Credential.OAuth, unknown>
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly mode: "code"
|
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 method: OAuthMethod
|
||||||
readonly authorize: (inputs: Inputs) => Effect.Effect<OAuthAuthorization, unknown, Scope.Scope>
|
readonly authorize: (inputs: Inputs) => Effect.Effect<OAuthAuthorization, unknown, Scope.Scope>
|
||||||
readonly refresh?: (credential: Credential.OAuth) => Effect.Effect<Credential.OAuth, unknown>
|
readonly refresh?: (credential: Credential.OAuth) => Effect.Effect<Credential.OAuth, unknown>
|
||||||
|
readonly label?: (credential: Credential.OAuth) => string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KeyImplementation {
|
export interface KeyImplementation {
|
||||||
@@ -135,10 +102,6 @@ export interface EnvImplementation {
|
|||||||
|
|
||||||
export type Implementation = OAuthImplementation | KeyImplementation | 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")({
|
export class Attempt extends Schema.Class<Attempt>("Integration.Attempt")({
|
||||||
attemptID: AttemptID,
|
attemptID: AttemptID,
|
||||||
url: Schema.String,
|
url: Schema.String,
|
||||||
@@ -178,12 +141,14 @@ export const Event = {
|
|||||||
type: "integration.updated",
|
type: "integration.updated",
|
||||||
schema: {},
|
schema: {},
|
||||||
}),
|
}),
|
||||||
|
ConnectionUpdated: EventV2.define({
|
||||||
|
type: "integration.connection.updated",
|
||||||
|
schema: { integrationID: ID },
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Ref = {
|
export const Ref = Integration.Ref
|
||||||
id: ID
|
export type Ref = Integration.Ref
|
||||||
name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Entry = {
|
type Entry = {
|
||||||
ref: Types.DeepMutable<Ref>
|
ref: Types.DeepMutable<Ref>
|
||||||
@@ -215,7 +180,11 @@ export interface Interface extends State.Transformable<Draft> {
|
|||||||
readonly list: () => Effect.Effect<Info[]>
|
readonly list: () => Effect.Effect<Info[]>
|
||||||
readonly connection: {
|
readonly connection: {
|
||||||
/** Returns the active connection for one integration. */
|
/** 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. */
|
/** Runs a key method and stores the resulting credential. */
|
||||||
readonly key: (input: {
|
readonly key: (input: {
|
||||||
/** Integration receiving the credential. */
|
/** Integration receiving the credential. */
|
||||||
@@ -385,7 +354,7 @@ export const locationLayer = Layer.effect(
|
|||||||
return error instanceof Error ? error.message : String(error)
|
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 now = yield* Clock.currentTimeMillis
|
||||||
const result = yield* SynchronizedRef.modify(attempts, (current) => {
|
const result = yield* SynchronizedRef.modify(attempts, (current) => {
|
||||||
const attempt = current.get(attemptID)
|
const attempt = current.get(attemptID)
|
||||||
@@ -397,14 +366,13 @@ export const locationLayer = Layer.effect(
|
|||||||
})
|
})
|
||||||
if (!result) return
|
if (!result) return
|
||||||
if (Exit.isSuccess(exit)) {
|
if (Exit.isSuccess(exit)) {
|
||||||
|
const implementation = state.get().integrations.get(result.integrationID)?.implementations.get(result.methodID)
|
||||||
yield* credentials.create({
|
yield* credentials.create({
|
||||||
integrationID: result.integrationID,
|
integrationID: result.integrationID,
|
||||||
label: result.label,
|
label: result.label ?? implementation?.label?.(exit.value),
|
||||||
value:
|
value: exit.value,
|
||||||
exit.value.type === "oauth"
|
|
||||||
? new Credential.OAuth({ ...exit.value, methodID: result.methodID })
|
|
||||||
: exit.value,
|
|
||||||
})
|
})
|
||||||
|
yield* events.publish(Event.ConnectionUpdated, { integrationID: result.integrationID })
|
||||||
yield* events.publish(Event.Updated, {})
|
yield* events.publish(Event.Updated, {})
|
||||||
}
|
}
|
||||||
yield* close(result.scope)
|
yield* close(result.scope)
|
||||||
@@ -445,10 +413,29 @@ export const locationLayer = Layer.effect(
|
|||||||
).toSorted((a, b) => a.name.localeCompare(b.name))
|
).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||||
}),
|
}),
|
||||||
connection: {
|
connection: {
|
||||||
forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) {
|
active: Effect.fn("Integration.connection.active")(function* (id) {
|
||||||
const entry = state.get().integrations.get(id)
|
const entry = state.get().integrations.get(id)
|
||||||
return resolveConnections(entry, yield* credentials.list(id))[0]
|
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) {
|
key: Effect.fn("Integration.connection.key")(function* (input) {
|
||||||
const method = state
|
const method = state
|
||||||
.get()
|
.get()
|
||||||
@@ -458,8 +445,9 @@ export const locationLayer = Layer.effect(
|
|||||||
yield* credentials.create({
|
yield* credentials.create({
|
||||||
integrationID: input.integrationID,
|
integrationID: input.integrationID,
|
||||||
label: input.label,
|
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, {})
|
yield* events.publish(Event.Updated, {})
|
||||||
}),
|
}),
|
||||||
oauth: Effect.fn("Integration.connection.oauth")(function* (input) {
|
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) {
|
update: Effect.fn("Integration.connection.update")(function* (credentialID, updates) {
|
||||||
|
const credential = yield* credentials.get(credentialID)
|
||||||
yield* credentials.update(credentialID, updates)
|
yield* credentials.update(credentialID, updates)
|
||||||
|
if (credential) {
|
||||||
|
yield* events.publish(Event.ConnectionUpdated, { integrationID: credential.integrationID })
|
||||||
|
}
|
||||||
yield* events.publish(Event.Updated, {})
|
yield* events.publish(Event.Updated, {})
|
||||||
}),
|
}),
|
||||||
remove: Effect.fn("Integration.connection.remove")(function* (credentialID) {
|
remove: Effect.fn("Integration.connection.remove")(function* (credentialID) {
|
||||||
|
const credential = yield* credentials.get(credentialID)
|
||||||
yield* credentials.remove(credentialID)
|
yield* credentials.remove(credentialID)
|
||||||
|
if (credential) {
|
||||||
|
yield* events.publish(Event.ConnectionUpdated, { integrationID: credential.integrationID })
|
||||||
|
}
|
||||||
yield* events.publish(Event.Updated, {})
|
yield* events.publish(Event.Updated, {})
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,22 +1,12 @@
|
|||||||
export * as IntegrationConnection from "./connection"
|
export * as IntegrationConnection from "./connection"
|
||||||
|
|
||||||
import { Schema } from "effect"
|
import { Connection } from "@opencode-ai/schema/connection"
|
||||||
import { Credential } from "../credential"
|
|
||||||
|
|
||||||
export const CredentialInfo = Schema.Struct({
|
export const CredentialInfo = Connection.CredentialInfo
|
||||||
type: Schema.Literal("credential"),
|
export type CredentialInfo = Connection.CredentialInfo
|
||||||
id: Credential.ID,
|
|
||||||
label: Schema.String,
|
|
||||||
}).annotate({ identifier: "Connection.CredentialInfo" })
|
|
||||||
export type CredentialInfo = typeof CredentialInfo.Type
|
|
||||||
|
|
||||||
export const EnvInfo = Schema.Struct({
|
export const EnvInfo = Connection.EnvInfo
|
||||||
type: Schema.Literal("env"),
|
export type EnvInfo = Connection.EnvInfo
|
||||||
name: Schema.String,
|
|
||||||
}).annotate({ identifier: "Connection.EnvInfo" })
|
|
||||||
export type EnvInfo = typeof EnvInfo.Type
|
|
||||||
|
|
||||||
export const Info = Schema.Union([CredentialInfo, EnvInfo])
|
export const Info = Connection.Info
|
||||||
.pipe(Schema.toTaggedUnion("type"))
|
export type Info = Connection.Info
|
||||||
.annotate({ identifier: "Connection.Info" })
|
|
||||||
export type Info = typeof Info.Type
|
|
||||||
|
|||||||
@@ -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 { Context, Effect, Layer, Schema } from "effect"
|
||||||
|
import { Ref } from "@opencode-ai/schema/location"
|
||||||
import { Project } from "./project"
|
import { Project } from "./project"
|
||||||
import { AbsolutePath, optionalOmitUndefined } from "./schema"
|
import { AbsolutePath, optionalOmitUndefined } from "./schema"
|
||||||
import { WorkspaceV2 } from "./workspace"
|
import { WorkspaceV2 } from "./workspace"
|
||||||
|
|
||||||
export * as Location from "./location"
|
export * as Location from "./location"
|
||||||
|
|
||||||
export class Ref extends Schema.Class<Ref>("Location.Ref")({
|
export { Ref }
|
||||||
directory: AbsolutePath,
|
|
||||||
workspaceID: Schema.optional(WorkspaceV2.ID).pipe(Schema.withConstructorDefault(Effect.succeed(undefined))),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("Location.Info")({
|
export class Info extends Schema.Class<Info>("Location.Info")({
|
||||||
directory: AbsolutePath,
|
directory: AbsolutePath,
|
||||||
|
|||||||
@@ -1,34 +1,12 @@
|
|||||||
export * as ModelRequest from "./model-request"
|
export * as ModelRequest from "./model-request"
|
||||||
|
|
||||||
import { Effect, Schema } from "effect"
|
import { ModelRequest } from "@opencode-ai/schema/model-request"
|
||||||
|
|
||||||
export const Generation = Schema.Struct({
|
export const Generation = ModelRequest.Generation
|
||||||
maxTokens: Schema.Number.pipe(Schema.optional),
|
export type Generation = ModelRequest.Generation
|
||||||
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 Request = Schema.Struct({
|
export const Request = ModelRequest.Request
|
||||||
headers: Schema.Record(Schema.String, Schema.String),
|
export type Request = ModelRequest.Request
|
||||||
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
|
|
||||||
|
|
||||||
interface MutableRequest {
|
interface MutableRequest {
|
||||||
headers: Record<string, string>
|
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 { 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 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
|
export type VariantID = typeof VariantID.Type
|
||||||
|
|
||||||
// Grouping of models, eg claude opus, claude sonnet
|
// Grouping of models, eg claude opus, claude sonnet
|
||||||
export const Family = Schema.String.pipe(Schema.brand("Family"))
|
export const Family = Model.Family
|
||||||
export type Family = typeof Family.Type
|
export type Family = Model.Family
|
||||||
|
|
||||||
export const Capabilities = Schema.Struct({
|
export const Capabilities = Model.Capabilities
|
||||||
tools: Schema.Boolean,
|
export type Capabilities = Model.Capabilities
|
||||||
// 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 Cost = Schema.Struct({
|
export const Cost = Model.Cost
|
||||||
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 Ref = Schema.Struct({
|
export const Ref = Model.Ref
|
||||||
id: ID,
|
|
||||||
providerID: ProviderV2.ID,
|
|
||||||
variant: VariantID.pipe(Schema.optional),
|
|
||||||
})
|
|
||||||
export type Ref = typeof Ref.Type
|
export type Ref = typeof Ref.Type
|
||||||
|
|
||||||
export const Api = Schema.Union([
|
export const Api = Model.Api
|
||||||
Schema.Struct({
|
export type Api = Model.Api
|
||||||
id: ID,
|
|
||||||
...ProviderV2.AISDK.fields,
|
|
||||||
}),
|
|
||||||
Schema.Struct({
|
|
||||||
id: ID,
|
|
||||||
...ProviderV2.Native.fields,
|
|
||||||
}),
|
|
||||||
]).pipe(Schema.toTaggedUnion("type"))
|
|
||||||
export type Api = typeof Api.Type
|
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("ModelV2.Info")({
|
export const Info = Model.Info
|
||||||
id: ID,
|
export type Info = Model.Info
|
||||||
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 type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & {
|
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & {
|
||||||
api: ProviderV2.MutableApi<Api>
|
api: ProviderV2.MutableApi<Api>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export * as PermissionV2 from "./permission"
|
export * as PermissionV2 from "./permission"
|
||||||
|
|
||||||
import { Context, Deferred, Effect as EffectRuntime, Layer, Schema } from "effect"
|
import { Context, Deferred, Effect as EffectRuntime, Layer, Schema } from "effect"
|
||||||
|
import { Permission } from "@opencode-ai/schema/permission"
|
||||||
import { EventV2 } from "./event"
|
import { EventV2 } from "./event"
|
||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { AgentV2 } from "./agent"
|
import { AgentV2 } from "./agent"
|
||||||
@@ -9,14 +10,10 @@ import { SessionStore } from "./session/store"
|
|||||||
import { withStatics } from "./schema"
|
import { withStatics } from "./schema"
|
||||||
import { Identifier } from "./util/identifier"
|
import { Identifier } from "./util/identifier"
|
||||||
import { Wildcard } from "./util/wildcard"
|
import { Wildcard } from "./util/wildcard"
|
||||||
import { PermissionSchema } from "./permission/schema"
|
|
||||||
import { PermissionSaved } from "./permission/saved"
|
import { PermissionSaved } from "./permission/saved"
|
||||||
|
|
||||||
export { Effect, Rule, Ruleset } from "./permission/schema"
|
export { Effect, Rule, Ruleset } from "@opencode-ai/schema/permission"
|
||||||
type Effect = PermissionSchema.Effect
|
const missingAgentPermissions: Permission.Ruleset = [{ action: "*", resource: "*", effect: "deny" }]
|
||||||
type Rule = PermissionSchema.Rule
|
|
||||||
type Ruleset = PermissionSchema.Ruleset
|
|
||||||
const missingAgentPermissions: Ruleset = [{ action: "*", resource: "*", effect: "deny" }]
|
|
||||||
|
|
||||||
export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe(
|
export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe(
|
||||||
Schema.brand("PermissionV2.ID"),
|
Schema.brand("PermissionV2.ID"),
|
||||||
@@ -67,7 +64,7 @@ export type ReplyInput = typeof ReplyInput.Type
|
|||||||
|
|
||||||
export const AskResult = Schema.Struct({
|
export const AskResult = Schema.Struct({
|
||||||
id: ID,
|
id: ID,
|
||||||
effect: PermissionSchema.Effect,
|
effect: Permission.Effect,
|
||||||
}).annotate({ identifier: "PermissionV2.AskResult" })
|
}).annotate({ identifier: "PermissionV2.AskResult" })
|
||||||
export type AskResult = typeof AskResult.Type
|
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", {
|
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionV2.DeniedError", {
|
||||||
rules: PermissionSchema.Ruleset,
|
rules: Permission.Ruleset,
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("PermissionV2.NotFoundError", {
|
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 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 (
|
return (
|
||||||
rulesets
|
rulesets
|
||||||
.flat()
|
.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()
|
return rulesets.flat()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +153,7 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
const savedRules = EffectRuntime.fnUntraced(function* () {
|
const savedRules = EffectRuntime.fnUntraced(function* () {
|
||||||
return (yield* saved.list({ projectID: location.project.id })).map(
|
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
|
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")
|
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))
|
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 }
|
if (denied(input, rules)) return { effect: "deny" as const, rules }
|
||||||
const all = [...rules, ...(yield* savedRules())]
|
const all = [...rules, ...(yield* savedRules())]
|
||||||
const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect)
|
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 }
|
return { effect, rules: all }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { AgentV2 } from "../agent"
|
|||||||
import { AISDK } from "../aisdk"
|
import { AISDK } from "../aisdk"
|
||||||
import { Catalog } from "../catalog"
|
import { Catalog } from "../catalog"
|
||||||
import { CommandV2 } from "../command"
|
import { CommandV2 } from "../command"
|
||||||
|
import { Credential } from "../credential"
|
||||||
import { Integration } from "../integration"
|
import { Integration } from "../integration"
|
||||||
import { ModelV2 } from "../model"
|
import { ModelV2 } from "../model"
|
||||||
import { PluginV2 } from "../plugin"
|
import { PluginV2 } from "../plugin"
|
||||||
@@ -97,6 +98,13 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||||||
},
|
},
|
||||||
integration: {
|
integration: {
|
||||||
reload: integration.reload,
|
reload: integration.reload,
|
||||||
|
connection: {
|
||||||
|
active: (id) => integration.connection.active(Integration.ID.make(id)),
|
||||||
|
resolve: (connection) =>
|
||||||
|
integration.connection.resolve(
|
||||||
|
connection.type === "credential" ? { ...connection, id: Credential.ID.make(connection.id) } : connection,
|
||||||
|
),
|
||||||
|
},
|
||||||
transform: (callback) =>
|
transform: (callback) =>
|
||||||
integration.transform((draft) =>
|
integration.transform((draft) =>
|
||||||
callback({
|
callback({
|
||||||
@@ -107,6 +115,59 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||||||
method: {
|
method: {
|
||||||
list: (id) => draft.method.list(Integration.ID.make(id)),
|
list: (id) => draft.method.list(Integration.ID.make(id)),
|
||||||
update: (input) => {
|
update: (input) => {
|
||||||
|
if ("authorize" in input) {
|
||||||
|
const methodID = Integration.MethodID.make(input.method.id)
|
||||||
|
const refresh = input.refresh
|
||||||
|
draft.method.update({
|
||||||
|
integrationID: Integration.ID.make(input.integrationID),
|
||||||
|
method: { ...input.method, id: methodID },
|
||||||
|
authorize: (inputs) =>
|
||||||
|
input.authorize(inputs).pipe(
|
||||||
|
Effect.map((authorization) => {
|
||||||
|
if (authorization.mode === "auto") {
|
||||||
|
return {
|
||||||
|
...authorization,
|
||||||
|
callback: authorization.callback.pipe(
|
||||||
|
Effect.map((credential) =>
|
||||||
|
Credential.OAuth.make({
|
||||||
|
...credential,
|
||||||
|
methodID: Integration.MethodID.make(credential.methodID),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...authorization,
|
||||||
|
callback: (code: string) =>
|
||||||
|
authorization.callback(code).pipe(
|
||||||
|
Effect.map((credential) =>
|
||||||
|
Credential.OAuth.make({
|
||||||
|
...credential,
|
||||||
|
methodID: Integration.MethodID.make(credential.methodID),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
...(refresh
|
||||||
|
? {
|
||||||
|
refresh: (value: Credential.OAuth) =>
|
||||||
|
refresh(value).pipe(
|
||||||
|
Effect.map((next) =>
|
||||||
|
Credential.OAuth.make({
|
||||||
|
...next,
|
||||||
|
methodID: Integration.MethodID.make(next.methodID),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(input.label ? { label: input.label } : {}),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
if (input.method.type === "env") {
|
if (input.method.type === "env") {
|
||||||
draft.method.update({
|
draft.method.update({
|
||||||
integrationID: Integration.ID.make(input.integrationID),
|
integrationID: Integration.ID.make(input.integrationID),
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { Npm } from "../npm"
|
|||||||
import { PluginV2 } from "../plugin"
|
import { PluginV2 } from "../plugin"
|
||||||
import { Reference } from "../reference"
|
import { Reference } from "../reference"
|
||||||
import { SkillV2 } from "../skill"
|
import { SkillV2 } from "../skill"
|
||||||
|
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
|
||||||
import { AgentPlugin } from "./agent"
|
import { AgentPlugin } from "./agent"
|
||||||
import { CommandPlugin } from "./command"
|
import { CommandPlugin } from "./command"
|
||||||
import { ModelsDevPlugin } from "./models-dev"
|
import { ModelsDevPlugin } from "./models-dev"
|
||||||
@@ -38,6 +39,7 @@ export type Requirements =
|
|||||||
| FileSystem.Service
|
| FileSystem.Service
|
||||||
| FSUtil.Service
|
| FSUtil.Service
|
||||||
| Global.Service
|
| Global.Service
|
||||||
|
| HttpClient.HttpClient
|
||||||
| Integration.Service
|
| Integration.Service
|
||||||
| Location.Service
|
| Location.Service
|
||||||
| ModelsDev.Service
|
| ModelsDev.Service
|
||||||
@@ -69,6 +71,7 @@ export const locationLayer = Layer.effectDiscard(
|
|||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const filesystem = yield* FileSystem.Service
|
const filesystem = yield* FileSystem.Service
|
||||||
const global = yield* Global.Service
|
const global = yield* Global.Service
|
||||||
|
const http = yield* HttpClient.HttpClient
|
||||||
const skill = yield* SkillV2.Service
|
const skill = yield* SkillV2.Service
|
||||||
const reference = yield* Reference.Service
|
const reference = yield* Reference.Service
|
||||||
const add = <R>(input: Plugin<R>) => {
|
const add = <R>(input: Plugin<R>) => {
|
||||||
@@ -90,6 +93,7 @@ export const locationLayer = Layer.effectDiscard(
|
|||||||
Effect.provideService(FSUtil.Service, fs),
|
Effect.provideService(FSUtil.Service, fs),
|
||||||
Effect.provideService(FileSystem.Service, filesystem),
|
Effect.provideService(FileSystem.Service, filesystem),
|
||||||
Effect.provideService(Global.Service, global),
|
Effect.provideService(Global.Service, global),
|
||||||
|
Effect.provideService(HttpClient.HttpClient, http),
|
||||||
Effect.provideService(SkillV2.Service, skill),
|
Effect.provideService(SkillV2.Service, skill),
|
||||||
Effect.provideService(Reference.Service, reference),
|
Effect.provideService(Reference.Service, reference),
|
||||||
),
|
),
|
||||||
@@ -115,4 +119,5 @@ export const locationLayer = Layer.effectDiscard(
|
|||||||
Layer.provideMerge(PluginV2.locationLayer),
|
Layer.provideMerge(PluginV2.locationLayer),
|
||||||
Layer.provideMerge(Config.locationLayer),
|
Layer.provideMerge(Config.locationLayer),
|
||||||
Layer.provideMerge(FileSystem.locationLayer),
|
Layer.provideMerge(FileSystem.locationLayer),
|
||||||
|
Layer.provideMerge(FetchHttpClient.layer),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ export function fromPromise(plugin: Plugin) {
|
|||||||
integration: {
|
integration: {
|
||||||
transform: transform(host.integration),
|
transform: transform(host.integration),
|
||||||
reload: () => run(host.integration.reload()),
|
reload: () => run(host.integration.reload()),
|
||||||
|
connection: {
|
||||||
|
active: (id) => Effect.runPromiseWith(context)(host.integration.connection.active(id)),
|
||||||
|
resolve: (connection) => Effect.runPromiseWith(context)(host.integration.connection.resolve(connection)),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugin: {
|
plugin: {
|
||||||
add: (input) => {
|
add: (input) => {
|
||||||
|
|||||||
@@ -1,257 +0,0 @@
|
|||||||
import { createServer } from "node:http"
|
|
||||||
import { Deferred, Effect } from "effect"
|
|
||||||
import { Integration } from "../../integration"
|
|
||||||
import { Credential } from "../../credential"
|
|
||||||
import { InstallationVersion } from "../../installation/version"
|
|
||||||
|
|
||||||
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
|
||||||
const issuer = "https://auth.openai.com"
|
|
||||||
const callbackPort = 1455
|
|
||||||
const pollingSafetyMargin = 3000
|
|
||||||
|
|
||||||
type Pkce = {
|
|
||||||
verifier: string
|
|
||||||
challenge: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type TokenResponse = {
|
|
||||||
id_token: string
|
|
||||||
access_token: string
|
|
||||||
refresh_token: string
|
|
||||||
expires_in?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type Claims = {
|
|
||||||
chatgpt_account_id?: string
|
|
||||||
organizations?: Array<{ id: string }>
|
|
||||||
"https://api.openai.com/auth"?: { chatgpt_account_id?: string }
|
|
||||||
}
|
|
||||||
|
|
||||||
const browserMethodID = Integration.MethodID.make("chatgpt-browser")
|
|
||||||
const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
|
|
||||||
|
|
||||||
export const browser = {
|
|
||||||
integrationID: Integration.ID.make("openai"),
|
|
||||||
method: {
|
|
||||||
id: browserMethodID,
|
|
||||||
type: "oauth",
|
|
||||||
label: "ChatGPT Pro/Plus (browser)",
|
|
||||||
},
|
|
||||||
authorize: () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const pkce = yield* Effect.promise(generatePKCE)
|
|
||||||
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
|
|
||||||
const code = yield* Deferred.make<string, Error>()
|
|
||||||
const redirect = `http://localhost:${callbackPort}/auth/callback`
|
|
||||||
const server = createServer((request, response) => {
|
|
||||||
const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`)
|
|
||||||
if (url.pathname !== "/auth/callback") {
|
|
||||||
response.writeHead(404).end("Not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
|
|
||||||
const value = url.searchParams.get("code")
|
|
||||||
if (error) {
|
|
||||||
Effect.runFork(Deferred.fail(code, new Error(error)))
|
|
||||||
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(error))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!value || url.searchParams.get("state") !== state) {
|
|
||||||
const message = value ? "Invalid OAuth state" : "Missing authorization code"
|
|
||||||
Effect.runFork(Deferred.fail(code, new Error(message)))
|
|
||||||
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(message))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Effect.runFork(Deferred.succeed(code, value))
|
|
||||||
response.writeHead(200, { "Content-Type": "text/html" }).end(successPage)
|
|
||||||
})
|
|
||||||
yield* Effect.callback<void, Error>((resume) => {
|
|
||||||
server.once("error", (error) => resume(Effect.fail(error)))
|
|
||||||
server.listen(callbackPort, "localhost", () => resume(Effect.void))
|
|
||||||
})
|
|
||||||
yield* Effect.addFinalizer(() =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
server.close()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
mode: "auto" as const,
|
|
||||||
url: authorizeURL(redirect, pkce, state),
|
|
||||||
instructions: "Complete authorization in your browser. This window will close automatically.",
|
|
||||||
callback: Deferred.await(code).pipe(
|
|
||||||
Effect.flatMap((value) => exchange(value, redirect, pkce)),
|
|
||||||
Effect.map((tokens) => credential(browserMethodID, tokens)),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
refresh: (value) => refresh(value),
|
|
||||||
} satisfies Integration.OAuthImplementation
|
|
||||||
|
|
||||||
export const headless = {
|
|
||||||
integrationID: Integration.ID.make("openai"),
|
|
||||||
method: {
|
|
||||||
id: headlessMethodID,
|
|
||||||
type: "oauth",
|
|
||||||
label: "ChatGPT Pro/Plus (headless)",
|
|
||||||
},
|
|
||||||
authorize: () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>(
|
|
||||||
`${issuer}/api/accounts/deviceauth/usercode`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: headers("application/json"),
|
|
||||||
body: JSON.stringify({ client_id: clientID }),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
const interval = Math.max(Number.parseInt(device.interval) || 5, 1) * 1000
|
|
||||||
return {
|
|
||||||
mode: "auto" as const,
|
|
||||||
url: `${issuer}/codex/device`,
|
|
||||||
instructions: `Enter code: ${device.user_code}`,
|
|
||||||
callback: Effect.gen(function* () {
|
|
||||||
while (true) {
|
|
||||||
const response = yield* Effect.tryPromise({
|
|
||||||
try: (signal) =>
|
|
||||||
fetch(`${issuer}/api/accounts/deviceauth/token`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: headers("application/json"),
|
|
||||||
body: JSON.stringify({ device_auth_id: device.device_auth_id, user_code: device.user_code }),
|
|
||||||
signal,
|
|
||||||
}),
|
|
||||||
catch: (cause) => cause,
|
|
||||||
})
|
|
||||||
if (response.ok) {
|
|
||||||
const data = (yield* Effect.promise(() => response.json())) as {
|
|
||||||
authorization_code: string
|
|
||||||
code_verifier: string
|
|
||||||
}
|
|
||||||
return credential(
|
|
||||||
headlessMethodID,
|
|
||||||
yield* exchange(data.authorization_code, `${issuer}/deviceauth/callback`, {
|
|
||||||
verifier: data.code_verifier,
|
|
||||||
challenge: "",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (response.status !== 403 && response.status !== 404) {
|
|
||||||
return yield* Effect.fail(new Error(`Device authorization failed: ${response.status}`))
|
|
||||||
}
|
|
||||||
yield* Effect.sleep(interval + pollingSafetyMargin)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
refresh: (value) => refresh(value),
|
|
||||||
} satisfies Integration.OAuthImplementation
|
|
||||||
|
|
||||||
function headers(contentType: string) {
|
|
||||||
return { "Content-Type": contentType, "User-Agent": `opencode/${InstallationVersion}` }
|
|
||||||
}
|
|
||||||
|
|
||||||
function exchange(code: string, redirect: string, pkce: Pkce) {
|
|
||||||
return request<TokenResponse>(`${issuer}/oauth/token`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: headers("application/x-www-form-urlencoded"),
|
|
||||||
body: new URLSearchParams({
|
|
||||||
grant_type: "authorization_code",
|
|
||||||
code,
|
|
||||||
redirect_uri: redirect,
|
|
||||||
client_id: clientID,
|
|
||||||
code_verifier: pkce.verifier,
|
|
||||||
}).toString(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function refresh(value: Credential.OAuth) {
|
|
||||||
return request<TokenResponse>(`${issuer}/oauth/token`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: headers("application/x-www-form-urlencoded"),
|
|
||||||
body: new URLSearchParams({
|
|
||||||
grant_type: "refresh_token",
|
|
||||||
refresh_token: value.refresh,
|
|
||||||
client_id: clientID,
|
|
||||||
}).toString(),
|
|
||||||
}).pipe(
|
|
||||||
Effect.map((tokens) => {
|
|
||||||
const next = credential(value.methodID, tokens)
|
|
||||||
return new Credential.OAuth({
|
|
||||||
...next,
|
|
||||||
metadata: next.metadata ?? value.metadata,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function request<A>(url: string, init: RequestInit) {
|
|
||||||
return Effect.tryPromise({
|
|
||||||
try: async (signal) => {
|
|
||||||
const response = await fetch(url, { ...init, signal })
|
|
||||||
if (!response.ok) throw new Error(`Request failed: ${response.status}`)
|
|
||||||
return response.json() as Promise<A>
|
|
||||||
},
|
|
||||||
catch: (cause) => cause,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function credential(methodID: Integration.MethodID, tokens: TokenResponse) {
|
|
||||||
const accountID = extractAccountID(tokens)
|
|
||||||
return new Credential.OAuth({
|
|
||||||
type: "oauth",
|
|
||||||
methodID,
|
|
||||||
refresh: tokens.refresh_token,
|
|
||||||
access: tokens.access_token,
|
|
||||||
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
|
||||||
metadata: accountID ? { accountID } : undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generatePKCE(): Promise<Pkce> {
|
|
||||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
|
||||||
const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)), (byte) => chars[byte % chars.length]).join("")
|
|
||||||
const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))
|
|
||||||
return { verifier, challenge }
|
|
||||||
}
|
|
||||||
|
|
||||||
function base64UrlEncode(buffer: ArrayBuffer) {
|
|
||||||
return Buffer.from(buffer).toString("base64url")
|
|
||||||
}
|
|
||||||
|
|
||||||
function authorizeURL(redirect: string, pkce: Pkce, state: string) {
|
|
||||||
return `${issuer}/oauth/authorize?${new URLSearchParams({
|
|
||||||
response_type: "code",
|
|
||||||
client_id: clientID,
|
|
||||||
redirect_uri: redirect,
|
|
||||||
scope: "openid profile email offline_access",
|
|
||||||
code_challenge: pkce.challenge,
|
|
||||||
code_challenge_method: "S256",
|
|
||||||
id_token_add_organizations: "true",
|
|
||||||
codex_cli_simplified_flow: "true",
|
|
||||||
state,
|
|
||||||
originator: "opencode",
|
|
||||||
})}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractAccountID(tokens: TokenResponse) {
|
|
||||||
return claim(tokens.id_token) ?? claim(tokens.access_token)
|
|
||||||
}
|
|
||||||
|
|
||||||
function claim(token: string) {
|
|
||||||
const part = token.split(".")[1]
|
|
||||||
if (!part) return
|
|
||||||
try {
|
|
||||||
const claims = JSON.parse(Buffer.from(part, "base64url").toString()) as Claims
|
|
||||||
return (
|
|
||||||
claims.chatgpt_account_id ??
|
|
||||||
claims["https://api.openai.com/auth"]?.chatgpt_account_id ??
|
|
||||||
claims.organizations?.[0]?.id
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const successPage =
|
|
||||||
"<!doctype html><title>OpenCode</title><h1>Authorization successful</h1><p>You can close this window.</p>"
|
|
||||||
const errorPage = (message: string) =>
|
|
||||||
`<!doctype html><title>OpenCode</title><h1>Authorization failed</h1><p>${message.replace(/[&<>"']/g, "")}</p>`
|
|
||||||
@@ -1,15 +1,155 @@
|
|||||||
import { Effect } from "effect"
|
import { createServer } from "node:http"
|
||||||
import { ModelV2 } from "../../model"
|
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||||
import { define } from "../internal"
|
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||||
import { ProviderV2 } from "../../provider"
|
import { Deferred, Effect } from "effect"
|
||||||
|
import type { Scope } from "effect"
|
||||||
|
import { Credential } from "../../credential"
|
||||||
|
import { InstallationVersion } from "../../installation/version"
|
||||||
import { Integration } from "../../integration"
|
import { Integration } from "../../integration"
|
||||||
import { browser, headless } from "./openai-auth"
|
import { ModelV2 } from "../../model"
|
||||||
|
import { ProviderV2 } from "../../provider"
|
||||||
|
import type { PluginInternal } from "../internal"
|
||||||
|
|
||||||
|
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||||
|
const issuer = "https://auth.openai.com"
|
||||||
|
const callbackPort = 1455
|
||||||
|
const pollingSafetyMargin = 3000
|
||||||
|
const browserMethodID = Integration.MethodID.make("chatgpt-browser")
|
||||||
|
const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
|
||||||
|
|
||||||
|
type Pkce = {
|
||||||
|
verifier: string
|
||||||
|
challenge: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TokenResponse = {
|
||||||
|
id_token: string
|
||||||
|
access_token: string
|
||||||
|
refresh_token: string
|
||||||
|
expires_in?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Claims = {
|
||||||
|
chatgpt_account_id?: string
|
||||||
|
organizations?: Array<{ id: string }>
|
||||||
|
"https://api.openai.com/auth"?: { chatgpt_account_id?: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
const browser = {
|
||||||
|
integrationID: Integration.ID.make("openai"),
|
||||||
|
method: {
|
||||||
|
id: browserMethodID,
|
||||||
|
type: "oauth",
|
||||||
|
label: "ChatGPT Pro/Plus (browser)",
|
||||||
|
},
|
||||||
|
authorize: () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const pkce = yield* Effect.promise(generatePKCE)
|
||||||
|
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
|
||||||
|
const code = yield* Deferred.make<string, Error>()
|
||||||
|
const redirect = `http://localhost:${callbackPort}/auth/callback`
|
||||||
|
const server = createServer((request, response) => {
|
||||||
|
const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`)
|
||||||
|
if (url.pathname !== "/auth/callback") {
|
||||||
|
response.writeHead(404).end("Not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
|
||||||
|
const value = url.searchParams.get("code")
|
||||||
|
if (error) {
|
||||||
|
Effect.runFork(Deferred.fail(code, new Error(error)))
|
||||||
|
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(error))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!value || url.searchParams.get("state") !== state) {
|
||||||
|
const message = value ? "Invalid OAuth state" : "Missing authorization code"
|
||||||
|
Effect.runFork(Deferred.fail(code, new Error(message)))
|
||||||
|
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(message))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Effect.runFork(Deferred.succeed(code, value))
|
||||||
|
response.writeHead(200, { "Content-Type": "text/html" }).end(successPage)
|
||||||
|
})
|
||||||
|
yield* Effect.callback<void, Error>((resume) => {
|
||||||
|
server.once("error", (error) => resume(Effect.fail(error)))
|
||||||
|
server.listen(callbackPort, "localhost", () => resume(Effect.void))
|
||||||
|
})
|
||||||
|
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
|
||||||
|
return {
|
||||||
|
mode: "auto" as const,
|
||||||
|
url: authorizeURL(redirect, pkce, state),
|
||||||
|
instructions: "Complete authorization in your browser. This window will close automatically.",
|
||||||
|
callback: Deferred.await(code).pipe(
|
||||||
|
Effect.flatMap((value) => exchange(value, redirect, pkce)),
|
||||||
|
Effect.map((tokens) => credential(browserMethodID, tokens)),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
refresh: (value) => refresh(browserMethodID, value),
|
||||||
|
} satisfies IntegrationOAuthMethodRegistration
|
||||||
|
|
||||||
|
const headless = {
|
||||||
|
integrationID: Integration.ID.make("openai"),
|
||||||
|
method: {
|
||||||
|
id: headlessMethodID,
|
||||||
|
type: "oauth",
|
||||||
|
label: "ChatGPT Pro/Plus (headless)",
|
||||||
|
},
|
||||||
|
authorize: () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>(
|
||||||
|
`${issuer}/api/accounts/deviceauth/usercode`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: headers("application/json"),
|
||||||
|
body: JSON.stringify({ client_id: clientID }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const interval = Math.max(Number.parseInt(device.interval) || 5, 1) * 1000
|
||||||
|
return {
|
||||||
|
mode: "auto" as const,
|
||||||
|
url: `${issuer}/codex/device`,
|
||||||
|
instructions: `Enter code: ${device.user_code}`,
|
||||||
|
callback: Effect.gen(function* () {
|
||||||
|
while (true) {
|
||||||
|
const response = yield* Effect.tryPromise({
|
||||||
|
try: (signal) =>
|
||||||
|
fetch(`${issuer}/api/accounts/deviceauth/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: headers("application/json"),
|
||||||
|
body: JSON.stringify({ device_auth_id: device.device_auth_id, user_code: device.user_code }),
|
||||||
|
signal,
|
||||||
|
}),
|
||||||
|
catch: (cause) => cause,
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const data = (yield* Effect.promise(() => response.json())) as {
|
||||||
|
authorization_code: string
|
||||||
|
code_verifier: string
|
||||||
|
}
|
||||||
|
return credential(
|
||||||
|
headlessMethodID,
|
||||||
|
yield* exchange(data.authorization_code, `${issuer}/deviceauth/callback`, {
|
||||||
|
verifier: data.code_verifier,
|
||||||
|
challenge: "",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (response.status !== 403 && response.status !== 404) {
|
||||||
|
return yield* Effect.fail(new Error(`Device authorization failed: ${response.status}`))
|
||||||
|
}
|
||||||
|
yield* Effect.sleep(interval + pollingSafetyMargin)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
refresh: (value) => refresh(headlessMethodID, value),
|
||||||
|
} satisfies IntegrationOAuthMethodRegistration
|
||||||
|
|
||||||
export const OpenAIPlugin = define({
|
export const OpenAIPlugin = define({
|
||||||
id: "openai",
|
id: "openai",
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
const integrations = yield* Integration.Service
|
yield* ctx.integration.transform((draft) => {
|
||||||
yield* integrations.transform((draft) => {
|
|
||||||
draft.method.update(browser)
|
draft.method.update(browser)
|
||||||
draft.method.update(headless)
|
draft.method.update(headless)
|
||||||
})
|
})
|
||||||
@@ -41,4 +181,112 @@ export const OpenAIPlugin = define({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
|
} satisfies PluginInternal.Plugin<PluginInternal.Requirements | Scope.Scope>)
|
||||||
|
|
||||||
|
function headers(contentType: string) {
|
||||||
|
return { "Content-Type": contentType, "User-Agent": `opencode/${InstallationVersion}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
function exchange(code: string, redirect: string, pkce: Pkce) {
|
||||||
|
return request<TokenResponse>(`${issuer}/oauth/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: headers("application/x-www-form-urlencoded"),
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code,
|
||||||
|
redirect_uri: redirect,
|
||||||
|
client_id: clientID,
|
||||||
|
code_verifier: pkce.verifier,
|
||||||
|
}).toString(),
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh(methodID: Integration.MethodID, value: Pick<Credential.OAuth, "refresh" | "metadata">) {
|
||||||
|
return request<TokenResponse>(`${issuer}/oauth/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: headers("application/x-www-form-urlencoded"),
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: value.refresh,
|
||||||
|
client_id: clientID,
|
||||||
|
}).toString(),
|
||||||
|
}).pipe(
|
||||||
|
Effect.map((tokens) => {
|
||||||
|
const next = credential(methodID, tokens)
|
||||||
|
return Credential.OAuth.make({ ...next, metadata: next.metadata ?? value.metadata })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function request<A>(url: string, init: RequestInit) {
|
||||||
|
return Effect.tryPromise({
|
||||||
|
try: async (signal) => {
|
||||||
|
const response = await fetch(url, { ...init, signal })
|
||||||
|
if (!response.ok) throw new Error(`Request failed: ${response.status}`)
|
||||||
|
return response.json() as Promise<A>
|
||||||
|
},
|
||||||
|
catch: (cause) => cause,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function credential(methodID: Integration.MethodID, tokens: TokenResponse) {
|
||||||
|
const accountID = extractAccountID(tokens)
|
||||||
|
return Credential.OAuth.make({
|
||||||
|
type: "oauth",
|
||||||
|
methodID,
|
||||||
|
refresh: tokens.refresh_token,
|
||||||
|
access: tokens.access_token,
|
||||||
|
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
||||||
|
metadata: accountID ? { accountID } : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generatePKCE(): Promise<Pkce> {
|
||||||
|
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||||
|
const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)), (byte) => chars[byte % chars.length]).join("")
|
||||||
|
const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))
|
||||||
|
return { verifier, challenge }
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlEncode(buffer: ArrayBuffer) {
|
||||||
|
return Buffer.from(buffer).toString("base64url")
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorizeURL(redirect: string, pkce: Pkce, state: string) {
|
||||||
|
return `${issuer}/oauth/authorize?${new URLSearchParams({
|
||||||
|
response_type: "code",
|
||||||
|
client_id: clientID,
|
||||||
|
redirect_uri: redirect,
|
||||||
|
scope: "openid profile email offline_access",
|
||||||
|
code_challenge: pkce.challenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
id_token_add_organizations: "true",
|
||||||
|
codex_cli_simplified_flow: "true",
|
||||||
|
state,
|
||||||
|
originator: "opencode",
|
||||||
|
})}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAccountID(tokens: TokenResponse) {
|
||||||
|
return claim(tokens.id_token) ?? claim(tokens.access_token)
|
||||||
|
}
|
||||||
|
|
||||||
|
function claim(token: string) {
|
||||||
|
const part = token.split(".")[1]
|
||||||
|
if (!part) return
|
||||||
|
try {
|
||||||
|
const claims = JSON.parse(Buffer.from(part, "base64url").toString()) as Claims
|
||||||
|
return (
|
||||||
|
claims.chatgpt_account_id ??
|
||||||
|
claims["https://api.openai.com/auth"]?.chatgpt_account_id ??
|
||||||
|
claims.organizations?.[0]?.id
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const successPage =
|
||||||
|
"<!doctype html><title>OpenCode</title><h1>Authorization successful</h1><p>You can close this window.</p>"
|
||||||
|
const errorPage = (message: string) =>
|
||||||
|
`<!doctype html><title>OpenCode</title><h1>Authorization failed</h1><p>${message.replace(/[&<>"']/g, "")}</p>`
|
||||||
|
|||||||
@@ -1,32 +1,314 @@
|
|||||||
import { Effect } from "effect"
|
import { Duration, Effect, Schema, Stream } from "effect"
|
||||||
import { define } from "../internal"
|
import type { Scope } from "effect"
|
||||||
import { ProviderV2 } from "../../provider"
|
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||||
|
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||||
|
import type { CredentialValue } from "@opencode-ai/sdk/v2/types"
|
||||||
|
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
|
import { EventV2 } from "../../event"
|
||||||
|
import { Credential } from "../../credential"
|
||||||
import { Integration } from "../../integration"
|
import { Integration } from "../../integration"
|
||||||
|
import { ModelV2 } from "../../model"
|
||||||
|
import { ModelRequest } from "../../model-request"
|
||||||
|
import { ProviderV2 } from "../../provider"
|
||||||
|
import { ConfigProviderV1 } from "../../v1/config/provider"
|
||||||
|
import { ConfigV1 } from "../../v1/config/config"
|
||||||
|
|
||||||
export const OpencodePlugin = define({
|
const defaultServer = "https://console.opencode.ai"
|
||||||
|
const clientID = "opencode-cli"
|
||||||
|
const methodID = Integration.MethodID.make("device")
|
||||||
|
const RemoteResponse = Schema.Struct({ config: ConfigV1.Info })
|
||||||
|
const Device = Schema.Struct({
|
||||||
|
device_code: Schema.String,
|
||||||
|
user_code: Schema.String,
|
||||||
|
verification_uri_complete: Schema.String,
|
||||||
|
expires_in: Schema.Number,
|
||||||
|
interval: Schema.Number,
|
||||||
|
})
|
||||||
|
const Token = Schema.Struct({
|
||||||
|
access_token: Schema.String,
|
||||||
|
refresh_token: Schema.String,
|
||||||
|
expires_in: Schema.Number,
|
||||||
|
})
|
||||||
|
const TokenPending = Schema.Struct({ error: Schema.String })
|
||||||
|
const DeviceToken = Schema.Union([Token, TokenPending])
|
||||||
|
const User = Schema.Struct({ id: Schema.String, email: Schema.String })
|
||||||
|
const Org = Schema.Struct({ id: Schema.String, name: Schema.String })
|
||||||
|
|
||||||
|
function oauth(http: HttpClient.HttpClient) {
|
||||||
|
return {
|
||||||
|
integrationID: Integration.ID.make("opencode"),
|
||||||
|
method: {
|
||||||
|
id: methodID,
|
||||||
|
type: "oauth",
|
||||||
|
label: "OpenCode Console account",
|
||||||
|
},
|
||||||
|
authorize: () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const device = yield* post(http, `${defaultServer}/auth/device/code`, { client_id: clientID }, Device)
|
||||||
|
return {
|
||||||
|
mode: "auto" as const,
|
||||||
|
url: `${defaultServer}${device.verification_uri_complete}`,
|
||||||
|
instructions: `Enter code: ${device.user_code}`,
|
||||||
|
callback: poll(http, defaultServer, device.device_code, Duration.seconds(device.interval)),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
refresh: (credential) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const server = typeof credential.metadata?.server === "string" ? credential.metadata.server : defaultServer
|
||||||
|
const token = yield* post(
|
||||||
|
http,
|
||||||
|
`${server}/auth/device/token`,
|
||||||
|
{ grant_type: "refresh_token", refresh_token: credential.refresh, client_id: clientID },
|
||||||
|
Token,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...credential,
|
||||||
|
access: token.access_token,
|
||||||
|
refresh: token.refresh_token,
|
||||||
|
expires: Date.now() + token.expires_in * 1000,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
label: (credential) => {
|
||||||
|
return typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined
|
||||||
|
},
|
||||||
|
} satisfies IntegrationOAuthMethodRegistration
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | Scope.Scope>({
|
||||||
id: "opencode",
|
id: "opencode",
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
const integrations = yield* Integration.Service
|
const events = yield* EventV2.Service
|
||||||
let hasKey = false
|
const http = yield* HttpClient.HttpClient
|
||||||
yield* ctx.catalog.transform(
|
let connected = false
|
||||||
Effect.fn(function* (evt) {
|
let providers: typeof ConfigV1.Info.Type.provider | undefined
|
||||||
const item = evt.provider.get(ProviderV2.ID.opencode)
|
|
||||||
if (!item) return
|
const load = Effect.fn("OpencodePlugin.load")(function* () {
|
||||||
const integration = yield* integrations.get(Integration.ID.make(item.provider.id))
|
const connection = yield* ctx.integration.connection.active("opencode")
|
||||||
hasKey = Boolean(
|
const credential = connection
|
||||||
process.env.OPENCODE_API_KEY || integration?.connections.length || item.provider.request.body.apiKey,
|
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
|
: undefined
|
||||||
|
connected = connection !== undefined
|
||||||
|
providers = credential
|
||||||
|
? yield* fetchProviders(http, credential).pipe(
|
||||||
|
Effect.catch((cause) =>
|
||||||
|
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(Effect.as(undefined)),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
evt.provider.update(item.provider.id, (provider) => {
|
: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* ctx.integration.transform((draft) => {
|
||||||
|
draft.update("opencode", (integration) => {
|
||||||
|
integration.name = "OpenCode"
|
||||||
|
})
|
||||||
|
draft.method.update(oauth(http))
|
||||||
|
draft.method.update({ integrationID: "opencode", method: { type: "key", label: "API key (service account)" } })
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* load()
|
||||||
|
yield* ctx.catalog.transform((catalog) => {
|
||||||
|
for (const [providerID, item] of Object.entries(providers ?? {})) {
|
||||||
|
catalog.provider.update(providerID, (provider) => {
|
||||||
|
provider.integrationID = Integration.ID.make("opencode")
|
||||||
|
if (item.name !== undefined) provider.name = item.name
|
||||||
|
provider.api = item.npm
|
||||||
|
? { type: "aisdk", package: item.npm, url: item.api }
|
||||||
|
: { type: "native", url: item.api, settings: {} }
|
||||||
|
Object.assign(provider.request.headers, item.options?.headers)
|
||||||
|
Object.assign(provider.request.body, withoutCredentials(item.options))
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelIDs = new Set(Object.keys(item.models ?? {}))
|
||||||
|
for (const model of catalog.provider.get(providerID)?.models.values() ?? []) {
|
||||||
|
if (!modelIDs.has(model.id)) catalog.model.remove(providerID, model.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [modelID, config] of Object.entries(item.models ?? {})) {
|
||||||
|
catalog.model.update(providerID, modelID, (model) => {
|
||||||
|
if (config.family !== undefined) model.family = config.family
|
||||||
|
if (config.name !== undefined) model.name = config.name
|
||||||
|
if (config.id !== undefined) model.api.id = config.id
|
||||||
|
if (config.provider !== undefined) {
|
||||||
|
model.api = config.provider.npm
|
||||||
|
? {
|
||||||
|
id: model.api.id,
|
||||||
|
type: "aisdk",
|
||||||
|
package: config.provider.npm,
|
||||||
|
url: config.provider.api,
|
||||||
|
}
|
||||||
|
: { id: model.api.id, type: "native", url: config.provider.api, settings: {} }
|
||||||
|
}
|
||||||
|
if (config.tool_call !== undefined) model.capabilities.tools = config.tool_call
|
||||||
|
if (config.modalities?.input !== undefined) model.capabilities.input = [...config.modalities.input]
|
||||||
|
if (config.modalities?.output !== undefined) model.capabilities.output = [...config.modalities.output]
|
||||||
|
const packageName = config.provider?.npm ?? item.npm
|
||||||
|
ModelRequest.assign(model.request, {
|
||||||
|
headers: config.headers,
|
||||||
|
...ModelRequest.normalizeAiSdkOptions(packageName, withoutCredentials(config.options)),
|
||||||
|
})
|
||||||
|
if (config.variants !== undefined) {
|
||||||
|
model.variants = Object.entries(config.variants).map(([id, options]) => ({
|
||||||
|
id: ModelV2.VariantID.make(id),
|
||||||
|
headers: { ...(options.headers ?? {}) },
|
||||||
|
...ModelRequest.normalizeAiSdkOptions(packageName, withoutCredentials(options)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
if (config.release_date !== undefined) {
|
||||||
|
const released = Date.parse(config.release_date)
|
||||||
|
model.time.released = Number.isFinite(released) ? released : 0
|
||||||
|
}
|
||||||
|
if (config.cost !== undefined) {
|
||||||
|
model.cost = remoteCost(config.cost)
|
||||||
|
}
|
||||||
|
model.status = config.status ?? "active"
|
||||||
|
model.enabled = config.status !== "deprecated"
|
||||||
|
if (config.limit !== undefined) model.limit = { ...config.limit }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = catalog.provider.get(ProviderV2.ID.opencode)
|
||||||
|
if (!item) return
|
||||||
|
const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || item.provider.request.body.apiKey)
|
||||||
|
catalog.provider.update(item.provider.id, (provider) => {
|
||||||
if (!hasKey) provider.request.body.apiKey = "public"
|
if (!hasKey) provider.request.body.apiKey = "public"
|
||||||
})
|
})
|
||||||
if (hasKey) return
|
if (hasKey) return
|
||||||
for (const model of item.models.values()) {
|
for (const model of item.models.values()) {
|
||||||
if (!model.cost.some((cost) => cost.input > 0)) continue
|
if (!model.cost.some((cost) => cost.input > 0)) continue
|
||||||
evt.model.update(item.provider.id, model.id, (draft) => {
|
catalog.model.update(item.provider.id, model.id, (draft) => {
|
||||||
draft.enabled = false
|
draft.enabled = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}),
|
})
|
||||||
|
|
||||||
|
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||||
|
Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")),
|
||||||
|
Stream.runForEach(() => load().pipe(Effect.andThen(ctx.catalog.reload()))),
|
||||||
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function fetchProviders(http: HttpClient.HttpClient, value: CredentialValue) {
|
||||||
|
const metadata = value.metadata
|
||||||
|
const server = typeof metadata?.server === "string" ? metadata.server : defaultServer
|
||||||
|
const orgID = typeof metadata?.orgID === "string" ? metadata.orgID : undefined
|
||||||
|
const token = value.type === "oauth" ? value.access : value.key
|
||||||
|
return http
|
||||||
|
.execute(
|
||||||
|
HttpClientRequest.get(`${server}/api/config`).pipe(
|
||||||
|
HttpClientRequest.acceptJson,
|
||||||
|
HttpClientRequest.bearerToken(token),
|
||||||
|
HttpClientRequest.setHeaders(orgID ? { "x-org-id": orgID } : {}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
Effect.flatMap((response) => {
|
||||||
|
if (response.status === 404) return Effect.succeed(undefined)
|
||||||
|
return HttpClientResponse.filterStatusOk(response).pipe(
|
||||||
|
Effect.flatMap(HttpClientResponse.schemaBodyJson(RemoteResponse)),
|
||||||
|
Effect.map((remote) => remote.config.provider),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function withoutCredentials(body: Readonly<Record<string, unknown>> | undefined) {
|
||||||
|
return Object.fromEntries(Object.entries(body ?? {}).filter(([key]) => key !== "apiKey" && key !== "headers"))
|
||||||
|
}
|
||||||
|
|
||||||
|
function remoteCost(input: NonNullable<(typeof ConfigProviderV1.Model.Type)["cost"]>) {
|
||||||
|
const base = {
|
||||||
|
input: input.input,
|
||||||
|
output: input.output,
|
||||||
|
cache: { read: input.cache_read ?? 0, write: input.cache_write ?? 0 },
|
||||||
|
}
|
||||||
|
if (!input.context_over_200k) return [base]
|
||||||
|
return [
|
||||||
|
base,
|
||||||
|
{
|
||||||
|
tier: { type: "context" as const, size: 200_000 },
|
||||||
|
input: input.context_over_200k.input,
|
||||||
|
output: input.context_over_200k.output,
|
||||||
|
cache: {
|
||||||
|
read: input.context_over_200k.cache_read ?? 0,
|
||||||
|
write: input.context_over_200k.cache_write ?? 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function poll(http: HttpClient.HttpClient, server: string, deviceCode: string, interval: Duration.Duration) {
|
||||||
|
const loop = (wait: Duration.Duration): Effect.Effect<Credential.OAuth, unknown> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Effect.sleep(wait)
|
||||||
|
const result = yield* post(
|
||||||
|
http,
|
||||||
|
`${server}/auth/device/token`,
|
||||||
|
{
|
||||||
|
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||||
|
device_code: deviceCode,
|
||||||
|
client_id: clientID,
|
||||||
|
},
|
||||||
|
DeviceToken,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
if ("access_token" in result) return yield* credential(http, server, result)
|
||||||
|
if (result.error === "authorization_pending") return yield* loop(wait)
|
||||||
|
if (result.error === "slow_down") {
|
||||||
|
return yield* loop(Duration.sum(wait, Duration.seconds(5)))
|
||||||
|
}
|
||||||
|
return yield* Effect.fail(new Error(`Device authorization failed: ${result.error}`))
|
||||||
|
})
|
||||||
|
return loop(interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
function credential(http: HttpClient.HttpClient, server: string, token: typeof Token.Type) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
const [user, orgs] = yield* Effect.all(
|
||||||
|
[
|
||||||
|
get(http, `${server}/api/user`, token.access_token, User),
|
||||||
|
get(http, `${server}/api/orgs`, token.access_token, Schema.Array(Org)),
|
||||||
|
],
|
||||||
|
{ concurrency: 2 },
|
||||||
|
)
|
||||||
|
const org = orgs.toSorted((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))[0]
|
||||||
|
return Credential.OAuth.make({
|
||||||
|
type: "oauth" as const,
|
||||||
|
methodID,
|
||||||
|
access: token.access_token,
|
||||||
|
refresh: token.refresh_token,
|
||||||
|
expires: Date.now() + token.expires_in * 1000,
|
||||||
|
metadata: {
|
||||||
|
server,
|
||||||
|
accountID: user.id,
|
||||||
|
email: user.email,
|
||||||
|
orgID: org?.id,
|
||||||
|
orgName: org?.name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function get<S extends Schema.Top>(http: HttpClient.HttpClient, url: string, token: string, schema: S) {
|
||||||
|
return HttpClient.filterStatusOk(http)
|
||||||
|
.execute(HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson, HttpClientRequest.bearerToken(token)))
|
||||||
|
.pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function post<S extends Schema.Top>(
|
||||||
|
http: HttpClient.HttpClient,
|
||||||
|
url: string,
|
||||||
|
body: Record<string, string>,
|
||||||
|
schema: S,
|
||||||
|
statusOk = true,
|
||||||
|
) {
|
||||||
|
return HttpClientRequest.post(url).pipe(
|
||||||
|
HttpClientRequest.acceptJson,
|
||||||
|
HttpClientRequest.schemaBodyJson(Schema.Record(Schema.String, Schema.String))(body),
|
||||||
|
Effect.flatMap((request) => http.execute(request)),
|
||||||
|
Effect.flatMap((response) => (statusOk ? HttpClientResponse.filterStatusOk(response) : Effect.succeed(response))),
|
||||||
|
Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ export const Plugin = define({
|
|||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
yield* ctx.skill.transform((draft) => {
|
yield* ctx.skill.transform((draft) => {
|
||||||
draft.source(
|
draft.source(
|
||||||
new SkillV2.EmbeddedSource({
|
SkillV2.EmbeddedSource.make({
|
||||||
type: "embedded",
|
type: "embedded",
|
||||||
skill: new SkillV2.Info({
|
skill: SkillV2.Info.make({
|
||||||
name: "customize-opencode",
|
name: "customize-opencode",
|
||||||
description:
|
description:
|
||||||
"Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.",
|
"Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.",
|
||||||
|
|||||||
@@ -10,7 +10,14 @@ export class AppProcessError extends Schema.TaggedErrorClass<AppProcessError>()(
|
|||||||
exitCode: Schema.optional(Schema.Number),
|
exitCode: Schema.optional(Schema.Number),
|
||||||
stderr: Schema.optional(Schema.String),
|
stderr: Schema.optional(Schema.String),
|
||||||
cause: Schema.optional(Schema.Defect()),
|
cause: Schema.optional(Schema.Defect()),
|
||||||
}) {}
|
}) {
|
||||||
|
override get message() {
|
||||||
|
const detail =
|
||||||
|
this.stderr?.trim() || (this.cause instanceof Error ? this.cause.message : this.cause && String(this.cause))
|
||||||
|
const status = this.exitCode === undefined ? "" : ` (exit ${this.exitCode})`
|
||||||
|
return `Command failed${status}: ${this.command}${detail ? `: ${detail}` : ""}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface RunOptions {
|
export interface RunOptions {
|
||||||
readonly maxOutputBytes?: number
|
readonly maxOutputBytes?: number
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user