mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-11 20:38:49 +00:00
Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dd1c872d8 | ||
|
|
66f37cdd16 | ||
|
|
a392532acd | ||
|
|
d994207deb | ||
|
|
2b6dd2559c | ||
|
|
2b7bd84867 | ||
|
|
baa54a5150 | ||
|
|
c5704d3e3b | ||
|
|
e3336725db | ||
|
|
ff3ecefd24 | ||
|
|
8486bbcdc1 | ||
|
|
2621dde1c9 | ||
|
|
a253c0437e | ||
|
|
d8b3e528e8 | ||
|
|
a5d6969f6f | ||
|
|
3954d7979d | ||
|
|
a69a372f27 | ||
|
|
7300e7e10f | ||
|
|
eeb870b0cc | ||
|
|
136bad9f17 | ||
|
|
17536a9237 | ||
|
|
315bc6ac2e | ||
|
|
a183fb5615 | ||
|
|
2c1596ca80 | ||
|
|
9e082c2d43 | ||
|
|
fa5ca85e30 | ||
|
|
37a8c6c916 | ||
|
|
23b0688a7f | ||
|
|
d0dd49bd84 | ||
|
|
db9a3b6c41 | ||
|
|
961b51b509 | ||
|
|
964f7f4254 | ||
|
|
8c27c8485e | ||
|
|
6721ff5328 | ||
|
|
026e0c634f | ||
|
|
c581b59b7f | ||
|
|
5cc7811ec2 | ||
|
|
5d32845b02 | ||
|
|
e9f5842f29 | ||
|
|
7b9734593a | ||
|
|
1dbfc7cfab | ||
|
|
3441b95afc | ||
|
|
306204bad8 | ||
|
|
fca453a2a5 | ||
|
|
2a51cf5f88 | ||
|
|
b7bd2b0ae0 | ||
|
|
45b2759a1e | ||
|
|
65b6abc4bb | ||
|
|
8c8492ef97 | ||
|
|
115e689723 | ||
|
|
7c30a4230e | ||
|
|
bd4557291b | ||
|
|
9ecf46bfea | ||
|
|
47c8d85904 | ||
|
|
c401076b6f | ||
|
|
9ded81b6c2 | ||
|
|
87142c1b4c | ||
|
|
d8320209c9 | ||
|
|
31c72b9a17 | ||
|
|
1aef4de853 | ||
|
|
9c94634515 | ||
|
|
c2b754bb03 |
@@ -75,7 +75,7 @@ jobs:
|
||||
build-cli:
|
||||
needs: version
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
with:
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
- name: Build legacy CLI
|
||||
if: github.ref_name != 'v2'
|
||||
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
run: ./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: github.ref_name != 'v2'
|
||||
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
with:
|
||||
name: opencode-cli
|
||||
path: |
|
||||
@@ -117,22 +117,76 @@ jobs:
|
||||
packages/opencode/dist/opencode-linux*
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: github.ref_name != 'v2'
|
||||
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
with:
|
||||
name: opencode-cli-windows
|
||||
path: packages/opencode/dist/opencode-windows*
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: opencode-preview-cli
|
||||
name: opencode-preview-cli-unsigned
|
||||
path: packages/cli/dist/cli-*
|
||||
|
||||
outputs:
|
||||
version: ${{ needs.version.outputs.version }}
|
||||
|
||||
sign-cli-macos:
|
||||
needs: build-cli
|
||||
runs-on: macos-26
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
- uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2.0.0
|
||||
with:
|
||||
keychain: build
|
||||
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: opencode-preview-cli-unsigned
|
||||
path: packages/cli/dist
|
||||
|
||||
- name: Sign macOS CLI binaries
|
||||
run: |
|
||||
identity=$(security find-identity -v -p codesigning build.keychain | sed -n 's/.*"\(Developer ID Application:.*\)"/\1/p' | head -n 1)
|
||||
if [ -z "$identity" ]; then
|
||||
echo "Developer ID Application identity not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
found=0
|
||||
for file in packages/cli/dist/cli-darwin-*/bin/opencode2; do
|
||||
if [ ! -f "$file" ]; then
|
||||
continue
|
||||
fi
|
||||
found=1
|
||||
codesign \
|
||||
--force \
|
||||
--timestamp \
|
||||
--options runtime \
|
||||
--entitlements packages/cli/script/entitlements.plist \
|
||||
--sign "$identity" \
|
||||
"$file"
|
||||
codesign --verify --deep --strict --verbose=4 "$file"
|
||||
codesign --display --requirements - "$file"
|
||||
done
|
||||
|
||||
if [ "$found" -eq 0 ]; then
|
||||
echo "No macOS CLI binaries found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: opencode-preview-cli
|
||||
path: packages/cli/dist/cli-*
|
||||
if-no-files-found: error
|
||||
|
||||
build-node-cli:
|
||||
needs: version
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -184,7 +238,7 @@ jobs:
|
||||
- build-cli
|
||||
- version
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -377,7 +431,7 @@ jobs:
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||
RUST_TARGET: ${{ matrix.settings.target }}
|
||||
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
@@ -393,6 +447,7 @@ jobs:
|
||||
VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }}
|
||||
VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }}
|
||||
VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }}
|
||||
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
|
||||
|
||||
- name: Package
|
||||
if: needs.version.outputs.release
|
||||
@@ -467,6 +522,7 @@ jobs:
|
||||
needs:
|
||||
- version
|
||||
- build-cli
|
||||
- sign-cli-macos
|
||||
- build-node-cli
|
||||
- sign-cli-windows
|
||||
- build-electron
|
||||
@@ -496,29 +552,31 @@ jobs:
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'v2'
|
||||
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
with:
|
||||
name: opencode-cli
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'v2'
|
||||
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
with:
|
||||
name: opencode-cli-windows
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'v2'
|
||||
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
with:
|
||||
name: opencode-cli-signed-windows
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'beta'
|
||||
with:
|
||||
name: opencode-preview-cli
|
||||
path: packages/cli/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'beta'
|
||||
with:
|
||||
pattern: opencode-node-cli-*
|
||||
path: packages/cli/dist/node
|
||||
|
||||
@@ -439,6 +439,7 @@
|
||||
"@actions/artifact": "4.0.0",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
|
||||
@@ -348,7 +348,10 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
continue
|
||||
}
|
||||
}
|
||||
messages.push({ role: "user", content })
|
||||
const previous = messages.at(-1)
|
||||
if (previous?.role === "user")
|
||||
messages[messages.length - 1] = { role: "user", content: [...previous.content, ...content] }
|
||||
else messages.push({ role: "user", content })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -392,7 +395,10 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
const cachePoint = BedrockCache.block(breakpoints, part.cache)
|
||||
if (cachePoint) content.push(cachePoint)
|
||||
}
|
||||
messages.push({ role: "user", content })
|
||||
const previous = messages.at(-1)
|
||||
if (previous?.role === "user")
|
||||
messages[messages.length - 1] = { role: "user", content: [...previous.content, ...content] }
|
||||
else messages.push({ role: "user", content })
|
||||
}
|
||||
|
||||
return messages
|
||||
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:bedrock-converse",
|
||||
"provider:amazon-bedrock",
|
||||
"protocol:bedrock-converse",
|
||||
"tool",
|
||||
"tool-loop",
|
||||
"parallel"
|
||||
],
|
||||
"name": "bedrock-converse/continues-after-parallel-tool-results",
|
||||
"recordedAt": "2026-08-11T16:41:46.482Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.amazon.nova-micro-v1%3A0/converse-stream",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"modelId\":\"us.amazon.nova-micro-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"Compare the weather in Paris and London.\"}]},{\"role\":\"assistant\",\"content\":[{\"toolUse\":{\"toolUseId\":\"weather_paris\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}},{\"toolUse\":{\"toolUseId\":\"weather_london\",\"name\":\"get_weather\",\"input\":{\"city\":\"London\"}}}]},{\"role\":\"user\",\"content\":[{\"toolResult\":{\"toolUseId\":\"weather_paris\",\"content\":[{\"json\":{\"temperature\":22,\"condition\":\"sunny\"}}],\"status\":\"success\"}},{\"toolResult\":{\"toolUseId\":\"weather_london\",\"content\":[{\"json\":{\"temperature\":14,\"condition\":\"rainy\"}}],\"status\":\"success\"}}]}],\"system\":[{\"text\":\"After receiving both tool results, reply exactly: Paris is sunny; London is rainy.\"}],\"inferenceConfig\":{\"maxTokens\":40,\"temperature\":0},\"toolConfig\":{\"tools\":[{\"toolSpec\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"inputSchema\":{\"json\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}}]}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "application/vnd.amazon.eventstream"
|
||||
},
|
||||
"body": "AAAAqAAAAFKgEDvmCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFEiLCJyb2xlIjoiYXNzaXN0YW50In189ig4AAAAzgAAAFfGCE2ECzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IlBhcmlzIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVVYifWttETIAAADDAAAAVz6YiTULOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIGlzIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE0ifRHJ8Q0AAACqAAAAV6q6nAkLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIHN1bm55In0sInAiOiJhYmNkZWZnaGlqayJ98ZCy6gAAALAAAABXgOoTKgs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiI7In0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2In020bBKAAAAygAAAFcziOtECzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiBMb25kb24ifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUCJ9ew04hAAAAK4AAABXXzo6yQs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgaXMifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxciJ9yK3bdAAAALcAAABXMsrPOgs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgcmFpbnkifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eCJ9JoCYVwAAALoAAABXyloLiws6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIuIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRiJ9WJwR8wAAAMEAAABXRFjaVQs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU4ifYzp4V0AAAChAAAAVqptnY4LOmV2ZW50LXR5cGUHABBjb250ZW50QmxvY2tTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQyJ9AHyeLwAAAJUAAABRYKgWaws6ZXZlbnQtdHlwZQcAC21lc3NhZ2VTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0Iiwic3RvcFJlYXNvbiI6ImVuZF90dXJuIn2HCXz0AAABBgAAAE6wWpX7CzpldmVudC10eXBlBwAIbWV0YWRhdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJtZXRyaWNzIjp7ImxhdGVuY3lNcyI6MTA5MH0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVSIsInVzYWdlIjp7ImlucHV0VG9rZW5zIjo1MjEsIm91dHB1dFRva2VucyI6OSwic2VydmVyVG9vbFVzYWdlIjp7fSwidG90YWxUb2tlbnMiOjUzMH19Uwfxiw==",
|
||||
"bodyEncoding": "base64"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -255,6 +255,57 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("merges parallel tool results into one user message", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_parallel_history",
|
||||
model,
|
||||
messages: [
|
||||
Message.user("Compare the weather."),
|
||||
Message.assistant([
|
||||
ToolCallPart.make({ id: "tool_paris", name: "lookup", input: { city: "Paris" } }),
|
||||
ToolCallPart.make({ id: "tool_london", name: "lookup", input: { city: "London" } }),
|
||||
]),
|
||||
Message.tool({ id: "tool_paris", name: "lookup", result: { forecast: "sunny" } }),
|
||||
Message.tool({ id: "tool_london", name: "lookup", result: { forecast: "rainy" } }),
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "user", content: [{ text: "Compare the weather." }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ toolUse: { toolUseId: "tool_paris", name: "lookup", input: { city: "Paris" } } },
|
||||
{ toolUse: { toolUseId: "tool_london", name: "lookup", input: { city: "London" } } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
toolResult: {
|
||||
toolUseId: "tool_paris",
|
||||
content: [{ json: { forecast: "sunny" } }],
|
||||
status: "success",
|
||||
},
|
||||
},
|
||||
{
|
||||
toolResult: {
|
||||
toolUseId: "tool_london",
|
||||
content: [{ json: { forecast: "rainy" } }],
|
||||
status: "success",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers image content in tool-result messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -1165,4 +1216,39 @@ describe("Bedrock Converse recorded", () => {
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect.with("continues after parallel tool results", { tags: ["tool", "tool-loop", "parallel"] }, () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
id: "recorded_bedrock_parallel_tool_results",
|
||||
model: recordedModel(),
|
||||
system: "After receiving both tool results, reply exactly: Paris is sunny; London is rainy.",
|
||||
messages: [
|
||||
Message.user("Compare the weather in Paris and London."),
|
||||
Message.assistant([
|
||||
ToolCallPart.make({ id: "weather_paris", name: weatherToolName, input: { city: "Paris" } }),
|
||||
ToolCallPart.make({ id: "weather_london", name: weatherToolName, input: { city: "London" } }),
|
||||
]),
|
||||
Message.tool({
|
||||
id: "weather_paris",
|
||||
name: weatherToolName,
|
||||
result: { temperature: 22, condition: "sunny" },
|
||||
}),
|
||||
Message.tool({
|
||||
id: "weather_london",
|
||||
name: weatherToolName,
|
||||
result: { temperature: 14, condition: "rainy" },
|
||||
}),
|
||||
],
|
||||
tools: [weatherTool],
|
||||
cache: "none",
|
||||
generation: { maxTokens: 40, temperature: 0 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.text.trim()).toBe("Paris is sunny; London is rainy.")
|
||||
expect(response.finishReason?.normalized).toBe("stop")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
## Localization
|
||||
|
||||
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors.
|
||||
- Feature work adds English source strings only. Leave non-English keys absent so the runtime English fallback applies; translations land separately after language review.
|
||||
- Render count-sensitive copy only through `language.plural(baseKey, count, params)`. Never select or pass `.zero`, `.one`, `.two`, `.few`, `.many`, or `.other` variants to `language.t(...)`.
|
||||
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
|
||||
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
|
||||
- Keep locale complexity behind the shared typed i18n APIs. Feature and component code should use `language.t(...)` for ordinary copy and `language.plural(baseKey, count, params)` for count-sensitive copy. It must not inspect the locale, call `Intl.PluralRules`, construct or select plural-category keys such as `.one` or `.other`, or branch on locale-specific grammar.
|
||||
|
||||
@@ -111,7 +111,7 @@ test("restores the draft caret before typing after a request dock closes", async
|
||||
})
|
||||
await mockServer(page, { questions: [] })
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await transport.waitForConnection()
|
||||
await transport.waitForConnection({ path: "/api/event" })
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
|
||||
@@ -132,32 +132,40 @@ test("restores the draft caret before typing after a request dock closes", async
|
||||
}),
|
||||
)
|
||||
.toBe(cursor)
|
||||
await transport.send({
|
||||
directory,
|
||||
payload: {
|
||||
type: "question.asked",
|
||||
properties: {
|
||||
id: "question-caret",
|
||||
sessionID,
|
||||
questions: [
|
||||
{
|
||||
header: "Continue",
|
||||
question: "Continue?",
|
||||
options: [{ label: "Yes", description: "Continue the session" }],
|
||||
},
|
||||
],
|
||||
tool: { messageID: "message-caret", callID: "call-caret" },
|
||||
await transport.send(
|
||||
{
|
||||
directory,
|
||||
payload: {
|
||||
type: "question.asked",
|
||||
properties: {
|
||||
id: "question-caret",
|
||||
sessionID,
|
||||
questions: [
|
||||
{
|
||||
header: "Continue",
|
||||
question: "Continue?",
|
||||
options: [{ label: "Yes", description: "Continue the session" }],
|
||||
},
|
||||
],
|
||||
tool: { messageID: "message-caret", callID: "call-caret" },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
undefined,
|
||||
"/api/event",
|
||||
)
|
||||
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
|
||||
await expect(question).toBeVisible()
|
||||
await expect(editor).toHaveCount(0)
|
||||
|
||||
await transport.send({
|
||||
directory,
|
||||
payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
|
||||
})
|
||||
await transport.send(
|
||||
{
|
||||
directory,
|
||||
payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
|
||||
},
|
||||
undefined,
|
||||
"/api/event",
|
||||
)
|
||||
await expect(question).toHaveCount(0)
|
||||
await expect(editor).toBeVisible()
|
||||
await page.keyboard.press("x")
|
||||
|
||||
@@ -17,7 +17,7 @@ import { installSseTransport } from "../utils/sse-transport"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const initialPageSize = 20
|
||||
const historyPageSize = 200
|
||||
const historyPageSize = 50
|
||||
const messages = Array.from({ length: initialPageSize + 1 }, (_, index) => {
|
||||
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
|
||||
return [
|
||||
|
||||
@@ -89,13 +89,15 @@ test("reconnects after a stream error", async ({ page }) => {
|
||||
|
||||
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
||||
id: "timeline-event-7",
|
||||
})
|
||||
const first = await timeline.transport.send(
|
||||
partUpdated(textPart("prt_transport_id", "event with id")),
|
||||
{ id: "timeline-event-7" },
|
||||
"/api/event",
|
||||
)
|
||||
await timeline.waitForPart("prt_transport_id")
|
||||
|
||||
await timeline.transport.error("retry with event id")
|
||||
const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
|
||||
await timeline.transport.error("retry with event id", "/api/event")
|
||||
const connection = await timeline.transport.waitForConnection({ after: first.connectionID, path: "/api/event" })
|
||||
|
||||
expect(first.eventID).toBe("timeline-event-7")
|
||||
expect(connection.headers["last-event-id"]).toBeUndefined()
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import type { OpenCodeEvent, SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const root = "C:/OpenCode/WorkspaceProject"
|
||||
const workspace = "C:/OpenCode/worktree/project/feature"
|
||||
const createdWorkspace = "C:/OpenCode/worktree/project/quick-contrast-fix"
|
||||
const project = {
|
||||
id: "proj_workspaces",
|
||||
canonical: root,
|
||||
vcs: "git" as const,
|
||||
name: "workspace-project",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [workspace],
|
||||
}
|
||||
const provider = {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test model", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
}
|
||||
const diff = {
|
||||
file: "src/workspace.ts",
|
||||
additions: 3,
|
||||
deletions: 1,
|
||||
status: "modified" as const,
|
||||
patch: "@@ -1 +1 @@\n-export const workspace = false\n+export const workspace = true",
|
||||
}
|
||||
const cors = {
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
|
||||
"access-control-allow-headers": "content-type",
|
||||
}
|
||||
|
||||
function session(id: string, directory: string, title?: string): SessionInfo {
|
||||
return {
|
||||
id,
|
||||
projectID: project.id,
|
||||
agent: "build",
|
||||
model: { providerID: "opencode", id: "test" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
title,
|
||||
location: { directory },
|
||||
subpath: "",
|
||||
time: { created: 1, updated: 2 },
|
||||
}
|
||||
}
|
||||
|
||||
function userMessage(id: string, text: string) {
|
||||
return { id, type: "user" as const, time: { created: 1 }, text }
|
||||
}
|
||||
|
||||
async function json(route: Route, body: unknown) {
|
||||
await route.fulfill({ status: 200, contentType: "application/json", headers: cors, body: JSON.stringify(body) })
|
||||
}
|
||||
|
||||
async function init(page: Page, tab: Record<string, unknown>) {
|
||||
await page.addInitScript(
|
||||
({ root, server, tab }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ projects: { local: [{ worktree: root, expanded: true }] }, lastProject: { local: root } }),
|
||||
)
|
||||
localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify([{ server, ...tab }]))
|
||||
},
|
||||
{ root, server, tab },
|
||||
)
|
||||
}
|
||||
|
||||
test("selects an existing workspace from the start menu", async ({ page }) => {
|
||||
const draftID = "draft_workspaces"
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory: root,
|
||||
project,
|
||||
provider,
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await init(page, { type: "draft", draftID, directory: root })
|
||||
const directories = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "GET" &&
|
||||
new URL(request.url()).pathname === `/api/project/${project.id}/directories`,
|
||||
)
|
||||
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
await directories
|
||||
await expectAppVisible(page.getByRole("textbox", { name: "Prompt" }))
|
||||
|
||||
await page.getByRole("button", { name: "Local", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Workspace" }).hover()
|
||||
await page.getByRole("menuitem", { name: "feature", exact: true }).click()
|
||||
await expect(page.getByRole("button", { name: "feature", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("lists and manually deletes workspaces from settings", async ({ page }) => {
|
||||
const draftID = "draft_workspace_settings"
|
||||
const cleanWorkspace = `${workspace}-clean`
|
||||
const inventory = { ...project, sandboxes: [cleanWorkspace] }
|
||||
let releaseSessions = () => {}
|
||||
const sessionsReady = new Promise<void>((resolve) => {
|
||||
releaseSessions = resolve
|
||||
})
|
||||
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory: root,
|
||||
project: inventory,
|
||||
provider,
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route("**/api/session**", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (
|
||||
route.request().method() !== "GET" ||
|
||||
url.pathname !== "/api/session" ||
|
||||
url.searchParams.get("limit") !== "100" ||
|
||||
url.searchParams.get("order") !== "desc"
|
||||
)
|
||||
return route.fallback()
|
||||
await sessionsReady
|
||||
await json(route, { data: [], cursor: {} })
|
||||
})
|
||||
await init(page, { type: "draft", draftID, directory: root })
|
||||
const directories = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "GET" &&
|
||||
new URL(request.url()).pathname === `/api/project/${project.id}/directories`,
|
||||
)
|
||||
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
await directories
|
||||
await expectAppVisible(page.getByRole("textbox", { name: "Prompt" }))
|
||||
|
||||
await page.getByRole("button", { name: "Local", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Workspace" }).hover()
|
||||
const sessions = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "GET" &&
|
||||
new URL(request.url()).pathname === "/api/session" &&
|
||||
new URL(request.url()).searchParams.get("limit") === "100",
|
||||
)
|
||||
await page.getByRole("menuitem", { name: "View all", exact: true }).click()
|
||||
await sessions
|
||||
|
||||
const settings = page.getByRole("dialog")
|
||||
await expect(settings.getByRole("tab", { name: "Workspaces" })).toHaveAttribute("data-selected")
|
||||
await expect(page.locator('[data-component="session-new-design"]')).toBeAttached()
|
||||
releaseSessions()
|
||||
await expect(settings.getByLabel(cleanWorkspace, { exact: true })).toBeVisible()
|
||||
|
||||
await settings.getByRole("button", { name: 'Delete workspace "feature-clean"?' }).click()
|
||||
const confirmation = page.getByRole("dialog").filter({ hasText: 'Delete workspace "feature-clean"?' })
|
||||
const removed = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "DELETE" &&
|
||||
new URL(request.url()).pathname === `/experimental/project/${project.id}/copy`,
|
||||
)
|
||||
await confirmation.getByRole("button", { name: "Delete workspace", exact: true }).click()
|
||||
const request = await removed
|
||||
expect(new URL(request.url()).searchParams.get("location[directory]")).toBe(root)
|
||||
expect(request.postDataJSON()).toEqual({ directory: cleanWorkspace, force: true })
|
||||
await expect(settings.getByLabel(cleanWorkspace, { exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("submits the owning prompt after a new workspace is created", async ({ page }) => {
|
||||
const draftID = "draft_workspace_submit"
|
||||
const sessionID = "ses_workspace_submit"
|
||||
const createdSession = session(sessionID, createdWorkspace)
|
||||
let releaseCopy = () => {}
|
||||
const copyReady = new Promise<void>((resolve) => {
|
||||
releaseCopy = resolve
|
||||
})
|
||||
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory: root,
|
||||
project,
|
||||
provider,
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route(`**/experimental/project/${project.id}/copy**`, async (route) => {
|
||||
const request = route.request()
|
||||
if (request.method() === "OPTIONS") return route.fulfill({ status: 204, headers: cors })
|
||||
if (request.method() !== "POST") return route.fallback()
|
||||
await copyReady
|
||||
await json(route, { directory: createdWorkspace })
|
||||
})
|
||||
await page.route("**/api/session**", async (route) => {
|
||||
const request = route.request()
|
||||
const url = new URL(request.url())
|
||||
const promptPath = `/api/session/${sessionID}/prompt`
|
||||
if (request.method() === "OPTIONS" && (url.pathname === "/api/session" || url.pathname === promptPath))
|
||||
return route.fulfill({ status: 204, headers: cors })
|
||||
if (request.method() === "POST" && url.pathname === "/api/session")
|
||||
return json(route, { data: createdSession })
|
||||
if (request.method() === "GET" && url.pathname === `/api/session/${sessionID}`)
|
||||
return json(route, { data: createdSession })
|
||||
if (request.method() !== "POST" || url.pathname !== promptPath) return route.fallback()
|
||||
const input = request.postDataJSON() as { id: string; text: string }
|
||||
await json(route, {
|
||||
data: {
|
||||
id: input.id,
|
||||
sessionID,
|
||||
timeCreated: 3,
|
||||
type: "user",
|
||||
data: { text: input.text },
|
||||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
})
|
||||
await init(page, { type: "draft", draftID, directory: root })
|
||||
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
const editor = page.getByRole("textbox", { name: "Prompt" })
|
||||
await expectAppVisible(editor)
|
||||
await page.getByRole("button", { name: "Local", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "New workspace", exact: true }).click()
|
||||
await editor.fill("Build workspace support")
|
||||
|
||||
const copied = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "POST" &&
|
||||
new URL(request.url()).pathname === `/experimental/project/${project.id}/copy`,
|
||||
)
|
||||
const created = page.waitForRequest(
|
||||
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/api/session",
|
||||
)
|
||||
const sent = page.waitForRequest(
|
||||
(request) => request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/prompt`,
|
||||
)
|
||||
await page.getByRole("button", { name: "Send", exact: true }).click()
|
||||
|
||||
const copyRequest = await copied
|
||||
expect(new URL(copyRequest.url()).searchParams.get("location[directory]")).toBe(root)
|
||||
expect(copyRequest.postDataJSON()).toEqual({ strategy: "git_worktree", directory: "C:/OpenCode" })
|
||||
releaseCopy()
|
||||
|
||||
expect((await created).postDataJSON()).toEqual({
|
||||
agent: "build",
|
||||
model: { id: "test", providerID: "opencode" },
|
||||
location: { directory: createdWorkspace },
|
||||
})
|
||||
const promptRequest = await sent
|
||||
expect(promptRequest.postDataJSON()).toEqual({
|
||||
id: expect.stringMatching(/^msg_/),
|
||||
text: "Build workspace support",
|
||||
files: [],
|
||||
agents: [],
|
||||
})
|
||||
await expect(page.getByText("Workspace created", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("moves a changed local session through workspace creation without changing lifecycle semantics", async ({
|
||||
page,
|
||||
}) => {
|
||||
const sessionID = "ses_workspace_move_new"
|
||||
const messageID = "msg_workspace_move_new"
|
||||
const currentSession = session(sessionID, root, "Create a workspace")
|
||||
const transport = await installSseTransport<OpenCodeEvent>(page, { server })
|
||||
let releaseCopy = () => {}
|
||||
const copyReady = new Promise<void>((resolve) => {
|
||||
releaseCopy = resolve
|
||||
})
|
||||
let releaseMove = () => {}
|
||||
const moveReady = new Promise<void>((resolve) => {
|
||||
releaseMove = resolve
|
||||
})
|
||||
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory: root,
|
||||
project,
|
||||
provider,
|
||||
sessions: [currentSession],
|
||||
pageMessages: () => ({ items: [userMessage(messageID, "Create isolated workspace")] }),
|
||||
vcsDiff: [diff],
|
||||
})
|
||||
await page.route(`**/experimental/project/${project.id}/copy**`, async (route) => {
|
||||
const request = route.request()
|
||||
if (request.method() === "OPTIONS") return route.fulfill({ status: 204, headers: cors })
|
||||
if (request.method() !== "POST") return route.fallback()
|
||||
await copyReady
|
||||
await json(route, { directory: createdWorkspace })
|
||||
})
|
||||
await page.route(`**/api/session/${sessionID}**`, async (route) => {
|
||||
const request = route.request()
|
||||
const url = new URL(request.url())
|
||||
if (request.method() === "OPTIONS" && url.pathname === `/api/session/${sessionID}/move`)
|
||||
return route.fulfill({ status: 204, headers: cors })
|
||||
if (request.method() === "GET" && url.pathname === `/api/session/${sessionID}`)
|
||||
return json(route, { data: currentSession })
|
||||
if (request.method() !== "POST" || url.pathname !== `/api/session/${sessionID}/move`)
|
||||
return route.fallback()
|
||||
await moveReady
|
||||
currentSession.location.directory = createdWorkspace
|
||||
await route.fulfill({ status: 204, headers: cors })
|
||||
})
|
||||
await init(page, { type: "session", sessionId: sessionID })
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await transport.waitForConnection()
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Local repository", exact: true }).click()
|
||||
|
||||
const copied = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "POST" &&
|
||||
new URL(request.url()).pathname === `/experimental/project/${project.id}/copy`,
|
||||
)
|
||||
const moved = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/move`,
|
||||
)
|
||||
await page.getByRole("menuitem", { name: "New workspace", exact: true }).click()
|
||||
|
||||
await copied
|
||||
await expect(page.getByText("Creating workspace", { exact: true })).toBeVisible()
|
||||
releaseCopy()
|
||||
|
||||
const moveRequest = await moved
|
||||
expect(moveRequest.postDataJSON()).toEqual({ directory: createdWorkspace })
|
||||
await transport.send({
|
||||
id: "evt_workspace_created",
|
||||
created: 3,
|
||||
type: "session.moved",
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
location: { directory: root },
|
||||
data: {
|
||||
sessionID,
|
||||
location: { directory: createdWorkspace },
|
||||
subpath: "",
|
||||
},
|
||||
})
|
||||
releaseMove()
|
||||
await expect(page.getByText("Workspace created", { exact: true })).toBeVisible()
|
||||
})
|
||||
@@ -16,6 +16,7 @@
|
||||
"../src/pages/session/timeline/observe-element-offset.ts",
|
||||
"./regression/new-session-panel-corner.spec.ts",
|
||||
"./regression/session-timeline-context-resize.spec.ts",
|
||||
"./regression/workspaces.spec.ts",
|
||||
"./utils/**/*.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -175,6 +175,14 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
if (path === "/api/project") return json(route, [config.project])
|
||||
if (path === "/api/project/current")
|
||||
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
|
||||
if (/^\/api\/project\/[^/]+\/directories$/.test(path))
|
||||
return json(route, [
|
||||
{ directory: config.directory },
|
||||
...((config.project as { sandboxes?: string[] }).sandboxes ?? []).map((directory) => ({
|
||||
directory,
|
||||
strategy: "git_worktree",
|
||||
})),
|
||||
])
|
||||
if (path === "/api/location") return json(route, location(config))
|
||||
const projectCopy = path.match(/^\/experimental\/project\/([^/]+)\/copy$/)?.[1]
|
||||
if (projectCopy && route.request().method() === "POST") {
|
||||
@@ -243,7 +251,10 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const limit = Number(url.searchParams.get("limit") ?? 50)
|
||||
const offset = Number(url.searchParams.get("cursor") ?? 0)
|
||||
const sessions = config.sessions
|
||||
.filter((session) => !directory || session.directory === directory)
|
||||
.filter((session) => {
|
||||
const location = session.location as { directory?: string } | undefined
|
||||
return !directory || location?.directory === directory || session.directory === directory
|
||||
})
|
||||
.filter((session) => parentID !== "null" || session.parentID === undefined)
|
||||
.filter((session) => {
|
||||
const search = url.searchParams.get("search")?.toLowerCase()
|
||||
@@ -466,6 +477,7 @@ function currentPermission(value: unknown) {
|
||||
|
||||
export function currentSession(session: { id: string } & Record<string, unknown>, fallbackDirectory?: string) {
|
||||
const time = session.time && typeof session.time === "object" ? session.time : {}
|
||||
const location = session.location && typeof session.location === "object" ? session.location : {}
|
||||
return {
|
||||
id: session.id,
|
||||
parentID: session.parentID,
|
||||
@@ -483,10 +495,19 @@ export function currentSession(session: { id: string } & Record<string, unknown>
|
||||
},
|
||||
title: session.title ?? session.id,
|
||||
location: {
|
||||
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
|
||||
...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
|
||||
directory:
|
||||
"directory" in location && typeof location.directory === "string"
|
||||
? location.directory
|
||||
: typeof session.directory === "string"
|
||||
? session.directory
|
||||
: fallbackDirectory,
|
||||
...(typeof session.workspaceID === "string"
|
||||
? { workspaceID: session.workspaceID }
|
||||
: "workspaceID" in location && typeof location.workspaceID === "string"
|
||||
? { workspaceID: location.workspaceID }
|
||||
: {}),
|
||||
},
|
||||
subpath: session.path,
|
||||
subpath: session.subpath ?? session.path,
|
||||
revert: session.revert,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,23 +29,38 @@ export type SseEventOptions = {
|
||||
|
||||
export type SseTransport<T> = {
|
||||
server: string
|
||||
waitForConnection(options?: { after?: number; timeout?: number }): Promise<SseConnectionRecord>
|
||||
send(payload: T, options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
||||
waitForConnection(options?: {
|
||||
after?: number
|
||||
timeout?: number
|
||||
path?: SseConnectionRecord["path"]
|
||||
}): Promise<SseConnectionRecord>
|
||||
send(payload: T, options?: SseEventOptions, path?: SseConnectionRecord["path"]): Promise<SseDeliveryAcknowledgement>
|
||||
burst(payloads: readonly T[], options?: readonly SseEventOptions[]): Promise<SseDeliveryAcknowledgement[]>
|
||||
split(payload: T, cuts: readonly number[], options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
||||
heartbeat(options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
||||
writeRaw(value: string | Uint8Array, cuts?: readonly number[], marker?: string): Promise<SseDeliveryAcknowledgement>
|
||||
close(): Promise<void>
|
||||
disconnect(message?: string): Promise<void>
|
||||
error(message?: string): Promise<void>
|
||||
error(message?: string, path?: SseConnectionRecord["path"]): Promise<void>
|
||||
connections(): Promise<SseConnectionRecord[]>
|
||||
acknowledgements(): Promise<SseDeliveryAcknowledgement[]>
|
||||
}
|
||||
|
||||
type BrowserCommand<T> =
|
||||
| { type: "send"; deliveries: { payload: T; options?: SseEventOptions }[]; burst: boolean; cuts?: number[] }
|
||||
| {
|
||||
type: "send"
|
||||
deliveries: { payload: T; options?: SseEventOptions }[]
|
||||
burst: boolean
|
||||
cuts?: number[]
|
||||
path?: SseConnectionRecord["path"]
|
||||
}
|
||||
| { type: "raw"; bytes: number[]; cuts?: number[]; marker?: string }
|
||||
| { type: "end"; mode: "close" | "disconnect" | "error"; message?: string }
|
||||
| {
|
||||
type: "end"
|
||||
mode: "close" | "disconnect" | "error"
|
||||
message?: string
|
||||
path?: SseConnectionRecord["path"]
|
||||
}
|
||||
| { type: "connections" }
|
||||
| { type: "acknowledgements" }
|
||||
|
||||
@@ -73,7 +88,8 @@ export async function installSseTransport<T>(
|
||||
let nextConnectionID = 0
|
||||
let nextDeliveryID = 0
|
||||
|
||||
const current = () => connections.findLast((connection) => connection.endedAt === undefined)
|
||||
const current = (path?: SseConnectionRecord["path"]) =>
|
||||
connections.findLast((connection) => connection.endedAt === undefined && (!path || connection.path === path))
|
||||
const chunks = (bytes: Uint8Array, cuts?: readonly number[]) => {
|
||||
const boundaries = [...new Set(cuts ?? [])]
|
||||
.filter((cut) => Number.isInteger(cut) && cut > 0 && cut < bytes.byteLength)
|
||||
@@ -125,8 +141,8 @@ export async function installSseTransport<T>(
|
||||
acknowledgements.push(acknowledgement)
|
||||
return acknowledgement
|
||||
}
|
||||
const end = (mode: "close" | "disconnect" | "error", message?: string) => {
|
||||
const connection = current()
|
||||
const end = (mode: "close" | "disconnect" | "error", message?: string, path?: SseConnectionRecord["path"]) => {
|
||||
const connection = current(path)
|
||||
if (!connection) throw new Error("SSE transport has no active connection")
|
||||
connection.endedAt = performance.now()
|
||||
connection.endedBy = mode
|
||||
@@ -146,8 +162,8 @@ export async function installSseTransport<T>(
|
||||
if (input.type === "connections")
|
||||
return connections.map(({ controller: _controller, ...connection }) => connection)
|
||||
if (input.type === "acknowledgements") return acknowledgements
|
||||
if (input.type === "end") return end(input.mode, input.message)
|
||||
const connection = current()
|
||||
if (input.type === "end") return end(input.mode, input.message, input.path)
|
||||
const connection = current(input.type === "send" ? input.path : undefined)
|
||||
if (!connection) throw new Error("SSE transport has no active connection")
|
||||
if (input.type === "raw") {
|
||||
marker(input.marker)
|
||||
@@ -235,12 +251,15 @@ export async function installSseTransport<T>(
|
||||
server,
|
||||
async waitForConnection(input = {}) {
|
||||
const connection = await page.waitForFunction(
|
||||
(after) => {
|
||||
({ after, path }) => {
|
||||
const transport = (window as BrowserTransport).__testSseTransport
|
||||
const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined
|
||||
return connections?.findLast((connection) => connection.id > after && connection.endedAt === undefined)
|
||||
return connections?.findLast(
|
||||
(connection) =>
|
||||
connection.id > after && connection.endedAt === undefined && (!path || connection.path === path),
|
||||
)
|
||||
},
|
||||
input.after ?? 0,
|
||||
{ after: input.after ?? 0, path: input.path },
|
||||
{ timeout: input.timeout },
|
||||
)
|
||||
let result: SseConnectionRecord | undefined
|
||||
@@ -252,8 +271,8 @@ export async function installSseTransport<T>(
|
||||
if (!result) throw new Error("SSE transport connection disappeared while waiting")
|
||||
return result
|
||||
},
|
||||
send(payload, eventOptions) {
|
||||
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false })
|
||||
send(payload, eventOptions, path) {
|
||||
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false, path })
|
||||
},
|
||||
burst(payloads, eventOptions = []) {
|
||||
return command({
|
||||
@@ -291,8 +310,8 @@ export async function installSseTransport<T>(
|
||||
disconnect(message) {
|
||||
return command({ type: "end", mode: "disconnect", message })
|
||||
},
|
||||
error(message) {
|
||||
return command({ type: "end", mode: "error", message })
|
||||
error(message, path) {
|
||||
return command({ type: "end", mode: "error", message, path })
|
||||
},
|
||||
connections() {
|
||||
return command({ type: "connections" })
|
||||
|
||||
+10
-28
@@ -1,6 +1,7 @@
|
||||
import "@/index.css"
|
||||
import * as Sentry from "@sentry/solid"
|
||||
import { I18nProvider } from "@opencode-ai/ui/context"
|
||||
import type { UiI18n } from "@opencode-ai/ui/context/i18n"
|
||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||
import { File } from "@opencode-ai/session-ui/file"
|
||||
@@ -51,10 +52,9 @@ import { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||
import Layout from "@/pages/layout"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { useCheckServerHealth } from "./utils/server-health"
|
||||
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||
import { legacySessionServer, sessionHref } from "./utils/session-route"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
|
||||
import { TargetSessionRouteContent } from "@/pages/session"
|
||||
import { TargetSessionRoute } from "@/pages/session-lazy"
|
||||
import { Home } from "@/pages/home"
|
||||
|
||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||
@@ -75,30 +75,6 @@ const DirectoryDraftRedirect = () => {
|
||||
return null
|
||||
}
|
||||
|
||||
function TargetServerRoute(props: ParentProps) {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
const global = useGlobal()
|
||||
const conn = createMemo(() => {
|
||||
const key = requireServerKey(params.serverKey)
|
||||
return global.servers.list().find((item) => ServerConnection.key(item) === key)
|
||||
})
|
||||
|
||||
return (
|
||||
// Owns the server-identity remount. Session changes must not remount this subtree.
|
||||
<Show when={requireServerKey(params.serverKey)} keyed>
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
const TargetSessionRoute = () => (
|
||||
<TargetServerRoute>
|
||||
<TargetSessionRouteContent />
|
||||
</TargetServerRoute>
|
||||
)
|
||||
|
||||
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
||||
// server via ServerKey, then provide the server-scoped shell for that server.
|
||||
function SelectedServerProviders(props: ParentProps) {
|
||||
@@ -156,7 +132,13 @@ function UiI18nBridge(props: ParentProps) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<I18nProvider
|
||||
value={{ locale: language.intl, layoutLocale: language.layoutLocale, t: language.t, plural: language.plural }}
|
||||
value={{
|
||||
locale: language.intl,
|
||||
layoutLocale: language.layoutLocale,
|
||||
t: language.t as UiI18n["t"],
|
||||
plural: language.plural,
|
||||
pluralForm: language.pluralForm,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</I18nProvider>
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
.project-settings-v2-dialog [data-slot="dialog-container"] {
|
||||
background: var(--v2-background-bg-base);
|
||||
}
|
||||
|
||||
.project-settings-v2-dialog [data-slot="dialog-body"] {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.project-settings-v2 {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.project-settings-v2-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.project-settings-v2-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden !important;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.project-settings-v2-panel :is(input, textarea, [contenteditable="true"]) {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.project-settings-v2-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.project-settings-v2-scroll {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
min-height: 0;
|
||||
padding: 40px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.project-settings-page-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.project-settings-page-header h2 {
|
||||
color: var(--v2-text-text-base);
|
||||
font-size: 15px;
|
||||
font-weight: 640;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.project-settings-page-header > span {
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.project-settings-extensions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.project-settings-extension-tabs {
|
||||
flex: 1;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
margin-top: 24px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
|
||||
width: auto;
|
||||
padding-inline: 0 !important;
|
||||
}
|
||||
|
||||
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"]::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"]
|
||||
> [data-slot="tabs-v2-list"]
|
||||
[data-slot="tabs-v2-trigger-wrapper"] {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"]
|
||||
> [data-slot="tabs-v2-list"]
|
||||
[data-slot="tabs-v2-trigger"] {
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.project-settings-extension-tabs [data-slot="tabs-v2-content"] {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.project-settings-extension-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.project-settings-extension-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
color: var(--v2-text-text-base);
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.project-settings-extension-section-header > :last-child {
|
||||
color: var(--v2-text-text-faint);
|
||||
font-size: 11px;
|
||||
font-weight: 440;
|
||||
}
|
||||
|
||||
.project-settings-extension-link {
|
||||
color: var(--v2-text-text-accent) !important;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.project-settings-extension-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.project-settings-extension-card {
|
||||
padding-inline: 12px;
|
||||
overflow: hidden;
|
||||
border: 0.5px solid var(--v2-border-border-base);
|
||||
border-radius: 8px;
|
||||
background: var(--v2-background-bg-base);
|
||||
}
|
||||
|
||||
.project-settings-extension-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 48px;
|
||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.project-settings-extension-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.project-settings-extension-row-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-settings-extension-row-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-icon-icon-muted);
|
||||
}
|
||||
|
||||
.project-settings-extension-row-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--v2-text-text-base);
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-settings-extension-row-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.project-settings-extension-row-status-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--v2-state-fg-warning);
|
||||
}
|
||||
|
||||
.project-settings-shared {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.project-settings-shared-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
gap: 6px;
|
||||
color: var(--v2-text-text-base);
|
||||
font-size: 11px;
|
||||
font-weight: 530;
|
||||
}
|
||||
|
||||
.project-settings-shared-chevron {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: var(--v2-icon-icon-muted);
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.project-settings-shared-chevron.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.project-settings-shared-count {
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
background: var(--v2-background-bg-layer-02);
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 9px;
|
||||
}
|
||||
@@ -1,156 +1,223 @@
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { Field } from "@opencode-ai/ui/v2/field-v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
||||
import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { For, Show } from "solid-js"
|
||||
import { For, Show, createSignal, startTransition } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
|
||||
import { SDKProvider } from "@/context/sdk"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||
import { createEditProjectModel } from "./edit-project"
|
||||
import { ProjectSettingsExtensions } from "./project-settings-extensions"
|
||||
import { SettingsServerDataScope } from "./settings-server-picker"
|
||||
import "./settings-v2/settings-v2.css"
|
||||
import "./dialog-edit-project-v2.css"
|
||||
|
||||
export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||
return (
|
||||
<SettingsServerDataScope server={props.server}>
|
||||
<SDKProvider directory={props.project.worktree}>
|
||||
<ProjectSettingsDialog project={props.project} server={props.server} />
|
||||
</SDKProvider>
|
||||
</SettingsServerDataScope>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectSettingsDialog(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||
const language = useLanguage()
|
||||
const model = createEditProjectModel(props)
|
||||
const projectName = () => displayName(props.project)
|
||||
const [tab, setTab] = createSignal("general")
|
||||
|
||||
const Footer = () => (
|
||||
<DialogFooter>
|
||||
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
|
||||
{language.t("common.cancel")}
|
||||
</ButtonV2>
|
||||
<ButtonV2 type="submit" variant="contrast" disabled={!model.supported || model.save.isPending}>
|
||||
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||
</ButtonV2>
|
||||
</DialogFooter>
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog fit>
|
||||
<form onSubmit={model.submit} class="contents">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{language.t("dialog.project.edit.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DividerV2 />
|
||||
<DialogBody class="flex max-h-[min(560px,calc(100vh-160px))] w-full flex-col gap-6 overflow-y-auto px-4 pt-4 pb-1">
|
||||
<Field>
|
||||
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
|
||||
<TextInputV2
|
||||
autofocus
|
||||
appearance="large"
|
||||
class="!w-full"
|
||||
value={model.store.name}
|
||||
placeholder={model.folderName()}
|
||||
onInput={(event) => model.setStore("name", event.currentTarget.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
||||
{language.t("dialog.project.edit.icon")}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={language.t("dialog.project.edit.icon.alt")}
|
||||
class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus"
|
||||
classList={{
|
||||
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
|
||||
}}
|
||||
onMouseEnter={() => model.setStore("iconHover", true)}
|
||||
onMouseLeave={() => model.setStore("iconHover", false)}
|
||||
onDrop={model.drop}
|
||||
onDragOver={model.dragOver}
|
||||
onDragLeave={model.dragLeave}
|
||||
onClick={model.iconClick}
|
||||
>
|
||||
<ProjectAvatar
|
||||
fallback={model.store.name || model.defaultName()}
|
||||
src={getProjectAvatarSource(props.project.id, {
|
||||
color: model.store.color,
|
||||
url: props.project.icon?.url,
|
||||
override: model.store.iconOverride,
|
||||
})}
|
||||
variant={getProjectAvatarVariant(model.store.color)}
|
||||
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
|
||||
/>
|
||||
<span
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
|
||||
classList={{
|
||||
"opacity-100": model.store.iconHover,
|
||||
"opacity-0": !model.store.iconHover,
|
||||
}}
|
||||
>
|
||||
<Icon name={model.store.iconOverride ? "close" : "outline-share"} />
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
ref={(element) => {
|
||||
model.setIconInput(element)
|
||||
}}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
onChange={model.inputChange}
|
||||
<Dialog size="x-large" variant="settings" class="project-settings-v2-dialog">
|
||||
<TabsV2
|
||||
orientation="vertical"
|
||||
variant="settings"
|
||||
value={tab()}
|
||||
onChange={(value) => void startTransition(() => setTab(value))}
|
||||
class="project-settings-v2"
|
||||
>
|
||||
<TabsV2.List>
|
||||
<div class="project-settings-v2-nav">
|
||||
<TabsV2.Trigger value="general">
|
||||
<ProjectAvatar
|
||||
fallback={projectName()}
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
class="!size-4 shrink-0"
|
||||
/>
|
||||
<div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
|
||||
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
||||
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="truncate">{projectName()}</span>
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="scripts">
|
||||
<Icon name="code" size="small" />
|
||||
{language.t("project.settings.scripts")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="extensions">
|
||||
<Icon name="extensions" size="small" />
|
||||
{language.t("settings.tab.extensions")}
|
||||
</TabsV2.Trigger>
|
||||
</div>
|
||||
</TabsV2.List>
|
||||
|
||||
<Show when={!model.store.iconOverride}>
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
||||
{language.t("dialog.project.edit.color")}
|
||||
<TabsV2.Content value="general" class="project-settings-v2-panel">
|
||||
<form onSubmit={model.submit} class="project-settings-v2-form">
|
||||
<div class="project-settings-v2-scroll">
|
||||
<div class="project-settings-page-header">
|
||||
<h2>{language.t("dialog.project.edit.title")}</h2>
|
||||
<span>{language.t("project.settings.general.description")}</span>
|
||||
</div>
|
||||
<div class="-ml-1 flex gap-1.5">
|
||||
<For each={PROJECT_AVATAR_VARIANTS}>
|
||||
{(color) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
||||
aria-pressed={getProjectAvatarVariant(model.store.color) === color}
|
||||
class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
|
||||
|
||||
<Field>
|
||||
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
|
||||
<TextInputV2
|
||||
autofocus
|
||||
appearance="large"
|
||||
class="!w-full"
|
||||
value={model.store.name}
|
||||
placeholder={model.folderName()}
|
||||
onInput={(event) => model.setStore("name", event.currentTarget.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
||||
{language.t("dialog.project.edit.icon")}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={language.t("dialog.project.edit.icon.alt")}
|
||||
class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus"
|
||||
classList={{
|
||||
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
|
||||
}}
|
||||
onMouseEnter={() => model.setStore("iconHover", true)}
|
||||
onMouseLeave={() => model.setStore("iconHover", false)}
|
||||
onDrop={model.drop}
|
||||
onDragOver={model.dragOver}
|
||||
onDragLeave={model.dragLeave}
|
||||
onClick={model.iconClick}
|
||||
>
|
||||
<ProjectAvatar
|
||||
fallback={model.store.name || model.defaultName()}
|
||||
src={getProjectAvatarSource(props.project.id, {
|
||||
color: model.store.color,
|
||||
url: props.project.icon?.url,
|
||||
override: model.store.iconOverride,
|
||||
})}
|
||||
variant={getProjectAvatarVariant(model.store.color)}
|
||||
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
|
||||
/>
|
||||
<span
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
|
||||
classList={{
|
||||
"bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
|
||||
getProjectAvatarVariant(model.store.color) === color,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
|
||||
model.setStore(
|
||||
"color",
|
||||
getProjectAvatarVariant(model.store.color) === color ? undefined : color,
|
||||
)
|
||||
"opacity-100": model.store.iconHover,
|
||||
"opacity-0": !model.store.iconHover,
|
||||
}}
|
||||
>
|
||||
<ProjectAvatar
|
||||
fallback={model.store.name || model.defaultName()}
|
||||
variant={getProjectAvatarVariant(color)}
|
||||
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<IconV2 name={model.store.iconOverride ? "close" : "share"} />
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
ref={(element) => model.setIconInput(element)}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
onChange={model.inputChange}
|
||||
/>
|
||||
<div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
|
||||
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
||||
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Field>
|
||||
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
|
||||
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
|
||||
<TextareaV2
|
||||
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
|
||||
rows={3}
|
||||
value={model.store.startup}
|
||||
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
||||
spellcheck={false}
|
||||
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
|
||||
/>
|
||||
</Field>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
|
||||
{language.t("common.cancel")}
|
||||
</ButtonV2>
|
||||
<ButtonV2 type="submit" variant="contrast" disabled={!model.supported || model.save.isPending}>
|
||||
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||
</ButtonV2>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
<Show when={!model.store.iconOverride}>
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
||||
{language.t("dialog.project.edit.color")}
|
||||
</div>
|
||||
<div class="-ml-1 flex gap-1.5">
|
||||
<For each={PROJECT_AVATAR_VARIANTS}>
|
||||
{(color) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
||||
aria-pressed={getProjectAvatarVariant(model.store.color) === color}
|
||||
class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
|
||||
classList={{
|
||||
"bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
|
||||
getProjectAvatarVariant(model.store.color) === color,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
|
||||
model.setStore(
|
||||
"color",
|
||||
getProjectAvatarVariant(model.store.color) === color ? undefined : color,
|
||||
)
|
||||
}}
|
||||
>
|
||||
<ProjectAvatar
|
||||
fallback={model.store.name || model.defaultName()}
|
||||
variant={getProjectAvatarVariant(color)}
|
||||
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Footer />
|
||||
</form>
|
||||
</TabsV2.Content>
|
||||
|
||||
<TabsV2.Content value="scripts" class="project-settings-v2-panel">
|
||||
<form onSubmit={model.submit} class="project-settings-v2-form">
|
||||
<div class="project-settings-v2-scroll">
|
||||
<div class="project-settings-page-header">
|
||||
<h2>{language.t("project.settings.scripts")}</h2>
|
||||
<span>{language.t("project.settings.scripts.description")}</span>
|
||||
</div>
|
||||
<Field>
|
||||
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
|
||||
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
|
||||
<TextareaV2
|
||||
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
|
||||
rows={5}
|
||||
value={model.store.startup}
|
||||
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
||||
spellcheck={false}
|
||||
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Footer />
|
||||
</form>
|
||||
</TabsV2.Content>
|
||||
|
||||
<TabsV2.Content value="extensions" class="project-settings-v2-panel">
|
||||
<ProjectSettingsExtensions />
|
||||
</TabsV2.Content>
|
||||
</TabsV2>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
||||
import { type Component, For, Show, createMemo, createResource, createSignal } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useMcpToggle } from "@/context/mcp"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { ExternalLink } from "./external-link"
|
||||
|
||||
type SkillItem = {
|
||||
name: string
|
||||
location: string
|
||||
}
|
||||
|
||||
const pluginName = (item: string | [string, Record<string, unknown>]) => (typeof item === "string" ? item : item[0])
|
||||
const skillKey = (item: SkillItem) => `${item.name}\n${item.location}`
|
||||
|
||||
const ExtensionCard: Component<{ children: unknown }> = (props) => (
|
||||
<div class="project-settings-extension-card">{props.children as any}</div>
|
||||
)
|
||||
|
||||
const ExtensionRow: Component<{
|
||||
icon: "mcp" | "cube" | "post-skill" | "code"
|
||||
name: string
|
||||
status?: string
|
||||
children?: unknown
|
||||
}> = (props) => (
|
||||
<div class="project-settings-extension-row">
|
||||
<div class="project-settings-extension-row-main">
|
||||
<Icon name={props.icon} class="project-settings-extension-row-icon" />
|
||||
<span class="project-settings-extension-row-name">{props.name}</span>
|
||||
</div>
|
||||
<Show when={props.status}>
|
||||
{(status) => (
|
||||
<span class="project-settings-extension-row-status">
|
||||
<span class="project-settings-extension-row-status-dot" />
|
||||
{status()}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
{props.children as any}
|
||||
</div>
|
||||
)
|
||||
|
||||
const SharedSection: Component<{
|
||||
count: number
|
||||
children: unknown
|
||||
}> = (props) => {
|
||||
const language = useLanguage()
|
||||
const [open, setOpen] = createSignal(false)
|
||||
return (
|
||||
<Show when={props.count > 0}>
|
||||
<div class="project-settings-shared">
|
||||
<button
|
||||
type="button"
|
||||
class="project-settings-shared-trigger"
|
||||
aria-expanded={open()}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
<Icon name="chevron-right" classList={{ "project-settings-shared-chevron": true, open: open() }} />
|
||||
<span>{language.t("project.settings.extensions.shared")}</span>
|
||||
<span class="project-settings-shared-count">{props.count}</span>
|
||||
</button>
|
||||
<Show when={open()}>
|
||||
<ExtensionCard>{props.children}</ExtensionCard>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export const ProjectSettingsExtensions: Component = () => {
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const directorySDK = useSDK()
|
||||
const serverSync = useServerSync()
|
||||
const sync = useSync()
|
||||
const toggleMcp = useMcpToggle()
|
||||
|
||||
const [serverMcp] = createResource(
|
||||
serverSDK,
|
||||
(sdk) =>
|
||||
sdk.api.mcp
|
||||
.list()
|
||||
.then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status])))
|
||||
.catch(() => ({})),
|
||||
{ initialValue: {} },
|
||||
)
|
||||
const globalMcpNames = createMemo(() =>
|
||||
[...new Set([...Object.keys(serverSync().data.config.mcp ?? {}), ...Object.keys(serverMcp.latest)])].sort(),
|
||||
)
|
||||
const projectMcpNames = createMemo(() => {
|
||||
const shared = new Set(globalMcpNames())
|
||||
const configured = Object.keys(sync().data.config.mcp ?? {}).filter((name) => !shared.has(name))
|
||||
if (configured.length > 0) return configured.sort()
|
||||
return Object.keys(sync().data.mcp ?? {})
|
||||
.filter((name) => !shared.has(name))
|
||||
.sort()
|
||||
})
|
||||
const mcpEnabled = (name: string) => sync().data.mcp?.[name]?.status === "connected"
|
||||
|
||||
const globalPlugins = createMemo(() => (serverSync().data.config.plugin ?? []).map(pluginName))
|
||||
const projectPlugins = createMemo(() => {
|
||||
const shared = new Set(globalPlugins())
|
||||
return (sync().data.config.plugin ?? []).map(pluginName).filter((name) => !shared.has(name))
|
||||
})
|
||||
|
||||
const [serverSkills] = createResource(
|
||||
serverSDK,
|
||||
(sdk): Promise<SkillItem[]> =>
|
||||
sdk.api.skill.list().then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
|
||||
{ initialValue: [] },
|
||||
)
|
||||
const [directorySkills] = createResource(
|
||||
directorySDK,
|
||||
(sdk): Promise<SkillItem[]> =>
|
||||
sdk.api.skill
|
||||
.list({ location: { directory: sdk.directory } })
|
||||
.then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
|
||||
{ initialValue: [] },
|
||||
)
|
||||
const projectSkills = createMemo(() => {
|
||||
const shared = new Set(serverSkills.latest.map(skillKey))
|
||||
return directorySkills.latest.filter((item) => !shared.has(skillKey(item)))
|
||||
})
|
||||
|
||||
const mcpRows = (items: string[]) => (
|
||||
<For each={items}>
|
||||
{(name) => (
|
||||
<ExtensionRow icon="mcp" name={name}>
|
||||
<Switch
|
||||
checked={mcpEnabled(name)}
|
||||
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
||||
hideLabel
|
||||
onChange={() => {
|
||||
if (toggleMcp.isPending) return
|
||||
toggleMcp.mutate(name)
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Switch>
|
||||
</ExtensionRow>
|
||||
)}
|
||||
</For>
|
||||
)
|
||||
|
||||
const pluginRows = (items: string[]) => <For each={items}>{(name) => <ExtensionRow icon="cube" name={name} />}</For>
|
||||
|
||||
const skillRows = (items: SkillItem[]) => (
|
||||
<For each={items}>{(item) => <ExtensionRow icon="post-skill" name={item.name} />}</For>
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="project-settings-extensions">
|
||||
<div class="project-settings-page-header">
|
||||
<h2>{language.t("settings.tab.extensions")}</h2>
|
||||
<span>{language.t("project.settings.extensions.description")}</span>
|
||||
</div>
|
||||
|
||||
<TabsV2 variant="pill" defaultValue="mcps" class="project-settings-extension-tabs">
|
||||
<TabsV2.List>
|
||||
<TabsV2.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="lsps">{language.t("project.settings.extensions.tab.lsps")}</TabsV2.Trigger>
|
||||
</TabsV2.List>
|
||||
|
||||
<TabsV2.Content value="mcps">
|
||||
<div class="project-settings-extension-section">
|
||||
<div class="project-settings-extension-section-header">
|
||||
<span>{language.t("project.settings.extensions.added")}</span>
|
||||
<span>{language.t("settings.extensions.manageConfig")}</span>
|
||||
</div>
|
||||
<Show when={projectMcpNames().length > 0}>
|
||||
<ExtensionCard>{mcpRows(projectMcpNames())}</ExtensionCard>
|
||||
</Show>
|
||||
<SharedSection count={globalMcpNames().length}>{mcpRows(globalMcpNames())}</SharedSection>
|
||||
</div>
|
||||
</TabsV2.Content>
|
||||
|
||||
<TabsV2.Content value="plugins">
|
||||
<div class="project-settings-extension-section">
|
||||
<div class="project-settings-extension-section-header">
|
||||
<span>{language.t("project.settings.extensions.added")}</span>
|
||||
<span>{language.t("settings.extensions.manageConfig")}</span>
|
||||
</div>
|
||||
<Show when={projectPlugins().length > 0}>
|
||||
<ExtensionCard>{pluginRows(projectPlugins())}</ExtensionCard>
|
||||
</Show>
|
||||
<SharedSection count={globalPlugins().length}>{pluginRows(globalPlugins())}</SharedSection>
|
||||
</div>
|
||||
</TabsV2.Content>
|
||||
|
||||
<TabsV2.Content value="skills">
|
||||
<div class="project-settings-extension-section">
|
||||
<div class="project-settings-extension-section-header">
|
||||
<span>{language.t("project.settings.extensions.added")}</span>
|
||||
<ExternalLink class="project-settings-extension-link" href="https://opencode.ai/docs/skills/">
|
||||
{language.t("settings.extensions.addSkills")}
|
||||
</ExternalLink>
|
||||
</div>
|
||||
<Show when={projectSkills().length > 0}>
|
||||
<ExtensionCard>{skillRows(projectSkills())}</ExtensionCard>
|
||||
</Show>
|
||||
<SharedSection count={serverSkills.latest.length}>{skillRows(serverSkills.latest)}</SharedSection>
|
||||
</div>
|
||||
</TabsV2.Content>
|
||||
|
||||
<TabsV2.Content value="lsps">
|
||||
<div class="project-settings-extension-section">
|
||||
<div class="project-settings-extension-section-header">
|
||||
<span>{language.t("project.settings.extensions.lsp.detected")}</span>
|
||||
<span>{language.t("project.settings.extensions.lsp.description")}</span>
|
||||
</div>
|
||||
<Show when={sync().data.lsp.length > 0}>
|
||||
<ExtensionCard>
|
||||
<For each={sync().data.lsp}>
|
||||
{(item) => (
|
||||
<ExtensionRow
|
||||
icon="code"
|
||||
name={item.name || item.id}
|
||||
status={
|
||||
item.status === "error" ? language.t("project.settings.extensions.setupRequired") : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</ExtensionCard>
|
||||
</Show>
|
||||
</div>
|
||||
</TabsV2.Content>
|
||||
</TabsV2>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -37,6 +37,7 @@ export type PromptInputV2ComposerProps = {
|
||||
class?: string
|
||||
controller: PromptInputV2ComposerController
|
||||
borderUnderlay?: boolean
|
||||
accentSubmit?: boolean
|
||||
}
|
||||
|
||||
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
|
||||
@@ -53,6 +54,7 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
|
||||
<div class="flex flex-col gap-3">
|
||||
<PromptInputV2
|
||||
controller={props.controller}
|
||||
accentSubmit={props.accentSubmit}
|
||||
borderUnderlay={props.borderUnderlay}
|
||||
class={props.class}
|
||||
variantControlVisible={!props.controller.model.loading}
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Prompt, PromptStore } from "@/context/prompt"
|
||||
import type { ModelSelection } from "@/context/local"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
|
||||
let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||
|
||||
const createdClients: string[] = []
|
||||
const createdSessions: string[] = []
|
||||
const sessionCreateInputs: Array<{
|
||||
type SessionCreateInput = {
|
||||
agent?: string
|
||||
model?: { id: string; providerID: string; variant?: string }
|
||||
location?: { directory: string }
|
||||
}> = []
|
||||
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
|
||||
}
|
||||
const optimistic: Array<{
|
||||
directory?: string
|
||||
sessionID?: string
|
||||
@@ -22,11 +21,9 @@ const optimistic: Array<{
|
||||
variant?: string
|
||||
}
|
||||
}> = []
|
||||
const optimisticSeeded: boolean[] = []
|
||||
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
||||
const promoted: Array<{ directory: string; sessionID: string }> = []
|
||||
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
|
||||
const syncedDirectories: string[] = []
|
||||
const sentShellDirectories: string[] = []
|
||||
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
|
||||
const sentPrompts: string[] = []
|
||||
const promptInputs: unknown[] = []
|
||||
@@ -37,15 +34,29 @@ const switchedModels: Array<{
|
||||
model: { id: string; providerID: string; variant?: string }
|
||||
}> = []
|
||||
const sessionRequestOrder: string[] = []
|
||||
const commands: Array<{ name: string }> = []
|
||||
const updatedDrafts: Array<{ draftID: string; worktree?: string }> = []
|
||||
const syncedServers: string[] = []
|
||||
const optimisticServers: string[] = []
|
||||
const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = []
|
||||
let serverSessionSyncs = 0
|
||||
|
||||
let params: { id?: string } = {}
|
||||
let search: { draftId?: string } = {}
|
||||
let selected = "/repo/worktree-a"
|
||||
let variant: string | undefined
|
||||
let permissionServer = "server-a"
|
||||
let createSessionGate: Promise<void> | undefined
|
||||
let createWorktreeGate: Promise<void> | undefined
|
||||
let worktreeFailure: Error | undefined
|
||||
let worktreeHung = false
|
||||
let worktreeCreates = 0
|
||||
let activeSDK = "server-a"
|
||||
let activeServerSync = "server-a"
|
||||
let activeDirectorySync = "server-a"
|
||||
let commands: Array<{ name: string }> = []
|
||||
let worktreeDirectory = "/repo/new-0"
|
||||
let worktreeID = 0
|
||||
const draftServers: Record<string, string> = {}
|
||||
const sessionDirectories: Record<string, string> = {}
|
||||
|
||||
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
const [promptStore, setPromptStore] = createStore<PromptStore>({
|
||||
@@ -73,21 +84,25 @@ const prompt = {
|
||||
replaceComments: () => undefined,
|
||||
items: () => [],
|
||||
},
|
||||
capture: () => prompt,
|
||||
capture: (scope?: unknown, target?: unknown) => {
|
||||
promptCaptures.push({ scope, target })
|
||||
return prompt
|
||||
},
|
||||
}
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
const clientFor = (directory: string) => {
|
||||
createdClients.push(directory)
|
||||
return {
|
||||
api: {
|
||||
session: {
|
||||
create: async (input: (typeof sessionCreateInputs)[number]) => {
|
||||
create: async (input: SessionCreateInput) => {
|
||||
await createSessionGate
|
||||
const location = input.location?.directory ?? directory
|
||||
createdSessions.push(location)
|
||||
sessionCreateInputs.push(input)
|
||||
const id = `session-${createdSessions.length}`
|
||||
sessionDirectories[id] = location
|
||||
return {
|
||||
id: `session-${createdSessions.length}`,
|
||||
id,
|
||||
projectID: "project",
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
@@ -100,7 +115,7 @@ const clientFor = (directory: string) => {
|
||||
},
|
||||
prompt: async (input: unknown) => {
|
||||
sessionRequestOrder.push("prompt")
|
||||
sentPrompts.push(directory)
|
||||
sentPrompts.push(sessionDirectories[(input as { sessionID: string }).sessionID] ?? directory)
|
||||
promptInputs.push(input)
|
||||
return { data: undefined }
|
||||
},
|
||||
@@ -120,6 +135,19 @@ const clientFor = (directory: string) => {
|
||||
},
|
||||
shell: async (input: { sessionID: string; id?: string; command: string }) => {
|
||||
sentShell.push(input)
|
||||
sentShellDirectories.push(sessionDirectories[input.sessionID] ?? directory)
|
||||
},
|
||||
},
|
||||
projectCopy: {
|
||||
create: async (_input: unknown, options?: { signal?: AbortSignal }) => {
|
||||
worktreeCreates++
|
||||
if (worktreeHung)
|
||||
return new Promise<never>((_, reject) => {
|
||||
options?.signal?.addEventListener("abort", () => reject(options.signal?.reason), { once: true })
|
||||
})
|
||||
await createWorktreeGate
|
||||
if (worktreeFailure) throw worktreeFailure
|
||||
return { directory: worktreeDirectory }
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -127,9 +155,6 @@ const clientFor = (directory: string) => {
|
||||
command: async () => ({ data: undefined }),
|
||||
abort: async () => ({ data: undefined }),
|
||||
},
|
||||
worktree: {
|
||||
create: async () => ({ data: { directory: `${directory}/new` } }),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +170,7 @@ beforeAll(async () => {
|
||||
|
||||
mock.module("@opencode-ai/ui/toast", () => ({
|
||||
Toast: { Region: () => null },
|
||||
toaster: { create: () => undefined, show: () => undefined, dismiss: () => undefined },
|
||||
showToast: () => 0,
|
||||
}))
|
||||
|
||||
@@ -162,20 +188,13 @@ beforeAll(async () => {
|
||||
current: () => ({ name: "agent" }),
|
||||
},
|
||||
session: {
|
||||
promote(directory: string, sessionID: string) {
|
||||
promoted.push({ directory, sessionID })
|
||||
},
|
||||
promote: () => undefined,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/permission", () => {
|
||||
const state = (server: string) => ({
|
||||
enableAutoAccept(sessionID: string, directory: string) {
|
||||
enabledAutoAccept.push({ server, sessionID, directory })
|
||||
},
|
||||
})
|
||||
return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) }
|
||||
return { usePermission: () => ({ currentServerState: () => ({ enableAutoAccept: () => undefined }) }) }
|
||||
})
|
||||
|
||||
mock.module("@/context/server", () => ({
|
||||
@@ -184,7 +203,10 @@ beforeAll(async () => {
|
||||
|
||||
mock.module("@/context/tabs", () => ({
|
||||
useTabs: () => ({
|
||||
draft: () => ({ server: "project-server" }),
|
||||
draft: (draftID: string) => ({ server: draftServers[draftID] ?? "project-server" }),
|
||||
updateDraft: (draftID: string, draft: { worktree?: string }) => {
|
||||
updatedDrafts.push({ draftID, ...draft })
|
||||
},
|
||||
promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => {
|
||||
promotedDrafts.push({ draftID, ...session })
|
||||
},
|
||||
@@ -205,68 +227,70 @@ beforeAll(async () => {
|
||||
|
||||
mock.module("@/context/sdk", () => ({
|
||||
useSDK: () => {
|
||||
const sdk = {
|
||||
scope: "local",
|
||||
directory: "/repo/main",
|
||||
return () => ({
|
||||
scope: activeSDK === "server-a" ? ServerScope.local : "server-b",
|
||||
directory: activeSDK === "server-a" ? "/repo/main" : "/repo/other",
|
||||
api: rootClient.api,
|
||||
url: "http://localhost:4096",
|
||||
}
|
||||
return () => sdk
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/context/sync", () => ({
|
||||
useSync: () => () => ({
|
||||
data: { command: commands },
|
||||
session: {
|
||||
optimistic: {
|
||||
add: (value: {
|
||||
directory?: string
|
||||
sessionID?: string
|
||||
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
|
||||
}) => {
|
||||
optimistic.push(value)
|
||||
optimisticSeeded.push(
|
||||
!!value.directory &&
|
||||
!!value.sessionID &&
|
||||
!!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title,
|
||||
)
|
||||
useSync: () => () => {
|
||||
const server = activeDirectorySync
|
||||
return {
|
||||
data: { command: commands, project: "project" },
|
||||
session: {
|
||||
optimistic: {
|
||||
add: (value: {
|
||||
directory?: string
|
||||
sessionID?: string
|
||||
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
|
||||
}) => {
|
||||
optimisticServers.push(server)
|
||||
optimistic.push(value)
|
||||
},
|
||||
remove: () => undefined,
|
||||
},
|
||||
remove: () => undefined,
|
||||
},
|
||||
},
|
||||
set: () => undefined,
|
||||
}),
|
||||
set: () => undefined,
|
||||
project: { worktree: server === "server-a" ? "/repo/main" : "/repo/other" },
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/context/server-sync", () => ({
|
||||
useServerSync: () => () => ({
|
||||
session: {
|
||||
remember: () => undefined,
|
||||
set: () => undefined,
|
||||
sync: async () => {
|
||||
serverSessionSyncs++
|
||||
},
|
||||
},
|
||||
child: (directory: string) => {
|
||||
syncedDirectories.push(directory)
|
||||
storedSessions[directory] ??= []
|
||||
return [
|
||||
{ session: storedSessions[directory] },
|
||||
(...args: unknown[]) => {
|
||||
if (args[0] !== "session") return
|
||||
const next = args[1]
|
||||
if (typeof next === "function") {
|
||||
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
|
||||
return
|
||||
}
|
||||
if (Array.isArray(next)) {
|
||||
storedSessions[directory] = next as Array<{ id: string; title?: string }>
|
||||
}
|
||||
useServerSync: () => () => {
|
||||
const server = activeServerSync
|
||||
return {
|
||||
session: {
|
||||
remember: () => undefined,
|
||||
set: () => undefined,
|
||||
sync: async () => {
|
||||
serverSessionSyncs++
|
||||
},
|
||||
]
|
||||
},
|
||||
}),
|
||||
},
|
||||
child: (directory: string) => {
|
||||
syncedServers.push(server)
|
||||
storedSessions[directory] ??= []
|
||||
return [
|
||||
{ session: storedSessions[directory] },
|
||||
(...args: unknown[]) => {
|
||||
if (args[0] !== "session") return
|
||||
const next = args[1]
|
||||
if (typeof next === "function") {
|
||||
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
|
||||
return
|
||||
}
|
||||
if (Array.isArray(next)) {
|
||||
storedSessions[directory] = next as Array<{ id: string; title?: string }>
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/context/platform", () => ({
|
||||
@@ -286,205 +310,159 @@ beforeAll(async () => {
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
createdClients.length = 0
|
||||
createdSessions.length = 0
|
||||
sessionCreateInputs.length = 0
|
||||
enabledAutoAccept.length = 0
|
||||
optimistic.length = 0
|
||||
optimisticSeeded.length = 0
|
||||
promoted.length = 0
|
||||
promotedDrafts.length = 0
|
||||
updatedDrafts.length = 0
|
||||
sentCommands.length = 0
|
||||
sentPrompts.length = 0
|
||||
promptInputs.length = 0
|
||||
sentCommands.length = 0
|
||||
switchedAgents.length = 0
|
||||
switchedModels.length = 0
|
||||
sessionRequestOrder.length = 0
|
||||
commands.length = 0
|
||||
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
syncedServers.length = 0
|
||||
optimisticServers.length = 0
|
||||
promptCaptures.length = 0
|
||||
params = {}
|
||||
search = {}
|
||||
sentShell.length = 0
|
||||
syncedDirectories.length = 0
|
||||
sentShellDirectories.length = 0
|
||||
selected = "/repo/worktree-a"
|
||||
variant = undefined
|
||||
permissionServer = "server-a"
|
||||
activeSDK = "server-a"
|
||||
activeServerSync = "server-a"
|
||||
activeDirectorySync = "server-a"
|
||||
commands = []
|
||||
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
worktreeDirectory = `/repo/new-${++worktreeID}`
|
||||
createSessionGate = undefined
|
||||
serverSessionSyncs = 0
|
||||
createWorktreeGate = undefined
|
||||
worktreeFailure = undefined
|
||||
worktreeHung = false
|
||||
worktreeCreates = 0
|
||||
for (const key of Object.keys(draftServers)) delete draftServers[key]
|
||||
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
|
||||
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
||||
})
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
const makeSubmit = (overrides: Partial<Parameters<typeof createPromptSubmit>[0]> = {}) =>
|
||||
createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe("prompt submit worktree selection", () => {
|
||||
test("reads the latest worktree accessor value per submit", async () => {
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "shell",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
test("admits only one concurrent new-workspace submission", async () => {
|
||||
selected = "create"
|
||||
let release = () => {}
|
||||
createWorktreeGate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const submit = makeSubmit()
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
const first = submit.handleSubmit(event)
|
||||
const duplicate = submit.handleSubmit(event)
|
||||
expect(worktreeCreates).toBe(1)
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
selected = "/repo/worktree-b"
|
||||
await submit.handleSubmit(event)
|
||||
release()
|
||||
await Promise.all([first, duplicate])
|
||||
expect(createdSessions).toEqual([worktreeDirectory])
|
||||
await settle()
|
||||
|
||||
expect(createdClients).toEqual([])
|
||||
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(sessionCreateInputs).toEqual([
|
||||
{
|
||||
agent: "agent",
|
||||
model: { id: "model", providerID: "provider", variant: undefined },
|
||||
location: { directory: "/repo/worktree-a" },
|
||||
},
|
||||
{
|
||||
agent: "agent",
|
||||
model: { id: "model", providerID: "provider", variant: undefined },
|
||||
location: { directory: "/repo/worktree-b" },
|
||||
},
|
||||
])
|
||||
expect(sentShell).toEqual([
|
||||
expect.objectContaining({ sessionID: "session-1", id: expect.stringMatching(/^evt_/), command: "ls" }),
|
||||
expect.objectContaining({ sessionID: "session-2", id: expect.stringMatching(/^evt_/), command: "ls" }),
|
||||
])
|
||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
||||
expect(serverSessionSyncs).toBe(0)
|
||||
expect(promoted).toEqual([
|
||||
{ directory: "/repo/worktree-a", sessionID: "session-1" },
|
||||
{ directory: "/repo/worktree-b", sessionID: "session-2" },
|
||||
])
|
||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
||||
expect(worktreeCreates).toBe(1)
|
||||
expect(createdSessions).toHaveLength(1)
|
||||
expect(sentPrompts).toEqual([worktreeDirectory])
|
||||
})
|
||||
|
||||
test("applies auto-accept to newly created sessions", async () => {
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => true,
|
||||
mode: () => "shell",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
test("aborts a hung new-workspace request and allows retry", async () => {
|
||||
selected = "create"
|
||||
worktreeHung = true
|
||||
let resets = 0
|
||||
const submit = makeSubmit({
|
||||
onNewSessionWorktreeReset: () => resets++,
|
||||
worktreeRequestTimeoutMs: 1,
|
||||
})
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
|
||||
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
|
||||
expect(worktreeCreates).toBe(1)
|
||||
expect(createdSessions).toEqual([])
|
||||
expect(selected).toBe("create")
|
||||
expect(promptValue).toEqual([{ type: "text", content: "ls", start: 0, end: 2 }])
|
||||
expect(resets).toBe(0)
|
||||
|
||||
worktreeHung = false
|
||||
await submit.handleSubmit(event)
|
||||
await settle()
|
||||
|
||||
expect(worktreeCreates).toBe(2)
|
||||
expect(createdSessions).toEqual([worktreeDirectory])
|
||||
expect(sentPrompts).toEqual([worktreeDirectory])
|
||||
expect(resets).toBe(1)
|
||||
})
|
||||
|
||||
test("keeps auto-accept bound to the submission server", async () => {
|
||||
test("keeps async submission effects bound to the initiating context", async () => {
|
||||
search = { draftId: "draft-1" }
|
||||
draftServers["draft-1"] = "project-server-a"
|
||||
draftServers["draft-2"] = "project-server-b"
|
||||
let release = () => {}
|
||||
createSessionGate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => true,
|
||||
mode: () => "shell",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
let submitted = 0
|
||||
const submit = makeSubmit({
|
||||
onSubmit: () => submitted++,
|
||||
})
|
||||
|
||||
const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
permissionServer = "server-b"
|
||||
const result = submit.handleSubmit(event)
|
||||
activeSDK = "server-b"
|
||||
activeServerSync = "server-b"
|
||||
activeDirectorySync = "server-b"
|
||||
search.draftId = "draft-2"
|
||||
release()
|
||||
await result
|
||||
await settle()
|
||||
|
||||
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
|
||||
})
|
||||
|
||||
test("promotes drafts using the selected project's server", async () => {
|
||||
search = { draftId: "draft-1" }
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
})
|
||||
|
||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
|
||||
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server", sessionId: "session-1" }])
|
||||
expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }])
|
||||
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server-a", sessionId: "session-1" }])
|
||||
expect(syncedServers.every((server) => server === "server-a")).toBe(true)
|
||||
expect(optimisticServers).toEqual(["server-a"])
|
||||
expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local })
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("complete")
|
||||
expect(WorkspaceOperation.get("server-b" as ServerScope, "session-1")).toBeUndefined()
|
||||
expect(submitted).toBe(0)
|
||||
})
|
||||
|
||||
test("switches the selected agent and model before prompting", async () => {
|
||||
params = { id: "session-1" }
|
||||
variant = "high"
|
||||
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
const submit = makeSubmit({
|
||||
info: () => ({
|
||||
id: "session-1",
|
||||
agent: "old-agent",
|
||||
model: { id: "old-model", providerID: "old-provider" },
|
||||
}),
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
})
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
await Bun.sleep(0)
|
||||
|
||||
@@ -519,24 +497,12 @@ describe("prompt submit worktree selection", () => {
|
||||
commands.push({ name: "review" })
|
||||
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
|
||||
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
const submit = makeSubmit({
|
||||
info: () => ({ id: "session-1" }),
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
})
|
||||
|
||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
await submit.handleSubmit(event)
|
||||
await settle()
|
||||
|
||||
expect(sentCommands).toEqual([
|
||||
{
|
||||
@@ -552,66 +518,20 @@ describe("prompt submit worktree selection", () => {
|
||||
expect(serverSessionSyncs).toBe(0)
|
||||
})
|
||||
|
||||
test("uses an injected model selection", async () => {
|
||||
params = { id: "session-1" }
|
||||
const model = {
|
||||
current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }),
|
||||
variant: { current: () => "draft-variant" },
|
||||
} as unknown as ModelSelection
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => ({ id: "session-1" }),
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
model,
|
||||
test("sends an initial shell after synchronous workspace creation", async () => {
|
||||
selected = "create"
|
||||
const submit = makeSubmit({
|
||||
mode: () => "shell",
|
||||
})
|
||||
|
||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
|
||||
expect(optimistic[0]).toMatchObject({
|
||||
message: {
|
||||
model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("seeds new sessions before optimistic prompts are added", async () => {
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
})
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
await settle()
|
||||
|
||||
expect(storedSessions["/repo/worktree-a"]).toHaveLength(1)
|
||||
expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" })
|
||||
expect(optimisticSeeded).toEqual([true])
|
||||
expect(sentShellDirectories).toEqual([worktreeDirectory])
|
||||
expect(sentShell[0]).toMatchObject({
|
||||
sessionID: "session-1",
|
||||
command: "ls",
|
||||
})
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("complete")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,10 +17,12 @@ import { useSync, type DirectorySync } from "@/context/sync"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||
import { getDirectory } from "@opencode-ai/core/util/path"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
import { WORKSPACE_PREPARATION_TIMEOUT_MS, workspaceRequestWithTimeout } from "@/utils/workspace-request"
|
||||
import { buildRequestParts } from "./build-request-parts"
|
||||
import { setCursorPosition } from "./editor-dom"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { ScopedKey } from "@/utils/server-scope"
|
||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||
import { createPromptSubmissionState } from "./submission-state"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { blobDataUrl } from "@/utils/draft-store"
|
||||
@@ -28,9 +30,13 @@ import { blobDataUrl } from "@/utils/draft-store"
|
||||
type PendingPrompt = {
|
||||
abort: AbortController
|
||||
cleanup: VoidFunction
|
||||
scope: ServerScope
|
||||
sessionID: string
|
||||
serverSync: ServerSync
|
||||
}
|
||||
|
||||
const pending = new Map<string, PendingPrompt>()
|
||||
const submitting = new Set<string>()
|
||||
|
||||
export type FollowupDraft = {
|
||||
sessionID: string
|
||||
@@ -44,6 +50,7 @@ export type FollowupDraft = {
|
||||
|
||||
type FollowupSendInput = {
|
||||
api: DirectorySDK["api"]["session"]
|
||||
scope: ServerScope
|
||||
serverSync: ServerSync
|
||||
sync: DirectorySync
|
||||
session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
|
||||
@@ -58,6 +65,8 @@ const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ?
|
||||
const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
|
||||
|
||||
export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
const operation = WorkspaceOperation.get(input.scope, input.draft.sessionID)
|
||||
if (operation?.status === "pending" && operation.messageID !== input.messageID) return false
|
||||
const text = draftText(input.draft.prompt)
|
||||
const images = draftImages(input.draft.prompt)
|
||||
const setBusy = () => {
|
||||
@@ -248,6 +257,7 @@ type PromptSubmitInput = {
|
||||
onAbort?: () => void
|
||||
onSubmit?: () => void
|
||||
model?: ModelSelection
|
||||
worktreeRequestTimeoutMs?: number
|
||||
}
|
||||
|
||||
export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
@@ -263,7 +273,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const params = useParams()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const tabs = useTabs()
|
||||
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
|
||||
const pendingKey = (scope: ServerScope, sessionID: string) => ScopedKey.from(scope, sessionID)
|
||||
let pendingSubmission: { key: string; scope: ServerScope; sessionID: string } | undefined
|
||||
|
||||
const errorMessage = (err: unknown) => {
|
||||
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
|
||||
@@ -276,18 +287,23 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
|
||||
const abort = async () => {
|
||||
const sessionID = params.id
|
||||
const routeSessionID = params.id
|
||||
const owned =
|
||||
pendingSubmission && (!routeSessionID || routeSessionID === pendingSubmission.sessionID)
|
||||
? pending.get(pendingSubmission.key)
|
||||
: undefined
|
||||
const sessionID = routeSessionID ?? owned?.sessionID
|
||||
if (!sessionID) return Promise.resolve()
|
||||
|
||||
serverSync().session.set("todo", sessionID, [])
|
||||
;(owned?.serverSync ?? serverSync()).session.set("todo", sessionID, [])
|
||||
|
||||
input.onAbort?.()
|
||||
|
||||
const key = pendingKey(sessionID)
|
||||
const queued = pending.get(key)
|
||||
const key = owned ? pendingSubmission!.key : pendingKey(sdk().scope, sessionID)
|
||||
const queued = owned ?? pending.get(key)
|
||||
if (queued) {
|
||||
queued.abort.abort()
|
||||
queued.cleanup()
|
||||
WorkspaceOperation.fail(queued.scope, queued.sessionID)
|
||||
pending.delete(key)
|
||||
return Promise.resolve()
|
||||
}
|
||||
@@ -319,9 +335,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
}
|
||||
|
||||
const seed = (dir: string, info: SessionInfo) => {
|
||||
serverSync().session.remember(info)
|
||||
const [, setStore] = serverSync().child(dir)
|
||||
const seed = (target: ServerSync, dir: string, info: SessionInfo) => {
|
||||
target.session.remember(info)
|
||||
const [, setStore] = target.child(dir)
|
||||
setStore("session", (list: SessionInfo[]) => {
|
||||
const result = Binary.search(list, info.id, (item) => item.id)
|
||||
const next = [...list]
|
||||
@@ -353,6 +369,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
if (input.working()) void abort()
|
||||
return
|
||||
}
|
||||
if (params.id && WorkspaceOperation.get(sdk().scope, params.id)?.status === "pending") return
|
||||
|
||||
const modelSelection = input.model ?? local.model
|
||||
const currentModel = modelSelection.current()
|
||||
@@ -366,284 +383,369 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
return
|
||||
}
|
||||
|
||||
input.addToHistory(currentPrompt, mode)
|
||||
input.resetHistoryNavigation()
|
||||
|
||||
const projectDirectory = sdk().directory
|
||||
const submissionSDK = sdk()
|
||||
const submissionSync = sync()
|
||||
const submissionServerSync = serverSync()
|
||||
const submissionScope = submissionSDK.scope
|
||||
const projectDirectory = submissionSDK.directory
|
||||
const projectRoot = submissionSync.project?.worktree ?? projectDirectory
|
||||
const sessionID = params.id
|
||||
const isNewSession = !sessionID
|
||||
const currentSession = input.info()
|
||||
const draftID = search.draftId
|
||||
const draftServer = draftID ? tabs.draft(draftID).server : undefined
|
||||
const capturePrompt = prompt.capture
|
||||
const localSession = local.session
|
||||
const handoff = layout.handoff
|
||||
const resetWorktree = input.onNewSessionWorktreeReset
|
||||
const onSubmit = input.onSubmit
|
||||
const permissionState = permission.currentServerState()
|
||||
const isNewSession = !params.id
|
||||
const shouldAutoAccept = isNewSession && input.autoAccept()
|
||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||
const submissionKey = ScopedKey.from(
|
||||
submissionScope,
|
||||
draftID ? `draft:${draftID}` : sessionID ? `session:${sessionID}` : `directory:${projectDirectory}`,
|
||||
)
|
||||
if (submitting.has(submissionKey)) return
|
||||
submitting.add(submissionKey)
|
||||
|
||||
let sessionDirectory = projectDirectory
|
||||
if (isNewSession) {
|
||||
if (worktreeSelection === "create") {
|
||||
const createdWorktree = await sdk()
|
||||
.api.projectCopy.create({
|
||||
projectID: sync().data.project,
|
||||
strategy: "git_worktree",
|
||||
directory: getDirectory(projectDirectory),
|
||||
location: { directory: projectDirectory },
|
||||
try {
|
||||
input.addToHistory(currentPrompt, mode)
|
||||
input.resetHistoryNavigation()
|
||||
|
||||
let sessionDirectory = projectDirectory
|
||||
if (isNewSession) {
|
||||
if (worktreeSelection === "create") {
|
||||
const createdWorktree = await workspaceRequestWithTimeout(
|
||||
(signal) =>
|
||||
submissionSDK.api.projectCopy.create(
|
||||
{
|
||||
projectID: submissionSync.data.project,
|
||||
strategy: "git_worktree",
|
||||
directory: getDirectory(projectDirectory),
|
||||
location: { directory: projectDirectory },
|
||||
},
|
||||
{ signal },
|
||||
),
|
||||
language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
input.worktreeRequestTimeoutMs ?? WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||
)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!createdWorktree) return
|
||||
WorktreeState.ready(submissionScope, createdWorktree.directory)
|
||||
sessionDirectory = createdWorktree.directory
|
||||
}
|
||||
|
||||
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
|
||||
sessionDirectory = worktreeSelection
|
||||
}
|
||||
|
||||
if (sessionDirectory !== projectDirectory) {
|
||||
submissionServerSync.child(sessionDirectory)
|
||||
}
|
||||
}
|
||||
|
||||
let session = currentSession
|
||||
if (!session && isNewSession) {
|
||||
const created = await submissionSDK.api.session
|
||||
.create({
|
||||
agent: currentAgent.name,
|
||||
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
||||
location: { directory: sessionDirectory },
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
if (!createdWorktree) return
|
||||
WorktreeState.pending(sdk().scope, createdWorktree.directory)
|
||||
sessionDirectory = createdWorktree.directory
|
||||
}
|
||||
|
||||
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
|
||||
sessionDirectory = worktreeSelection
|
||||
}
|
||||
|
||||
if (sessionDirectory !== projectDirectory) {
|
||||
serverSync().child(sessionDirectory)
|
||||
}
|
||||
|
||||
input.onNewSessionWorktreeReset?.()
|
||||
}
|
||||
|
||||
let session = input.info()
|
||||
if (!session && isNewSession) {
|
||||
const created = await sdk()
|
||||
.api.session.create({
|
||||
agent: currentAgent.name,
|
||||
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
||||
location: { directory: sessionDirectory },
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||
description: errorMessage(err),
|
||||
if (created) {
|
||||
seed(submissionServerSync, sessionDirectory, created)
|
||||
session = created
|
||||
await startTransition(() => {
|
||||
if (!session) return
|
||||
if (draftID) tabs.updateDraft(draftID, { worktree: undefined })
|
||||
if (!draftID) resetWorktree?.()
|
||||
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
|
||||
localSession.promote(sessionDirectory, session.id, {
|
||||
agent: currentAgent.name,
|
||||
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
||||
variant: variant ?? null,
|
||||
})
|
||||
handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||
if (draftID && draftServer) tabs.promoteDraft(draftID, { server: draftServer, sessionId: session.id })
|
||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
submission.retarget(
|
||||
capturePrompt(
|
||||
{ dir: base64Encode(sessionDirectory), id: session.id },
|
||||
{ server: draftServer, scope: submissionScope },
|
||||
),
|
||||
)
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
if (!session) {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: language.t("prompt.toast.promptSendFailed.description"),
|
||||
})
|
||||
if (created) {
|
||||
seed(sessionDirectory, created)
|
||||
session = created
|
||||
await startTransition(() => {
|
||||
if (!session) return
|
||||
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
|
||||
local.session.promote(sessionDirectory, session.id, {
|
||||
agent: currentAgent.name,
|
||||
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
||||
variant: variant ?? null,
|
||||
})
|
||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||
const draftID = search.draftId
|
||||
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
|
||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!session) {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: language.t("prompt.toast.promptSendFailed.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const model = {
|
||||
modelID: currentModel.id,
|
||||
providerID: currentModel.provider.id,
|
||||
}
|
||||
const agent = currentAgent.name
|
||||
const draft: FollowupDraft = {
|
||||
sessionID: session.id,
|
||||
sessionDirectory,
|
||||
prompt: currentPrompt,
|
||||
context,
|
||||
agent,
|
||||
model,
|
||||
variant,
|
||||
}
|
||||
const model = {
|
||||
modelID: currentModel.id,
|
||||
providerID: currentModel.provider.id,
|
||||
}
|
||||
const agent = currentAgent.name
|
||||
const draft: FollowupDraft = {
|
||||
sessionID: session.id,
|
||||
sessionDirectory,
|
||||
prompt: currentPrompt,
|
||||
context,
|
||||
agent,
|
||||
model,
|
||||
variant,
|
||||
}
|
||||
|
||||
const clearInput = () => {
|
||||
submission.clear()
|
||||
input.setMode("normal")
|
||||
input.setPopover(null)
|
||||
}
|
||||
const clearInput = () => {
|
||||
submission.clear()
|
||||
input.setMode("normal")
|
||||
input.setPopover(null)
|
||||
}
|
||||
|
||||
const restoreInput = () => {
|
||||
const restored = submission.restore()
|
||||
if (!restored) return false
|
||||
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
|
||||
if (!submission.current(prompt.capture())) return true
|
||||
input.setMode(mode)
|
||||
input.setPopover(null)
|
||||
requestAnimationFrame(() => {
|
||||
const editor = input.editor()
|
||||
if (!editor) return
|
||||
editor.focus()
|
||||
setCursorPosition(editor, input.promptLength(currentPrompt))
|
||||
input.queueScroll()
|
||||
})
|
||||
return true
|
||||
}
|
||||
const restoreInput = () => {
|
||||
const restored = submission.restore()
|
||||
if (!restored) return false
|
||||
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
|
||||
if (!submission.current(prompt.capture())) return true
|
||||
input.setMode(mode)
|
||||
input.setPopover(null)
|
||||
requestAnimationFrame(() => {
|
||||
const editor = input.editor()
|
||||
if (!editor) return
|
||||
editor.focus()
|
||||
setCursorPosition(editor, input.promptLength(currentPrompt))
|
||||
input.queueScroll()
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
|
||||
input.onQueue?.(draft)
|
||||
clearContext(submission.target())
|
||||
clearInput()
|
||||
return
|
||||
}
|
||||
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
|
||||
input.onQueue?.(draft)
|
||||
clearContext(submission.target())
|
||||
clearInput()
|
||||
return
|
||||
}
|
||||
|
||||
input.onSubmit?.()
|
||||
const startWorkspaceOperation = (messageID: string) => {
|
||||
if (!isNewSession) return
|
||||
if (worktreeSelection !== "main" && worktreeSelection !== "create" && sessionDirectory !== projectRoot) {
|
||||
WorkspaceOperation.start(submissionScope, session.id, "move", sessionDirectory, messageID)
|
||||
WorkspaceOperation.complete(submissionScope, session.id)
|
||||
}
|
||||
if (worktreeSelection !== "create") return
|
||||
const worktree = WorktreeState.get(submissionScope, sessionDirectory)
|
||||
WorkspaceOperation.start(submissionScope, session.id, "create", sessionDirectory, messageID)
|
||||
if (worktree?.status === "ready") WorkspaceOperation.complete(submissionScope, session.id)
|
||||
if (worktree?.status === "failed") WorkspaceOperation.fail(submissionScope, session.id)
|
||||
}
|
||||
|
||||
if (mode === "shell") {
|
||||
clearInput()
|
||||
const eventID = Event.ID.create()
|
||||
sdk()
|
||||
.api.session.shell({
|
||||
const waitForWorktree = async (cleanup: VoidFunction) => {
|
||||
const worktree = WorktreeState.get(submissionScope, sessionDirectory)
|
||||
if (!worktree) return true
|
||||
if (worktree.status === "ready") {
|
||||
WorkspaceOperation.complete(submissionScope, session.id)
|
||||
return true
|
||||
}
|
||||
if (worktree.status === "failed") {
|
||||
WorkspaceOperation.fail(submissionScope, session.id)
|
||||
throw new Error(worktree.message)
|
||||
}
|
||||
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
submissionSync.set("session_status", session.id, { type: "busy" })
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const key = pendingKey(submissionScope, session.id)
|
||||
pendingSubmission = { key, scope: submissionScope, sessionID: session.id }
|
||||
pending.set(key, {
|
||||
abort: controller,
|
||||
cleanup,
|
||||
scope: submissionScope,
|
||||
sessionID: session.id,
|
||||
id: eventID,
|
||||
command: text,
|
||||
serverSync: submissionServerSync,
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.shellSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
|
||||
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||
if (controller.signal.aborted) {
|
||||
resolve({ status: "failed", message: "aborted" })
|
||||
return
|
||||
}
|
||||
controller.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
resolve({ status: "failed", message: "aborted" })
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
|
||||
const timeoutMs = 5 * 60 * 1000
|
||||
const timer = { id: undefined as number | undefined }
|
||||
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||
timer.id = window.setTimeout(() => {
|
||||
resolve({
|
||||
status: "failed",
|
||||
message: language.t("workspace.error.stillPreparing"),
|
||||
})
|
||||
}, timeoutMs)
|
||||
})
|
||||
|
||||
const result = await Promise.race([
|
||||
WorktreeState.wait(submissionScope, sessionDirectory),
|
||||
abortWait,
|
||||
timeout,
|
||||
]).finally(() => {
|
||||
pending.delete(key)
|
||||
if (pendingSubmission?.key === key) pendingSubmission = undefined
|
||||
if (timer.id === undefined) return
|
||||
clearTimeout(timer.id)
|
||||
})
|
||||
if (controller.signal.aborted) return false
|
||||
if (result.status === "failed") {
|
||||
WorkspaceOperation.fail(submissionScope, session.id)
|
||||
throw new Error(result.message)
|
||||
}
|
||||
WorkspaceOperation.complete(submissionScope, session.id)
|
||||
return true
|
||||
}
|
||||
|
||||
if (!draftID || search.draftId === draftID) onSubmit?.()
|
||||
|
||||
if (mode === "shell") {
|
||||
clearInput()
|
||||
const eventID = Event.ID.create()
|
||||
startWorkspaceOperation(eventID)
|
||||
void waitForWorktree(() => {
|
||||
restoreInput()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (text.startsWith("/")) {
|
||||
const [cmdName, ...args] = text.split(" ")
|
||||
const commandName = cmdName.slice(1)
|
||||
const customCommand = sync().data.command.find((c) => c.name === commandName)
|
||||
if (customCommand) {
|
||||
clearInput()
|
||||
const messageID = Identifier.ascending("message")
|
||||
serverSync().session.set("session_status", session.id, { type: "busy" })
|
||||
sdk()
|
||||
.api.session.command({
|
||||
sessionID: session.id,
|
||||
id: messageID,
|
||||
command: commandName,
|
||||
arguments: args.join(" "),
|
||||
agent,
|
||||
model: { id: model.modelID, providerID: model.providerID, variant },
|
||||
files: await Promise.all(
|
||||
images.map(async (attachment) => ({
|
||||
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
||||
name: attachment.filename,
|
||||
})),
|
||||
),
|
||||
.then((ready) => {
|
||||
if (!ready) return
|
||||
return submissionSDK.api.session.shell({
|
||||
sessionID: session.id,
|
||||
id: eventID,
|
||||
command: text,
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
serverSync().session.set("session_status", session.id, { type: "idle" })
|
||||
showToast({
|
||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||
title: language.t("prompt.toast.shellSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
restoreInput()
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
||||
const messageID = Identifier.ascending("message")
|
||||
|
||||
const removeOptimisticMessage = () => {
|
||||
sync().session.optimistic.remove({
|
||||
directory: sessionDirectory,
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
})
|
||||
}
|
||||
|
||||
for (const item of commentItems) submission.target().context.remove(item.key)
|
||||
clearInput()
|
||||
|
||||
const waitForWorktree = async () => {
|
||||
const worktree = WorktreeState.get(sdk().scope, sessionDirectory)
|
||||
if (!worktree || worktree.status !== "pending") return true
|
||||
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync().set("session_status", session.id, { type: "busy" })
|
||||
if (text.startsWith("/")) {
|
||||
const [cmdName, ...args] = text.split(" ")
|
||||
const commandName = cmdName.slice(1)
|
||||
const customCommand = submissionSync.data.command.find((c) => c.name === commandName)
|
||||
if (customCommand) {
|
||||
clearInput()
|
||||
const messageID = Identifier.ascending("message")
|
||||
startWorkspaceOperation(messageID)
|
||||
submissionServerSync.session.set("session_status", session.id, { type: "busy" })
|
||||
void waitForWorktree(() => {
|
||||
submissionServerSync.session.set("session_status", session.id, { type: "idle" })
|
||||
restoreInput()
|
||||
})
|
||||
.then(async (ready) => {
|
||||
if (!ready) return
|
||||
return submissionSDK.api.session.command({
|
||||
sessionID: session.id,
|
||||
id: messageID,
|
||||
command: commandName,
|
||||
arguments: args.join(" "),
|
||||
agent,
|
||||
model: { id: model.modelID, providerID: model.providerID, variant },
|
||||
files: await Promise.all(
|
||||
images.map(async (attachment) => ({
|
||||
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
||||
name: attachment.filename,
|
||||
})),
|
||||
),
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
submissionServerSync.session.set("session_status", session.id, { type: "idle" })
|
||||
showToast({
|
||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||
})
|
||||
restoreInput()
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
||||
const messageID = Identifier.ascending("message")
|
||||
startWorkspaceOperation(messageID)
|
||||
|
||||
const removeOptimisticMessage = () => {
|
||||
submissionSync.session.optimistic.remove({
|
||||
directory: sessionDirectory,
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
})
|
||||
}
|
||||
|
||||
for (const item of commentItems) submission.target().context.remove(item.key)
|
||||
clearInput()
|
||||
|
||||
const cleanup = () => {
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync().set("session_status", session.id, { type: "idle" })
|
||||
submissionSync.set("session_status", session.id, { type: "idle" })
|
||||
}
|
||||
removeOptimisticMessage()
|
||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||
}
|
||||
|
||||
pending.set(pendingKey(session.id), { abort: controller, cleanup })
|
||||
|
||||
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||
if (controller.signal.aborted) {
|
||||
resolve({ status: "failed", message: "aborted" })
|
||||
return
|
||||
void sendFollowupDraft({
|
||||
api: submissionSDK.api.session,
|
||||
scope: submissionScope,
|
||||
sync: submissionSync,
|
||||
serverSync: submissionServerSync,
|
||||
session: () => session,
|
||||
draft,
|
||||
messageID,
|
||||
optimisticBusy: sessionDirectory === projectDirectory,
|
||||
before: () => waitForWorktree(cleanup),
|
||||
}).catch((err) => {
|
||||
pending.delete(pendingKey(submissionScope, session.id))
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
submissionSync.set("session_status", session.id, { type: "idle" })
|
||||
}
|
||||
controller.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
resolve({ status: "failed", message: "aborted" })
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
removeOptimisticMessage()
|
||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||
})
|
||||
|
||||
const timeoutMs = 5 * 60 * 1000
|
||||
const timer = { id: undefined as number | undefined }
|
||||
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||
timer.id = window.setTimeout(() => {
|
||||
resolve({
|
||||
status: "failed",
|
||||
message: language.t("workspace.error.stillPreparing"),
|
||||
})
|
||||
}, timeoutMs)
|
||||
})
|
||||
|
||||
const result = await Promise.race([
|
||||
WorktreeState.wait(sdk().scope, sessionDirectory),
|
||||
abortWait,
|
||||
timeout,
|
||||
]).finally(() => {
|
||||
if (timer.id === undefined) return
|
||||
clearTimeout(timer.id)
|
||||
})
|
||||
pending.delete(pendingKey(session.id))
|
||||
if (controller.signal.aborted) return false
|
||||
if (result.status === "failed") throw new Error(result.message)
|
||||
return true
|
||||
} finally {
|
||||
submitting.delete(submissionKey)
|
||||
}
|
||||
|
||||
void sendFollowupDraft({
|
||||
api: sdk().api.session,
|
||||
sync: sync(),
|
||||
serverSync: serverSync(),
|
||||
session: () => input.info() ?? session,
|
||||
draft,
|
||||
messageID,
|
||||
optimisticBusy: sessionDirectory === projectDirectory,
|
||||
before: waitForWorktree,
|
||||
}).catch((err) => {
|
||||
pending.delete(pendingKey(session.id))
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync().set("session_status", session.id, { type: "idle" })
|
||||
}
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
removeOptimisticMessage()
|
||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { For, Show } from "solid-js"
|
||||
import { createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
@@ -11,25 +10,42 @@ export function PromptWorkspaceSelector(props: {
|
||||
projectRoot: string
|
||||
workspaces: string[]
|
||||
branch?: string
|
||||
onboarding?: boolean
|
||||
onChange: (value: string) => void
|
||||
onDone: () => void
|
||||
onViewAll: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
let pending: string | undefined
|
||||
const [search, setSearch] = createSignal("")
|
||||
let searchInput: HTMLInputElement | undefined
|
||||
let focusSearch = false
|
||||
let pending: { type: "select"; value: string } | { type: "viewAll" } | undefined
|
||||
const selected = () => (props.value === props.projectRoot ? "main" : props.value)
|
||||
const workspaces = createMemo(() => {
|
||||
const query = search().trim().toLowerCase()
|
||||
if (!query) return props.workspaces
|
||||
return props.workspaces.filter((workspace) => getFilename(workspace).toLowerCase().includes(query))
|
||||
})
|
||||
const icon = () => {
|
||||
if (selected() === "main") return "monitor"
|
||||
if (selected() === "create") return "workspace-new"
|
||||
return "workspace"
|
||||
return "workspace-isolated"
|
||||
}
|
||||
const select = (value: string) => {
|
||||
pending = value
|
||||
pending = { type: "select", value }
|
||||
}
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (open) return
|
||||
const value = pending
|
||||
if (open) {
|
||||
setSearch("")
|
||||
return
|
||||
}
|
||||
const action = pending
|
||||
pending = undefined
|
||||
if (value) props.onChange(value)
|
||||
if (action?.type === "select") props.onChange(action.value)
|
||||
if (action?.type === "viewAll") {
|
||||
props.onViewAll()
|
||||
return
|
||||
}
|
||||
props.onDone()
|
||||
}
|
||||
const label = () => {
|
||||
@@ -41,87 +57,220 @@ export function PromptWorkspaceSelector(props: {
|
||||
return (
|
||||
<>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
|
||||
<MenuV2.Trigger class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted">
|
||||
<IconV2 name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{label()}</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</MenuV2.Trigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content class="w-[180px]">
|
||||
<MenuV2.Group>
|
||||
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
|
||||
<MenuV2.Item onSelect={() => select("main")}>
|
||||
<IconV2 name="monitor" />
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("session.new.workspace.local")}</span>
|
||||
<Show when={selected() === "main"}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={() => select("create")}>
|
||||
<IconV2 name="workspace-new" />
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
|
||||
<Show when={selected() === "create"}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Group>
|
||||
<Show when={props.workspaces.length > 0}>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
|
||||
<MenuV2.SubTrigger>
|
||||
<IconV2 name="workspace" />
|
||||
{language.t("session.new.workspace.existing")}
|
||||
</MenuV2.SubTrigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.SubContent class="max-w-[200px]">
|
||||
<For each={props.workspaces}>
|
||||
{(workspace) => (
|
||||
<MenuV2.Item onSelect={() => select(workspace)}>
|
||||
<IconV2 name="workspace-isolated" />
|
||||
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||
<Show when={selected() === workspace}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
)}
|
||||
</For>
|
||||
</MenuV2.SubContent>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2.Sub>
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
openDelay={800}
|
||||
value={
|
||||
props.onboarding ? (
|
||||
<div class="flex flex-col gap-1 text-left">
|
||||
<div class="flex items-center gap-1.5 font-[530] text-v2-text-text-base">
|
||||
<Icon name="workspace-isolated" size="small" class="shrink-0 text-v2-text-text-accent" />
|
||||
<span>{language.t("workspace.onboarding.title")}</span>
|
||||
</div>
|
||||
<span class="font-[440] text-v2-text-text-muted">
|
||||
{language.t("workspace.onboarding.description")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
language.t("session.new.workspace.trigger.tooltip")
|
||||
)
|
||||
}
|
||||
contentClass={props.onboarding ? "max-w-[280px]" : undefined}
|
||||
class="min-w-0"
|
||||
>
|
||||
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
|
||||
<MenuV2.Trigger
|
||||
aria-description={language.t("session.new.workspace.trigger.tooltip")}
|
||||
class="flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
|
||||
>
|
||||
<Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{label()}</span>
|
||||
<Show when={props.onboarding}>
|
||||
<span
|
||||
data-slot="workspace-onboarding-dot"
|
||||
aria-hidden="true"
|
||||
class="size-1.5 shrink-0 rounded-full bg-v2-text-text-accent"
|
||||
/>
|
||||
</Show>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
<PromptGitStatus branch={props.branch} />
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</MenuV2.Trigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content class="w-[200px]">
|
||||
<MenuV2.Group>
|
||||
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
|
||||
<MenuV2.Item onSelect={() => select("main")}>
|
||||
<Icon name="monitor" />
|
||||
<TooltipV2
|
||||
placement="right"
|
||||
openDelay={800}
|
||||
value={
|
||||
<span class="flex flex-col gap-0.5">
|
||||
<span>{language.t("session.new.workspace.local")}</span>
|
||||
<span class="font-[440] text-v2-text-text-muted">
|
||||
{language.t("session.new.workspace.local.tooltip")}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
class="min-w-0 flex-1"
|
||||
>
|
||||
<span class="min-w-0 truncate">{language.t("session.new.workspace.local")}</span>
|
||||
</TooltipV2>
|
||||
<Show when={selected() === "main"}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={() => select("create")}>
|
||||
<Icon name="workspace-new" />
|
||||
<TooltipV2
|
||||
placement="right"
|
||||
openDelay={800}
|
||||
value={
|
||||
<span class="flex flex-col gap-0.5">
|
||||
<span>{language.t("workspace.new")}</span>
|
||||
<span class="font-[440] text-v2-text-text-muted">
|
||||
{language.t("session.new.workspace.new.tooltip")}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
class="min-w-0 flex-1"
|
||||
>
|
||||
<span class="min-w-0 truncate">{language.t("workspace.new")}</span>
|
||||
</TooltipV2>
|
||||
<Show when={selected() === "create"}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Group>
|
||||
<Show
|
||||
when={props.workspaces.length > 0}
|
||||
fallback={
|
||||
<>
|
||||
<MenuV2.Separator class="h-[0.5px]" />
|
||||
<MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
||||
</MenuV2.Item>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<MenuV2.Separator class="h-[0.5px]" />
|
||||
<MenuV2.Sub
|
||||
gutter={0}
|
||||
overlap
|
||||
overflowPadding={8}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
focusSearch = false
|
||||
return
|
||||
}
|
||||
if (!focusSearch || props.workspaces.length < 10) return
|
||||
focusSearch = false
|
||||
requestAnimationFrame(() => searchInput?.focus())
|
||||
}}
|
||||
>
|
||||
<MenuV2.SubTrigger
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "ArrowRight" ||
|
||||
event.key === "ArrowLeft" ||
|
||||
event.key === "Enter" ||
|
||||
event.key === " "
|
||||
)
|
||||
focusSearch = true
|
||||
}}
|
||||
>
|
||||
<Icon name="workspace-isolated" />
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
|
||||
</span>
|
||||
</MenuV2.SubTrigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
|
||||
<Show when={props.workspaces.length >= 10}>
|
||||
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2 text-v2-icon-icon-muted">
|
||||
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||
<input
|
||||
ref={(element) => {
|
||||
searchInput = element
|
||||
}}
|
||||
value={search()}
|
||||
placeholder={language.t("session.new.workspace.search.placeholder")}
|
||||
aria-label={language.t("session.new.workspace.search.placeholder")}
|
||||
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
|
||||
onInput={(event) => setSearch(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "Escape" ||
|
||||
event.key === "ArrowDown" ||
|
||||
event.key === "ArrowUp" ||
|
||||
event.key === "Enter"
|
||||
)
|
||||
return
|
||||
event.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<For each={workspaces()}>
|
||||
{(workspace) => (
|
||||
<MenuV2.Item onSelect={() => select(workspace)}>
|
||||
<Icon name="workspace-isolated" />
|
||||
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||
<Show when={selected() === workspace}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
)}
|
||||
</For>
|
||||
<MenuV2.Separator class="h-[0.5px]" />
|
||||
<MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
||||
</MenuV2.Item>
|
||||
</MenuV2.SubContent>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2.Sub>
|
||||
</Show>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
</TooltipV2>
|
||||
<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ml-1" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) {
|
||||
export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean; class?: string }) {
|
||||
const language = useLanguage()
|
||||
const label = () => {
|
||||
if (props.noGit) return language.t("session.new.git.none")
|
||||
if (!props.branch) return undefined
|
||||
if (props.from) return language.t("session.new.workspace.fromBranch", { branch: props.branch })
|
||||
return props.branch
|
||||
}
|
||||
|
||||
const icon = () => {
|
||||
if (props.noGit) return "monitor"
|
||||
if (props.from) return "branch-out"
|
||||
return "branch"
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={label()}>
|
||||
{(value) => (
|
||||
<>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
value={value()}
|
||||
class="min-w-0 max-w-[220px]"
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
|
||||
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{value()}</span>
|
||||
</div>
|
||||
</TooltipV2>
|
||||
</>
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
value={value()}
|
||||
class={`min-w-0 max-w-[220px] ${props.class ?? ""}`}
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<div class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint">
|
||||
<Icon
|
||||
name={icon()}
|
||||
size="small"
|
||||
class="shrink-0 text-v2-icon-icon-muted"
|
||||
/>
|
||||
<span class="min-w-0 truncate">{value()}</span>
|
||||
</div>
|
||||
</TooltipV2>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { For, Show, type ComponentProps, type JSX } from "solid-js"
|
||||
import type { Project } from "@/types"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useSettingsDialog } from "@/components/settings-dialog"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { Worktree } from "@/utils/worktree"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { workspaceDirectories } from "@/utils/workspace"
|
||||
import {
|
||||
WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS,
|
||||
WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||
workspaceRequestWithTimeout,
|
||||
} from "@/utils/workspace-request"
|
||||
|
||||
export function SessionWorkspaceMenu(props: {
|
||||
eligible?: boolean
|
||||
sessionID: string
|
||||
project: Project
|
||||
directory: string
|
||||
messageID?: string
|
||||
placement?: ComponentProps<typeof MenuV2>["placement"]
|
||||
gutter?: number
|
||||
class?: string
|
||||
contentClass?: string
|
||||
children: JSX.Element
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const openWorkspaces = useSettingsDialog("workspaces")
|
||||
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
||||
const operationPending = () => WorkspaceOperation.get(serverSDK().scope, props.sessionID)?.status === "pending"
|
||||
const blocked = () =>
|
||||
props.eligible === false || operationPending() || serverSync().session.data.session_working(props.sessionID)
|
||||
const workspaces = () =>
|
||||
workspaceDirectories(props.project).filter((workspace) => pathKey(workspace) !== pathKey(props.directory))
|
||||
|
||||
const fail = (scope: ServerScope, sessionID: string, message: string) => {
|
||||
if (WorkspaceOperation.get(scope, sessionID)?.status === "complete") return
|
||||
WorkspaceOperation.fail(scope, sessionID)
|
||||
showToast({ variant: "error", title: language.t("workspace.move.failed"), description: message })
|
||||
}
|
||||
const move = async (selection: "create" | string) => {
|
||||
if (store.selected || blocked()) return
|
||||
const sdk = serverSDK()
|
||||
const sync = serverSync()
|
||||
const scope = sdk.scope
|
||||
const sessionID = props.sessionID
|
||||
const messageID = props.messageID
|
||||
const source = props.directory
|
||||
setStore("selected", selection)
|
||||
|
||||
try {
|
||||
const destination =
|
||||
selection === "create"
|
||||
? await createWorkspace(
|
||||
props.project,
|
||||
source,
|
||||
sessionID,
|
||||
messageID,
|
||||
sdk,
|
||||
(message) => fail(scope, sessionID, message),
|
||||
{
|
||||
createFailed: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
},
|
||||
)
|
||||
: selection
|
||||
if (!destination) return
|
||||
|
||||
WorkspaceOperation.start(scope, sessionID, selection === "create" ? "create" : "move", destination, messageID)
|
||||
if (sync.session.data.session_working(sessionID)) throw new Error(language.t("workspace.move.failed"))
|
||||
await workspaceRequestWithTimeout(
|
||||
(signal) => sdk.api.session.move({ sessionID, directory: destination }, { signal }),
|
||||
language.t("workspace.move.failed"),
|
||||
WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||
)
|
||||
const session = await workspaceRequestWithTimeout(
|
||||
(signal) => sync.session.resolve(sessionID, { force: true, signal }),
|
||||
language.t("workspace.move.failed"),
|
||||
WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS,
|
||||
)
|
||||
if (!session || pathKey(session.location.directory) !== pathKey(destination))
|
||||
throw new Error(language.t("workspace.move.failed"))
|
||||
WorkspaceOperation.complete(scope, sessionID, destination)
|
||||
sync.reindexSession(sessionID, source)
|
||||
} catch (error) {
|
||||
fail(scope, sessionID, error instanceof Error ? error.message : language.t("common.requestFailed"))
|
||||
} finally {
|
||||
setStore("selected", undefined)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuV2
|
||||
placement={props.placement ?? "bottom-end"}
|
||||
gutter={props.gutter ?? 4}
|
||||
modal={false}
|
||||
onOpenChange={props.onOpenChange}
|
||||
>
|
||||
<MenuV2.Trigger class={props.class} disabled={blocked()}>
|
||||
{props.children}
|
||||
</MenuV2.Trigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content class={`w-[200px] ${props.contentClass ?? ""}`}>
|
||||
<MenuV2.Group>
|
||||
<MenuV2.GroupLabel>{language.t("workspace.move.menu.title")}</MenuV2.GroupLabel>
|
||||
<Show when={pathKey(props.directory) !== pathKey(props.project.worktree)}>
|
||||
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(props.project.worktree)}>
|
||||
<Icon name="monitor" />
|
||||
{language.t("session.new.workspace.local")}
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move("create")}>
|
||||
<Icon name="workspace-new" />
|
||||
{language.t("workspace.new")}
|
||||
</MenuV2.Item>
|
||||
<Show when={workspaces().length > 0}>
|
||||
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
|
||||
<MenuV2.SubTrigger>
|
||||
<Icon name="workspace-isolated" />
|
||||
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
|
||||
</MenuV2.SubTrigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
|
||||
<For each={workspaces()}>
|
||||
{(workspace) => (
|
||||
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(workspace)}>
|
||||
<Icon name="workspace-isolated" />
|
||||
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||
</MenuV2.Item>
|
||||
)}
|
||||
</For>
|
||||
</MenuV2.SubContent>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2.Sub>
|
||||
</Show>
|
||||
</MenuV2.Group>
|
||||
<MenuV2.Separator class="h-[0.5px] bg-v2-border-border-base" />
|
||||
<MenuV2.Item onSelect={() => openWorkspaces()}>
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
)
|
||||
}
|
||||
|
||||
async function createWorkspace(
|
||||
project: Project,
|
||||
source: string,
|
||||
sessionID: string,
|
||||
messageID: string | undefined,
|
||||
serverSDK: ReturnType<ReturnType<typeof useServerSDK>>,
|
||||
fail: (message: string) => void,
|
||||
messages: { createFailed: string },
|
||||
) {
|
||||
WorkspaceOperation.start(serverSDK.scope, sessionID, "create", project.worktree, messageID)
|
||||
const created = await workspaceRequestWithTimeout(
|
||||
(signal) =>
|
||||
serverSDK.api.projectCopy.create(
|
||||
{
|
||||
projectID: project.id,
|
||||
strategy: "git_worktree",
|
||||
directory: getDirectory(source),
|
||||
location: { directory: source },
|
||||
},
|
||||
{ signal },
|
||||
),
|
||||
messages.createFailed,
|
||||
WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||
)
|
||||
.catch((error) => {
|
||||
fail(error instanceof Error ? error.message : messages.createFailed)
|
||||
return undefined
|
||||
})
|
||||
if (!created?.directory) return
|
||||
WorkspaceOperation.start(serverSDK.scope, sessionID, "create", created.directory, messageID)
|
||||
Worktree.ready(serverSDK.scope, created.directory)
|
||||
return created.directory
|
||||
}
|
||||
@@ -451,7 +451,10 @@ function SettingsKeybindsV2View(props: {
|
||||
<>
|
||||
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
||||
<div class="settings-v2-tab-header-row">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.shortcuts.description")}</span>
|
||||
</div>
|
||||
<ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides()}>
|
||||
{language.t("settings.shortcuts.reset.button")}
|
||||
</ButtonV2>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { type ParentProps, Show } from "solid-js"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { ModelsProvider } from "@/context/models"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { ServerSDKProvider } from "@/context/server-sdk"
|
||||
import { ServerSyncProvider } from "@/context/server-sync"
|
||||
|
||||
export function SettingsServerScope(props: ParentProps) {
|
||||
const global = useGlobal()
|
||||
return (
|
||||
<Show when={global.settings.server.selected()} keyed fallback={props.children}>
|
||||
{(server) => <SettingsServerDataScope server={server}>{props.children}</SettingsServerDataScope>}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsServerDataScope(props: ParentProps<{ server: ServerConnection.Any }>) {
|
||||
const global = useGlobal()
|
||||
const serverCtx = () => global.ensureServerCtx(props.server)
|
||||
return (
|
||||
<QueryClientProvider client={serverCtx().queryClient}>
|
||||
<ServerSDKProvider server={() => props.server}>
|
||||
<ServerSyncProvider server={() => props.server}>
|
||||
<ModelsProvider>{props.children}</ModelsProvider>
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Component, createMemo } from "solid-js"
|
||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ExternalLink } from "../external-link"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { SettingsRowV2 } from "./parts/row"
|
||||
import { createAppearanceSettingsController, type AppearanceSettingsController } from "./general-controllers"
|
||||
import "./settings-v2.css"
|
||||
|
||||
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
|
||||
const fontSettings = {
|
||||
ui: {
|
||||
action: "settings-ui-font",
|
||||
title: "settings.general.row.uiFont.title",
|
||||
description: "settings.general.row.uiFont.description",
|
||||
font: "ui",
|
||||
input: "setUI",
|
||||
},
|
||||
code: {
|
||||
action: "settings-code-font",
|
||||
title: "settings.general.row.font.title",
|
||||
description: "settings.general.row.font.description",
|
||||
font: "code",
|
||||
input: "setCode",
|
||||
},
|
||||
terminal: {
|
||||
action: "settings-terminal-font",
|
||||
title: "settings.general.row.terminalFont.title",
|
||||
description: "settings.general.row.terminalFont.description",
|
||||
font: "terminal",
|
||||
input: "setTerminal",
|
||||
},
|
||||
} as const
|
||||
|
||||
const FontSetting: Component<{
|
||||
kind: "ui" | "code" | "terminal"
|
||||
fonts: AppearanceSettingsController["fonts"]
|
||||
}> = (props) => {
|
||||
const language = useLanguage()
|
||||
const config = () => fontSettings[props.kind]
|
||||
return (
|
||||
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
|
||||
<div class="w-full sm:w-[220px]">
|
||||
<TextInputV2
|
||||
data-action={config().action}
|
||||
type="text"
|
||||
appearance="base"
|
||||
value={props.fonts[config().font]().value}
|
||||
onInput={(event) => props.fonts[config().input](event.currentTarget.value)}
|
||||
placeholder={props.fonts[config().font]().placeholder}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
aria-label={language.t(config().title)}
|
||||
style={{ "font-family": props.fonts[config().font]().family }}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
)
|
||||
}
|
||||
|
||||
export const SettingsAppearanceV2: Component = () => {
|
||||
const language = useLanguage()
|
||||
const appearance = createAppearanceSettingsController()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-v2-tab-header">
|
||||
<div class="settings-v2-tab-header-row">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.general.section.appearance")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.appearance.description")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-tab-body">
|
||||
<div class="settings-v2-section">
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.colorScheme.title")}
|
||||
description={language.t("settings.general.row.colorScheme.description")}
|
||||
>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
data-action="settings-color-scheme"
|
||||
options={schemeOptions}
|
||||
current={schemeOptions.find((option) => option === appearance.scheme.current())}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
label={(option) => {
|
||||
if (option === "system") return language.t("theme.scheme.system")
|
||||
if (option === "light") return language.t("theme.scheme.light")
|
||||
return language.t("theme.scheme.dark")
|
||||
}}
|
||||
onSelect={(option) => option && appearance.scheme.select(option)}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.theme.title")}
|
||||
description={
|
||||
<>
|
||||
{language.t("settings.general.row.theme.description")}{" "}
|
||||
<ExternalLink class="settings-v2-link" href="https://opencode.ai/docs/themes/">
|
||||
{language.t("common.learnMore")}
|
||||
</ExternalLink>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
data-action="settings-theme"
|
||||
options={appearance.theme.options()}
|
||||
current={appearance.theme.current()}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
value={(option) => option.id}
|
||||
label={(option) => option.name}
|
||||
onSelect={appearance.theme.select}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
|
||||
<FontSetting kind="ui" fonts={appearance.fonts} />
|
||||
<FontSetting kind="code" fonts={appearance.fonts} />
|
||||
<FontSetting kind="terminal" fonts={appearance.fonts} />
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -5,15 +5,23 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { SettingsGeneralV2 } from "./general"
|
||||
import { SettingsAppearanceV2 } from "./appearance"
|
||||
import { SettingsKeybinds } from "../settings-keybinds"
|
||||
import { SettingsNotificationsV2 } from "./notifications"
|
||||
import { SettingsProvidersV2 } from "./providers"
|
||||
import { SettingsModelsV2 } from "./models"
|
||||
import "./settings-v2.css"
|
||||
import { SettingsServersV2 } from "./servers"
|
||||
import { SettingsWorkspacesV2 } from "./workspaces"
|
||||
import { SettingsProjectsV2 } from "./projects"
|
||||
import { SettingsExtensionsV2 } from "./extensions"
|
||||
import { SettingsServerScope } from "../settings-server-picker"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import "./settings-v2.css"
|
||||
|
||||
export const DialogSettings: Component<{
|
||||
sessionID?: string
|
||||
@@ -25,8 +33,13 @@ export const DialogSettings: Component<{
|
||||
const layout = useLayout()
|
||||
const tabs = useTabs()
|
||||
const serverSync = useServerSync()
|
||||
const global = useGlobal()
|
||||
const currentServer = global.servers.list().find((server) => global.ensureServerCtx(server).sync === serverSync())
|
||||
if (currentServer) global.settings.server.set(ServerConnection.key(currentServer))
|
||||
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
|
||||
const directory = createMemo(() => {
|
||||
const server = global.settings.server.selected()
|
||||
if (!server || serverSync() !== global.ensureServerCtx(server).sync) return
|
||||
const route = layout.route()
|
||||
if (route.type === "dir-new-sesssion") return route.dir
|
||||
if (route.type === "draft") {
|
||||
@@ -52,62 +65,99 @@ export const DialogSettings: Component<{
|
||||
>
|
||||
<TabsV2.List>
|
||||
<div class="flex flex-col justify-between h-full w-full">
|
||||
<div class="flex flex-col gap-3 w-full">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<TabsV2.SectionTitle>{language.t("settings.section.desktop")}</TabsV2.SectionTitle>
|
||||
<div class="flex flex-col gap-1.5 w-full">
|
||||
<TabsV2.Trigger value="general">
|
||||
<Icon name="sliders" />
|
||||
{language.t("settings.tab.general")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="shortcuts">
|
||||
<Icon name="keyboard" />
|
||||
{language.t("settings.tab.shortcuts")}
|
||||
</TabsV2.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-4 w-full">
|
||||
{/* Group 1: Preferences */}
|
||||
<div class="flex flex-col gap-1 w-full">
|
||||
<TabsV2.Trigger value="general">
|
||||
<Icon name="sliders" />
|
||||
{language.t("settings.tab.preferences")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="appearance">
|
||||
<Icon name="appearance" />
|
||||
{language.t("settings.general.section.appearance")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="notifications">
|
||||
<Icon name="notifications" />
|
||||
{language.t("settings.tab.notifications")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="shortcuts">
|
||||
<Icon name="keyboard" />
|
||||
{language.t("settings.tab.shortcuts")}
|
||||
</TabsV2.Trigger>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<TabsV2.SectionTitle>{language.t("settings.section.server")}</TabsV2.SectionTitle>
|
||||
<div class="flex flex-col gap-1.5 w-full">
|
||||
<TabsV2.Trigger value="servers">
|
||||
<Icon name="server" />
|
||||
{language.t("status.popover.tab.servers")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="providers">
|
||||
<Icon name="providers" />
|
||||
{language.t("settings.providers.title")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="models">
|
||||
<Icon name="models" />
|
||||
{language.t("settings.models.title")}
|
||||
</TabsV2.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
{/* Group 2: Environment & Workspaces */}
|
||||
<div class="flex flex-col gap-1 w-full">
|
||||
<TabsV2.Trigger value="servers">
|
||||
<Icon name="server" />
|
||||
{language.t("status.popover.tab.servers")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="workspaces">
|
||||
<Icon name="workspace-isolated" />
|
||||
{language.t("settings.tab.workspaces")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="projects">
|
||||
<Icon name="folder" />
|
||||
{language.t("settings.tab.projects")}
|
||||
</TabsV2.Trigger>
|
||||
</div>
|
||||
|
||||
{/* Group 3: Capabilities & Extensions */}
|
||||
<div class="flex flex-col gap-1 w-full">
|
||||
<TabsV2.Trigger value="providers">
|
||||
<Icon name="providers" />
|
||||
{language.t("settings.providers.title")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="models">
|
||||
<Icon name="models" />
|
||||
{language.t("settings.models.title")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="extensions">
|
||||
<Icon name="extensions" />
|
||||
{language.t("settings.tab.extensions")}
|
||||
</TabsV2.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-nav-footer">
|
||||
<span>{language.t("app.name.desktop")}</span>
|
||||
<span>v{platform.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabsV2.List>
|
||||
|
||||
<TabsV2.Content value="general" class="settings-v2-panel">
|
||||
<SettingsGeneralV2 sessionID={props.sessionID} />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="appearance" class="settings-v2-panel">
|
||||
<SettingsAppearanceV2 />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="notifications" class="settings-v2-panel">
|
||||
<SettingsNotificationsV2 />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
|
||||
<SettingsKeybinds v2 />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="workspaces" class="settings-v2-panel">
|
||||
<SettingsWorkspacesV2 activeDirectory={directory()} />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="servers" class="settings-v2-panel">
|
||||
<SettingsServersV2 />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="providers" class="settings-v2-panel">
|
||||
<SettingsProvidersV2 directory={directory} onBack={showProviders} />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="models" class="settings-v2-panel">
|
||||
<SettingsModelsV2 />
|
||||
<TabsV2.Content value="projects" class="settings-v2-panel">
|
||||
<SettingsProjectsV2 />
|
||||
</TabsV2.Content>
|
||||
<SettingsServerScope>
|
||||
<TabsV2.Content value="providers" class="settings-v2-panel">
|
||||
<SettingsProvidersV2 directory={directory} onBack={showProviders} />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="models" class="settings-v2-panel">
|
||||
<SettingsModelsV2 />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="extensions" class="settings-v2-panel">
|
||||
<SettingsExtensionsV2 />
|
||||
</TabsV2.Content>
|
||||
</SettingsServerScope>
|
||||
</TabsV2>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Component, For, createMemo, createResource } from "solid-js"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { ExternalLink } from "../external-link"
|
||||
import { InlineServerSelect } from "./parts/server-select"
|
||||
import "./settings-v2.css"
|
||||
|
||||
interface McpRowItem {
|
||||
name: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface PluginRowItem {
|
||||
name: string
|
||||
}
|
||||
|
||||
export const SettingsExtensionsV2: Component = () => {
|
||||
const language = useLanguage()
|
||||
const serverSdk = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const mcps = createMemo<McpRowItem[]>(() => {
|
||||
const configMcp = serverSync().data.config.mcp ?? {}
|
||||
return Object.entries(configMcp).map(([name, config]) => ({
|
||||
name,
|
||||
enabled: typeof config !== "object" || config === null || !("enabled" in config) || config.enabled !== false,
|
||||
}))
|
||||
})
|
||||
|
||||
const handleMcpToggle = (item: McpRowItem, checked: boolean) => {
|
||||
const before = serverSync().data.config.mcp ?? {}
|
||||
const config = before[item.name]
|
||||
if (typeof config !== "object" || config === null) return
|
||||
const next = { ...before, [item.name]: { ...config, enabled: checked } }
|
||||
serverSync().set("config", "mcp", next)
|
||||
void serverSync()
|
||||
.updateConfig({ mcp: next })
|
||||
.catch(() => serverSync().set("config", "mcp", before))
|
||||
}
|
||||
|
||||
const plugins = createMemo<PluginRowItem[]>(() => {
|
||||
const raw = serverSync().data.config.plugin ?? []
|
||||
return raw.map((item) => {
|
||||
const name = typeof item === "string" ? item : item[0]
|
||||
return { name }
|
||||
})
|
||||
})
|
||||
|
||||
const [skills] = createResource(serverSdk, (sdk) => sdk.api.skill.list().then((result) => result.data), {
|
||||
initialValue: [],
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-v2-tab-header">
|
||||
<div class="settings-v2-tab-header-row">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.tab.extensions")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.extensions.description")}</span>
|
||||
</div>
|
||||
<InlineServerSelect />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-tab-body">
|
||||
<TabsV2 variant="pill" defaultValue="mcps" class="settings-v2-extensions-tabs">
|
||||
<TabsV2.List>
|
||||
<TabsV2.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</TabsV2.Trigger>
|
||||
</TabsV2.List>
|
||||
|
||||
<TabsV2.Content value="mcps">
|
||||
<div class="settings-v2-section">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-13-medium text-v2-text-text-base">
|
||||
{language.t("settings.extensions.availableAll")}
|
||||
</span>
|
||||
<span class="text-13-regular text-v2-text-faint">{language.t("settings.extensions.manageConfig")}</span>
|
||||
</div>
|
||||
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
|
||||
<For each={mcps()}>
|
||||
{(item) => (
|
||||
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
<Icon name="mcp" class="text-v2-icon-icon-muted shrink-0" />
|
||||
<span class="text-13-medium text-v2-text-text-base truncate">{item.name}</span>
|
||||
</div>
|
||||
<Switch checked={item.enabled} onChange={(checked) => handleMcpToggle(item, checked)} hideLabel>
|
||||
{item.name}
|
||||
</Switch>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</TabsV2.Content>
|
||||
|
||||
<TabsV2.Content value="plugins">
|
||||
<div class="settings-v2-section">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-13-medium text-v2-text-text-base">
|
||||
{language.t("settings.extensions.availableAll")}
|
||||
</span>
|
||||
<span class="text-13-regular text-v2-text-faint">{language.t("settings.extensions.manageConfig")}</span>
|
||||
</div>
|
||||
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
|
||||
<For each={plugins()}>
|
||||
{(plugin) => (
|
||||
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
<Icon name="cube" class="text-v2-icon-icon-muted shrink-0" />
|
||||
<span class="text-13-medium text-v2-text-text-base truncate font-mono">{plugin.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</TabsV2.Content>
|
||||
|
||||
<TabsV2.Content value="skills">
|
||||
<div class="settings-v2-section">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-13-medium text-v2-text-text-base">
|
||||
{language.t("settings.extensions.availableAll")}
|
||||
</span>
|
||||
<ExternalLink
|
||||
class="text-13-regular text-v2-text-accent hover:underline"
|
||||
href="https://opencode.ai/docs/skills/"
|
||||
>
|
||||
{language.t("settings.extensions.addSkills")}
|
||||
</ExternalLink>
|
||||
</div>
|
||||
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
|
||||
<For each={skills()}>
|
||||
{(skill) => (
|
||||
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
<Icon name="post-skill" class="text-v2-icon-icon-muted shrink-0" />
|
||||
<span class="text-13-medium text-v2-text-text-base truncate">{skill.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</TabsV2.Content>
|
||||
</TabsV2>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useUpdaterAction } from "../updater-action"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { type WorkspaceDefaultDestination, useSettings } from "@/context/settings"
|
||||
import { ExternalLink } from "../external-link"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { SettingsRowV2 } from "./parts/row"
|
||||
@@ -85,6 +85,34 @@ const PermissionScopeSetting: Component<{ controller: PermissionScopeController
|
||||
)
|
||||
}
|
||||
|
||||
const WorkspaceDestinationSetting: Component = () => {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const options = createMemo((): { value: WorkspaceDefaultDestination; label: string }[] => [
|
||||
{ value: "last-used", label: language.t("settings.workspaces.default.lastUsed") },
|
||||
{ value: "local", label: language.t("settings.workspaces.default.local") },
|
||||
{ value: "new", label: language.t("settings.workspaces.default.new") },
|
||||
])
|
||||
|
||||
return (
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.workspaces.default.title")}
|
||||
description={language.t("settings.workspaces.default.description")}
|
||||
>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
options={options()}
|
||||
current={options().find((option) => option.value === settings.workspaces.defaultDestination())}
|
||||
value={(option) => option.value}
|
||||
label={(option) => option.label}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
onSelect={(option) => option && settings.workspaces.setDefaultDestination(option.value)}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
)
|
||||
}
|
||||
|
||||
const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
|
||||
const language = useLanguage()
|
||||
const options = createMemo(() =>
|
||||
@@ -279,8 +307,6 @@ export const SettingsGeneralV2: Component<{
|
||||
const updater = useUpdaterAction()
|
||||
const permissionScope = createPermissionScopeController(() => props.sessionID)
|
||||
const shell = createShellSettingsController()
|
||||
const appearance = createAppearanceSettingsController()
|
||||
const sounds = createSoundSettingsController()
|
||||
const desktop = createMemo(() => platform.platform === "desktop")
|
||||
|
||||
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
|
||||
@@ -298,9 +324,11 @@ export const SettingsGeneralV2: Component<{
|
||||
|
||||
const GeneralSection = () => (
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.general")}</h3>
|
||||
<SettingsListV2>
|
||||
<LanguageSetting />
|
||||
|
||||
<WorkspaceDestinationSetting />
|
||||
<PermissionScopeSetting controller={permissionScope} />
|
||||
|
||||
<ShellSetting controller={shell} />
|
||||
@@ -363,18 +391,6 @@ export const SettingsGeneralV2: Component<{
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.advanced")}</h3>
|
||||
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.showFileTree.title")}
|
||||
description={language.t("settings.general.row.showFileTree.description")}
|
||||
>
|
||||
<div data-action="settings-show-file-tree">
|
||||
<Switch
|
||||
checked={settings.general.showFileTree()}
|
||||
onChange={(checked) => settings.general.setShowFileTree(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.showSearch.title")}
|
||||
description={language.t("settings.general.row.showSearch.description")}
|
||||
@@ -510,18 +526,19 @@ export const SettingsGeneralV2: Component<{
|
||||
return (
|
||||
<>
|
||||
<div class="settings-v2-tab-header">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.tab.general")}</h2>
|
||||
<div class="settings-v2-tab-header-row">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.tab.preferences")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">
|
||||
{language.t("settings.preferences.description")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-tab-body">
|
||||
<GeneralSection />
|
||||
|
||||
<AppearanceSection controller={appearance} />
|
||||
|
||||
<NotificationsSection />
|
||||
|
||||
<SoundsSection controller={sounds} />
|
||||
|
||||
<Show when={desktop()}>
|
||||
<UpdatesSection />
|
||||
</Show>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useModels } from "@/context/models"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { popularProviders } from "@/hooks/use-providers"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { InlineServerSelect } from "./parts/server-select"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { SettingsRowV2 } from "./parts/row"
|
||||
import "./settings-v2.css"
|
||||
@@ -53,7 +54,13 @@ export const SettingsModelsV2: Component = () => {
|
||||
return (
|
||||
<>
|
||||
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.models.title")}</h2>
|
||||
<div class="settings-v2-tab-header-row">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.models.title")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.models.description")}</span>
|
||||
</div>
|
||||
<InlineServerSelect />
|
||||
</div>
|
||||
<div class="settings-v2-tab-search">
|
||||
<TextInputV2
|
||||
type="search"
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Component } from "solid-js"
|
||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { SettingsRowV2 } from "./parts/row"
|
||||
import { createSoundSettingsController, soundOptions, type SoundSettingsController } from "./general-controllers"
|
||||
import "./settings-v2.css"
|
||||
|
||||
const soundSettings = {
|
||||
agent: {
|
||||
action: "settings-sounds-agent",
|
||||
title: "settings.general.sounds.agent.title",
|
||||
description: "settings.general.sounds.agent.description",
|
||||
},
|
||||
permissions: {
|
||||
action: "settings-sounds-permissions",
|
||||
title: "settings.general.sounds.permissions.title",
|
||||
description: "settings.general.sounds.permissions.description",
|
||||
},
|
||||
errors: {
|
||||
action: "settings-sounds-errors",
|
||||
title: "settings.general.sounds.errors.title",
|
||||
description: "settings.general.sounds.errors.description",
|
||||
},
|
||||
} as const
|
||||
|
||||
const SoundSetting: Component<{
|
||||
kind: "agent" | "permissions" | "errors"
|
||||
channel: SoundSettingsController["agent"]
|
||||
}> = (props) => {
|
||||
const language = useLanguage()
|
||||
const config = () => soundSettings[props.kind]
|
||||
return (
|
||||
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
data-action={config().action}
|
||||
options={soundOptions}
|
||||
current={props.channel.current()}
|
||||
value={(option) => option.id}
|
||||
label={(option) => language.t(option.label)}
|
||||
onHighlight={props.channel.highlight}
|
||||
onSelect={props.channel.select}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
)
|
||||
}
|
||||
|
||||
export const SettingsNotificationsV2: Component = () => {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const sounds = createSoundSettingsController()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-v2-tab-header">
|
||||
<div class="settings-v2-tab-header-row">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.tab.notifications")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">
|
||||
{language.t("settings.notifications.description")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-tab-body">
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.notifications")}</h3>
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.notifications.agent.title")}
|
||||
description={language.t("settings.general.notifications.agent.description")}
|
||||
>
|
||||
<div data-action="settings-notifications-agent">
|
||||
<Switch
|
||||
checked={settings.notifications.agent()}
|
||||
onChange={(checked) => settings.notifications.setAgent(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.notifications.permissions.title")}
|
||||
description={language.t("settings.general.notifications.permissions.description")}
|
||||
>
|
||||
<div data-action="settings-notifications-permissions">
|
||||
<Switch
|
||||
checked={settings.notifications.permissions()}
|
||||
onChange={(checked) => settings.notifications.setPermissions(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.notifications.errors.title")}
|
||||
description={language.t("settings.general.notifications.errors.description")}
|
||||
>
|
||||
<div data-action="settings-notifications-errors">
|
||||
<Switch
|
||||
checked={settings.notifications.errors()}
|
||||
onChange={(checked) => settings.notifications.setErrors(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3>
|
||||
<SettingsListV2>
|
||||
<SoundSetting kind="agent" channel={sounds.agent} />
|
||||
<SoundSetting kind="permissions" channel={sounds.permissions} />
|
||||
<SoundSetting kind="errors" channel={sounds.errors} />
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Show, createMemo, type Component } from "solid-js"
|
||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { ServerConnection, serverName } from "@/context/server"
|
||||
|
||||
const allServers = { type: "all" } as const
|
||||
type ServerOption = ServerConnection.Any | typeof allServers
|
||||
|
||||
export const InlineServerSelect: Component<{
|
||||
all?: {
|
||||
label: string
|
||||
selected: () => boolean
|
||||
onSelect: () => void
|
||||
}
|
||||
onServerSelect?: () => void
|
||||
}> = (props) => {
|
||||
const global = useGlobal()
|
||||
const options = createMemo<ServerOption[]>(() => [...(props.all ? [allServers] : []), ...global.servers.list()])
|
||||
const current = () => (props.all?.selected() ? allServers : global.settings.server.selected())
|
||||
|
||||
return (
|
||||
<Show when={options().length > 1}>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
data-action="settings-server-select"
|
||||
options={options()}
|
||||
current={current()}
|
||||
value={(server) => (server.type === "all" ? server.type : ServerConnection.key(server))}
|
||||
label={(server) =>
|
||||
server.type === "all" ? (props.all?.label ?? "") : serverName(server) || ServerConnection.key(server)
|
||||
}
|
||||
optionDisabled={(server) =>
|
||||
server.type === "all" ? false : global.servers.health[ServerConnection.key(server)]?.healthy === false
|
||||
}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
onSelect={(server) => {
|
||||
if (!server) return
|
||||
if (server.type === "all") {
|
||||
props.all?.onSelect()
|
||||
return
|
||||
}
|
||||
global.settings.server.set(ServerConnection.key(server))
|
||||
props.onServerSelect?.()
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Component, For, Show, createMemo, createSignal } from "solid-js"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { getProjectAvatarVariant } from "@/context/layout"
|
||||
import { ServerConnection, serverName } from "@/context/server"
|
||||
import { displayName } from "@/pages/layout/helpers"
|
||||
import { InlineServerSelect } from "./parts/server-select"
|
||||
import { DialogEditProjectV2 } from "../dialog-edit-project-v2"
|
||||
import "./settings-v2.css"
|
||||
|
||||
export const SettingsProjectsV2: Component = () => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const global = useGlobal()
|
||||
const [allServers, setAllServers] = createSignal(true)
|
||||
const selected = global.settings.server.selected
|
||||
const projects = createMemo(() => {
|
||||
const server = selected()
|
||||
if (!server) return []
|
||||
return global.ensureServerCtx(server).projects.list()
|
||||
})
|
||||
|
||||
type ProjectItem = ReturnType<typeof projects>[number]
|
||||
|
||||
const groups = createMemo(() =>
|
||||
global.servers
|
||||
.list()
|
||||
.map((server) => ({ server, projects: global.ensureServerCtx(server).projects.list() }))
|
||||
.filter((group) => group.projects.length > 0),
|
||||
)
|
||||
|
||||
const openProjectSettings = (project: ProjectItem, server = selected()) => {
|
||||
if (!server) return
|
||||
dialog.push(() => <DialogEditProjectV2 project={project} server={server} />)
|
||||
}
|
||||
|
||||
const ProjectRow: Component<{ project: ProjectItem; server: ServerConnection.Any }> = (props) => {
|
||||
const name = () => displayName(props.project)
|
||||
const color = () => getProjectAvatarVariant(props.project.icon?.color)
|
||||
return (
|
||||
<div
|
||||
class="group flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] cursor-pointer transition-all hover:bg-v2-background-bg-layer-01"
|
||||
onClick={() => openProjectSettings(props.project, props.server)}
|
||||
>
|
||||
<div class="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
<ProjectAvatar fallback={name()} variant={color()} class="shrink-0" />
|
||||
<span class="text-13-medium text-v2-text-text-base truncate">{name()}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="settings-gear" size="small" class="text-v2-icon-icon-muted" />}
|
||||
onClick={(event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
openProjectSettings(props.project, props.server)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-v2-tab-header">
|
||||
<div class="settings-v2-tab-header-row">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.projects.title")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.projects.description")}</span>
|
||||
</div>
|
||||
<InlineServerSelect
|
||||
all={{
|
||||
label: language.t("settings.projects.server.all"),
|
||||
selected: allServers,
|
||||
onSelect: () => setAllServers(true),
|
||||
}}
|
||||
onServerSelect={() => setAllServers(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-tab-body">
|
||||
<Show
|
||||
when={allServers()}
|
||||
fallback={
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<Show
|
||||
when={projects().length > 0}
|
||||
fallback={
|
||||
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
|
||||
{language.t("settings.projects.empty")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={selected()} keyed>
|
||||
{(server) => (
|
||||
<For each={projects()}>{(project) => <ProjectRow project={project} server={server} />}</For>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col gap-8 w-full">
|
||||
<Show
|
||||
when={groups().length > 0}
|
||||
fallback={
|
||||
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
|
||||
{language.t("settings.projects.empty")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={groups()}>
|
||||
{(group) => (
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">
|
||||
{serverName(group.server) || ServerConnection.key(group.server)}
|
||||
</h3>
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<For each={group.projects}>
|
||||
{(project) => <ProjectRow project={project} server={group.server} />}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
|
||||
import { DialogCustomProvider } from "../dialog-custom-provider"
|
||||
import { SettingsServerScope } from "../settings-server-picker"
|
||||
import { InlineServerSelect } from "./parts/server-select"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import "./settings-v2.css"
|
||||
|
||||
@@ -42,13 +44,23 @@ export const SettingsProvidersV2: Component<{
|
||||
|
||||
const connect = (provider?: string) => {
|
||||
providerConnect.select(provider)
|
||||
void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
|
||||
void dialog.show(() => (
|
||||
<SettingsServerScope>
|
||||
<DialogConnectProvider directory={props.directory} controller={providerConnect} />
|
||||
</SettingsServerScope>
|
||||
))
|
||||
}
|
||||
|
||||
const connected = createMemo(() => {
|
||||
return providers
|
||||
.connected()
|
||||
.filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input))
|
||||
return providers.connected().filter(
|
||||
(provider) =>
|
||||
provider.id !== "opencode" ||
|
||||
Object.values(provider.models).some((model) => {
|
||||
if (typeof model !== "object" || model === null || !("cost" in model)) return false
|
||||
const cost = model.cost
|
||||
return typeof cost === "object" && cost !== null && "input" in cost
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const popular = createMemo(() => {
|
||||
@@ -92,29 +104,6 @@ export const SettingsProvidersV2: Component<{
|
||||
return true
|
||||
}
|
||||
|
||||
const disableProvider = async (providerID: string, name: string) => {
|
||||
return
|
||||
const before = serverSync().data.config.disabled_providers ?? []
|
||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||
serverSync().set("config", "disabled_providers", next)
|
||||
|
||||
await serverSync()
|
||||
.updateConfig({ disabled_providers: next })
|
||||
.then(() => {
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
|
||||
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
serverSync().set("config", "disabled_providers", before)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
})
|
||||
}
|
||||
|
||||
const disconnect = async (providerID: string, name: string) => {
|
||||
const location = props.directory() ? { directory: props.directory() } : undefined
|
||||
await serverSdk()
|
||||
@@ -141,7 +130,13 @@ export const SettingsProvidersV2: Component<{
|
||||
return (
|
||||
<>
|
||||
<div class="settings-v2-tab-header">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.providers.title")}</h2>
|
||||
<div class="settings-v2-tab-header-row">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.providers.title")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.providers.description")}</span>
|
||||
</div>
|
||||
<InlineServerSelect />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-tab-body settings-v2-providers">
|
||||
@@ -244,7 +239,11 @@ export const SettingsProvidersV2: Component<{
|
||||
variant="neutral"
|
||||
icon="plus"
|
||||
onClick={() => {
|
||||
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
|
||||
dialog.show(() => (
|
||||
<SettingsServerScope>
|
||||
<DialogCustomProvider onBack={dialog.close} />
|
||||
</SettingsServerScope>
|
||||
))
|
||||
}}
|
||||
>
|
||||
{language.t("common.connect")}
|
||||
|
||||
@@ -53,7 +53,10 @@ export const SettingsServersV2: Component = () => {
|
||||
classList={{ "settings-v2-tab-header--stacked": showSearch() }}
|
||||
>
|
||||
<div class="settings-v2-tab-header-row">
|
||||
<h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.servers.description")}</span>
|
||||
</div>
|
||||
<AddServerMenu onAddServer={openAdd} />
|
||||
</div>
|
||||
<Show when={showSearch()}>
|
||||
|
||||
@@ -39,6 +39,14 @@
|
||||
background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent);
|
||||
}
|
||||
|
||||
.settings-v2-tab-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-v2-tab-title {
|
||||
font-size: 15px;
|
||||
font-weight: 640;
|
||||
@@ -174,13 +182,13 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-list"] {
|
||||
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] > [data-slot="tabs-v2-list"] {
|
||||
background-color: var(--v2-background-bg-layer-01);
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.settings-v2[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
|
||||
[data-slot="tabs-v2-list"] {
|
||||
> [data-slot="tabs-v2-list"] {
|
||||
width: 144px;
|
||||
min-width: 144px;
|
||||
padding-inline: 8px;
|
||||
@@ -684,6 +692,223 @@
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-v2-tab-header.settings-v2-workspaces-header {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-header .settings-v2-tab-title {
|
||||
font-weight: 610;
|
||||
}
|
||||
|
||||
.settings-v2-tab-body.settings-v2-workspaces {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-toolbar {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-count {
|
||||
font-size: 15px;
|
||||
font-weight: 530;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-delete-all {
|
||||
color: var(--v2-state-fg-danger);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-inventory [data-component="settings-v2-list"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 20px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--v2-background-bg-base);
|
||||
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-row:not(:last-child) {
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-row-header {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-row-actions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-main [data-component="tooltip-v2-trigger"] {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-path {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--v2-text-text-base);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-meta {
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-active,
|
||||
.settings-v2-workspaces-more {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-sessions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 0.5px solid var(--v2-border-border-base);
|
||||
border-radius: 4px;
|
||||
background-color: var(--v2-background-bg-base);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-session {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 16px;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-session:not(:last-child) {
|
||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-session > span:first-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-session-time {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-block: 48px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.settings-v2-workspaces-header {
|
||||
padding: 24px 20px 20px;
|
||||
}
|
||||
|
||||
.settings-v2-tab-body.settings-v2-workspaces {
|
||||
padding: 0 20px 24px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-toolbar,
|
||||
.settings-v2-workspaces-main {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-toolbar-actions {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-inventory [data-component="settings-v2-list"] {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-path {
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.settings-v2-workspaces-active {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-container"] {
|
||||
width: 480px;
|
||||
max-width: calc(100vw - 32px);
|
||||
@@ -727,3 +952,24 @@
|
||||
line-height: 1;
|
||||
color: var(--v2-state-fg-danger);
|
||||
}
|
||||
|
||||
.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
|
||||
width: 280px;
|
||||
padding-inline: 0 !important;
|
||||
}
|
||||
|
||||
.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"]::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"]
|
||||
> [data-slot="tabs-v2-list"]
|
||||
[data-slot="tabs-v2-trigger-wrapper"] {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"]
|
||||
> [data-slot="tabs-v2-list"]
|
||||
[data-slot="tabs-v2-trigger"] {
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
import type { Component } from "solid-js"
|
||||
import { For, Show, createMemo } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Dialog, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getRelativeTime } from "@/utils/time"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { clearWorkspaceTerminals } from "@/context/terminal"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import type { Project } from "@/types"
|
||||
import {
|
||||
containsDirectory,
|
||||
filterWorkspaceInventory,
|
||||
inspectWorkspaceDeletion,
|
||||
mergeWorkspaceSessionInventory,
|
||||
removeWorkspacesSequentially,
|
||||
sessionsForWorkspace,
|
||||
type WorkspaceDeleteInspection,
|
||||
workspaceInventory,
|
||||
} from "@/utils/workspace"
|
||||
import { listAllSessions } from "@/utils/session"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import "./settings-v2.css"
|
||||
|
||||
type Workspace = {
|
||||
directory: string
|
||||
project: Project
|
||||
}
|
||||
|
||||
export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const tabs = useTabs()
|
||||
const platform = usePlatform()
|
||||
const [store, setStore] = createStore({
|
||||
project: "all",
|
||||
transaction: undefined as "confirm" | "running" | undefined,
|
||||
})
|
||||
|
||||
const workspaces = createMemo(() => workspaceInventory(serverSync().data.project))
|
||||
const projects = createMemo(() => serverSync().data.project.filter((project) => project.sandboxes?.length))
|
||||
const projectName = (project: Project) => project.name || getFilename(project.worktree)
|
||||
const projectOptions = createMemo(() => [
|
||||
{ id: "all", label: language.t("settings.workspaces.filter.all") },
|
||||
...projects().map((project) => ({ id: project.id, label: projectName(project) })),
|
||||
])
|
||||
const selectedProject = createMemo(() =>
|
||||
store.project === "all" || projects().some((project) => project.id === store.project) ? store.project : "all",
|
||||
)
|
||||
const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject()))
|
||||
const captureDeleteContext = () => {
|
||||
const sdk = serverSDK()
|
||||
return { sdk, sync: serverSync(), server: ServerConnection.key(sdk.server), activeDirectory: props.activeDirectory }
|
||||
}
|
||||
const loadSessions = async (context = captureDeleteContext()) => {
|
||||
const fetched = await listAllSessions(context.sdk.api.session, { order: "desc" })
|
||||
return mergeWorkspaceSessionInventory(
|
||||
fetched,
|
||||
Object.values(context.sync.session.data.info).filter((session): session is SessionInfo => !!session),
|
||||
)
|
||||
}
|
||||
const sessionQuery = useQuery(() => ({
|
||||
queryKey: [serverSDK().scope, null, "settings-workspace-sessions"] as const,
|
||||
queryFn: () => loadSessions(),
|
||||
refetchOnMount: "always",
|
||||
}))
|
||||
const workspaceSessions = (workspace: Workspace) => {
|
||||
if (!sessionQuery.isSuccess) return []
|
||||
return sessionsForWorkspace(sessionQuery.data ?? [], workspace.directory)
|
||||
}
|
||||
const sessionCount = (workspace: Workspace) => {
|
||||
if (sessionQuery.isPending) return language.t("session.messages.loading")
|
||||
if (sessionQuery.isError) return language.t("common.requestFailed")
|
||||
const count = workspaceSessions(workspace).length
|
||||
return language.plural("settings.workspaces.sessions", count, {
|
||||
count,
|
||||
project: projectName(workspace.project),
|
||||
})
|
||||
}
|
||||
const lastActive = (workspace: Workspace) => {
|
||||
const updated = workspaceSessions(workspace)[0]?.time.updated
|
||||
if (!updated) return undefined
|
||||
return getRelativeTime(new Date(updated).toISOString(), language.t)
|
||||
}
|
||||
const sessionTime = (session: SessionInfo) => {
|
||||
if (!session.time.updated) return undefined
|
||||
return getRelativeTime(new Date(session.time.updated).toISOString(), language.t)
|
||||
}
|
||||
|
||||
const inspect = async (workspace: Workspace, context = captureDeleteContext()) => {
|
||||
const [working, branch, sessions] = await Promise.all([
|
||||
context.sdk.api.vcs.status({ location: { directory: workspace.directory } }),
|
||||
context.sdk.api.vcs.diff({ location: { directory: workspace.directory }, mode: "branch" }),
|
||||
loadSessions(context),
|
||||
])
|
||||
const result = inspectWorkspaceDeletion({
|
||||
workspace: workspace.directory,
|
||||
activeDirectory: context.activeDirectory,
|
||||
sessions,
|
||||
status: working.data.length > 0 || branch.data.length > 0 ? "dirty" : "clean",
|
||||
})
|
||||
return { result, sessions }
|
||||
}
|
||||
const inspectionMessage = (result: WorkspaceDeleteInspection) => {
|
||||
if (result === "active") return language.t("settings.workspaces.delete.blocked.active")
|
||||
if (result === "linked") return language.t("settings.workspaces.delete.blocked.linked")
|
||||
if (result === "dirty") return language.t("workspace.status.dirty")
|
||||
return language.t("workspace.status.clean")
|
||||
}
|
||||
const blocked = (result: WorkspaceDeleteInspection) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("workspace.delete.failed.title"),
|
||||
description: inspectionMessage(result),
|
||||
})
|
||||
}
|
||||
|
||||
const remove = async (workspace: Workspace, allowDirty = false, context = captureDeleteContext()) => {
|
||||
const preflight = await inspect(workspace, context)
|
||||
if (preflight.result !== "safe" && (!allowDirty || preflight.result !== "dirty")) {
|
||||
blocked(preflight.result)
|
||||
return
|
||||
}
|
||||
const removed = await context.sdk.api.projectCopy
|
||||
.remove({
|
||||
projectID: workspace.project.id,
|
||||
location: { directory: workspace.project.worktree },
|
||||
directory: workspace.directory,
|
||||
force: allowDirty,
|
||||
})
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("workspace.delete.failed.title"),
|
||||
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
||||
})
|
||||
return false
|
||||
})
|
||||
if (!removed) return
|
||||
tabs.store.forEach((tab) => {
|
||||
if (tab.type !== "draft" || tab.server !== context.server) return
|
||||
const directoryMatches = containsDirectory(workspace.directory, tab.directory)
|
||||
const worktreeMatches = tab.worktree && containsDirectory(workspace.directory, tab.worktree)
|
||||
if (!directoryMatches && !worktreeMatches) return
|
||||
tabs.updateDraft(tab.draftID, {
|
||||
directory: directoryMatches ? workspace.project.worktree : tab.directory,
|
||||
worktree: undefined,
|
||||
})
|
||||
})
|
||||
clearWorkspaceTerminals(
|
||||
workspace.directory,
|
||||
preflight.sessions.map((session) => session.id),
|
||||
platform,
|
||||
context.sdk.scope,
|
||||
)
|
||||
context.sync.set(
|
||||
"project",
|
||||
produce((draft) => {
|
||||
const project = draft.find((item) => item.id === workspace.project.id)
|
||||
if (!project) return
|
||||
project.sandboxes = (project.sandboxes ?? []).filter(
|
||||
(directory) => pathKey(directory) !== pathKey(workspace.directory),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
let inspectionID = 0
|
||||
const releaseConfirmation = () => {
|
||||
if (store.transaction === "confirm") setStore("transaction", undefined)
|
||||
}
|
||||
const transact = async (task: () => Promise<void>) => {
|
||||
if (store.transaction !== "confirm") return
|
||||
setStore("transaction", "running")
|
||||
try {
|
||||
await task()
|
||||
} catch (error) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("workspace.delete.failed.title"),
|
||||
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
||||
})
|
||||
} finally {
|
||||
setStore("transaction", undefined)
|
||||
}
|
||||
}
|
||||
const confirmDelete = (workspace: Workspace) => {
|
||||
if (store.transaction) return
|
||||
const context = captureDeleteContext()
|
||||
const current = ++inspectionID
|
||||
setStore("transaction", "confirm")
|
||||
void dialog.push(
|
||||
() => (
|
||||
<DialogDeleteWorkspace
|
||||
workspace={workspace}
|
||||
scope={context.sdk.scope}
|
||||
inspectionID={current}
|
||||
inspect={() => inspect(workspace, context)}
|
||||
inspectionMessage={inspectionMessage}
|
||||
onDelete={() => transact(() => remove(workspace, true, context))}
|
||||
/>
|
||||
),
|
||||
releaseConfirmation,
|
||||
)
|
||||
}
|
||||
const removeAll = async (inventory: Workspace[], context: ReturnType<typeof captureDeleteContext>) => {
|
||||
await removeWorkspacesSequentially(inventory, (workspace) => remove(workspace, false, context))
|
||||
}
|
||||
const confirmDeleteAll = () => {
|
||||
if (store.transaction) return
|
||||
const context = captureDeleteContext()
|
||||
const inventory = [...filtered()]
|
||||
const project = projectOptions().find((option) => option.id === selectedProject())?.label ?? selectedProject()
|
||||
setStore("transaction", "confirm")
|
||||
void dialog.push(
|
||||
() => (
|
||||
<DialogDeleteAllWorkspaces
|
||||
count={inventory.length}
|
||||
project={project}
|
||||
onDelete={() => transact(() => removeAll(inventory, context))}
|
||||
/>
|
||||
),
|
||||
releaseConfirmation,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-v2-tab-header settings-v2-workspaces-header">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.tab.workspaces")}</h2>
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-tab-body settings-v2-workspaces">
|
||||
<div class="settings-v2-workspaces-toolbar">
|
||||
<span class="settings-v2-workspaces-count">
|
||||
{language.plural("settings.workspaces.count", filtered().length)}
|
||||
</span>
|
||||
<div class="settings-v2-workspaces-toolbar-actions">
|
||||
<Show when={projects().length > 1}>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
options={projectOptions()}
|
||||
current={projectOptions().find((option) => option.id === selectedProject())}
|
||||
value={(option) => option.id}
|
||||
label={(option) => option.label}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
onSelect={(option) => option && setStore("project", option.id)}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={filtered().length > 0}>
|
||||
<MenuV2 placement="bottom-end" gutter={4}>
|
||||
<MenuV2.Trigger
|
||||
as={IconButtonV2}
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
disabled={!!store.transaction}
|
||||
icon={<Icon name="outline-dots" size="small" />}
|
||||
/>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content>
|
||||
<MenuV2.Item onSelect={confirmDeleteAll}>
|
||||
<span class="settings-v2-workspaces-delete-all">
|
||||
{language.t("settings.workspaces.deleteAll")}
|
||||
</span>
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-workspaces-inventory">
|
||||
<Show
|
||||
when={filtered().length > 0}
|
||||
fallback={<div class="settings-v2-workspaces-empty">{language.t("settings.workspaces.empty")}</div>}
|
||||
>
|
||||
<SettingsListV2>
|
||||
<For each={filtered()}>
|
||||
{(workspace) => {
|
||||
const linked = () => workspaceSessions(workspace)
|
||||
return (
|
||||
<div class="settings-v2-workspaces-row">
|
||||
<div class="settings-v2-workspaces-row-header">
|
||||
<div class="settings-v2-workspaces-copy">
|
||||
<div class="settings-v2-workspaces-main">
|
||||
<TooltipV2
|
||||
value={workspace.directory}
|
||||
placement="top-start"
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<span tabIndex={0} aria-label={workspace.directory} class="settings-v2-workspaces-path">
|
||||
{workspace.directory}
|
||||
</span>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
<span class="settings-v2-workspaces-meta">{sessionCount(workspace)}</span>
|
||||
</div>
|
||||
<div class="settings-v2-workspaces-row-actions">
|
||||
<Show when={lastActive(workspace)}>
|
||||
{(value) => (
|
||||
<TooltipV2
|
||||
value={language.t("settings.workspaces.lastActiveSession")}
|
||||
placement="top-end"
|
||||
>
|
||||
<span tabIndex={0} class="settings-v2-workspaces-active">
|
||||
{value()}
|
||||
</span>
|
||||
</TooltipV2>
|
||||
)}
|
||||
</Show>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
aria-label={language.t("workspace.delete.confirm", {
|
||||
name: getFilename(workspace.directory),
|
||||
})}
|
||||
disabled={!!store.transaction}
|
||||
icon={<Icon name="trash" size="small" />}
|
||||
onClick={() => confirmDelete(workspace)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={linked().length > 0}>
|
||||
<div class="settings-v2-workspaces-sessions">
|
||||
<For each={linked()}>
|
||||
{(session) => (
|
||||
<div class="settings-v2-workspaces-session">
|
||||
<span>{session.title}</span>
|
||||
<Show when={sessionTime(session)}>
|
||||
{(time) => <span class="settings-v2-workspaces-session-time">{time()}</span>}
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</SettingsListV2>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDeleteAllWorkspaces(props: { count: number; project: string; onDelete: () => Promise<void> }) {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const remove = () => {
|
||||
const deleting = props.onDelete()
|
||||
dialog.close()
|
||||
void deleting
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog fit>
|
||||
<DialogHeader>
|
||||
<DialogTitleGroup
|
||||
title={language.t("settings.workspaces.deleteAll")}
|
||||
description={
|
||||
<>
|
||||
{language.t("settings.workspaces.deleteAll.confirm", { count: props.count })}
|
||||
<br />
|
||||
{language.t("settings.workspaces.deleteAll.warning", { count: props.count, project: props.project })}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</ButtonV2>
|
||||
<ButtonV2 type="button" variant="danger" onClick={remove}>
|
||||
{language.t("settings.workspaces.deleteAll")}
|
||||
</ButtonV2>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDeleteWorkspace(props: {
|
||||
workspace: Workspace
|
||||
scope: ServerScope
|
||||
inspectionID: number
|
||||
inspect: () => Promise<{ result: WorkspaceDeleteInspection; sessions: SessionInfo[] }>
|
||||
inspectionMessage: (result: WorkspaceDeleteInspection) => string
|
||||
onDelete: () => Promise<void>
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const status = useQuery(() => ({
|
||||
queryKey: [props.scope, pathKey(props.workspace.directory), "workspace-delete-status", props.inspectionID] as const,
|
||||
queryFn: props.inspect,
|
||||
staleTime: 0,
|
||||
}))
|
||||
const description = () => {
|
||||
if (status.isPending) return language.t("workspace.status.checking")
|
||||
if (status.isError) return language.t("workspace.status.error")
|
||||
return props.inspectionMessage(status.data?.result ?? "unknown")
|
||||
}
|
||||
const remove = () => {
|
||||
const deleting = props.onDelete()
|
||||
dialog.close()
|
||||
void deleting
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog fit>
|
||||
<DialogHeader>
|
||||
<DialogTitleGroup
|
||||
title={language.t("workspace.delete.title")}
|
||||
description={
|
||||
<>
|
||||
{language.t("workspace.delete.confirm", { name: getFilename(props.workspace.directory) })}
|
||||
<br />
|
||||
<code class="max-w-full rounded-[4px] bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-v2-text-text-base break-all">
|
||||
{props.workspace.directory}
|
||||
</code>
|
||||
<br />
|
||||
{language.t("settings.workspaces.delete.warning")}
|
||||
<br />
|
||||
{description()}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</ButtonV2>
|
||||
<ButtonV2
|
||||
type="button"
|
||||
variant="danger"
|
||||
disabled={
|
||||
status.isPending || status.isError || (status.data?.result !== "safe" && status.data?.result !== "dirty")
|
||||
}
|
||||
onClick={remove}
|
||||
>
|
||||
{language.t("workspace.delete.button")}
|
||||
</ButtonV2>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -106,16 +106,26 @@ describe("query keys", () => {
|
||||
})
|
||||
|
||||
test("loads projects from the current endpoint", async () => {
|
||||
const calls: string[] = []
|
||||
const api = {
|
||||
list: async () => [
|
||||
{ id: "b", worktree: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
{ id: "a", worktree: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
],
|
||||
directories: async ({ projectID }: { projectID: string }) => {
|
||||
calls.push(projectID)
|
||||
return [
|
||||
{ directory: `/${projectID}` },
|
||||
{ directory: `/${projectID}/copy`, strategy: "git_worktree" },
|
||||
]
|
||||
},
|
||||
} as unknown as ProjectApi
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api))
|
||||
|
||||
expect(result.map((project) => project.id)).toEqual(["a", "b"])
|
||||
expect(result.map((project) => project.sandboxes)).toEqual([["/a/copy"], ["/b/copy"]])
|
||||
expect(calls.toSorted()).toEqual(["a", "b"])
|
||||
})
|
||||
|
||||
test("loads references from the current location-scoped endpoint", async () => {
|
||||
|
||||
@@ -103,6 +103,7 @@ export const loadGlobalConfigQuery = (scope: ServerScope) =>
|
||||
type ProjectApi = {
|
||||
readonly list: () => Promise<ProjectListOutput>
|
||||
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
|
||||
readonly directories: ServerApi["project"]["directories"]
|
||||
}
|
||||
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
|
||||
|
||||
@@ -116,10 +117,16 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
|
||||
queryKey: [scope, "project"],
|
||||
queryFn: () =>
|
||||
retry(() =>
|
||||
api.list().then((projects) => {
|
||||
return projects
|
||||
.filter((p) => !!p?.id)
|
||||
.map(normalizeProjectInfo)
|
||||
api.list().then(async (projects) => {
|
||||
return (await Promise.all(
|
||||
projects.filter((project) => !!project?.id).map(async (project) => {
|
||||
const directories = await api.directories({ projectID: project.id })
|
||||
return normalizeProjectInfo({
|
||||
...project,
|
||||
sandboxes: directories.filter((item) => item.strategy !== undefined).map((item) => item.directory),
|
||||
})
|
||||
}),
|
||||
))
|
||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||
.slice()
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
|
||||
@@ -2,7 +2,12 @@ import * as i18n from "@solid-primitives/i18n"
|
||||
import { createEffect, createMemo, createResource } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { pluralCategory, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n"
|
||||
import {
|
||||
pluralCategory,
|
||||
type UiI18nPluralLookupKey,
|
||||
type UiI18nPluralKey,
|
||||
type UiPluralCategory,
|
||||
} from "@opencode-ai/ui/context/i18n"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { dict as en } from "@/i18n/en"
|
||||
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
|
||||
@@ -28,11 +33,13 @@ function localeDirection(locale: Locale): Direction {
|
||||
|
||||
type RawDictionary = typeof en & typeof uiEn
|
||||
type Dictionary = i18n.Flatten<RawDictionary>
|
||||
type PluralKey =
|
||||
| UiI18nPluralKey
|
||||
| "session.question.pending"
|
||||
| "session.followupDock.summary"
|
||||
| "session.revertDock.summary"
|
||||
type AppI18nKey = Extract<keyof typeof en, string>
|
||||
type AppI18nPluralKey = {
|
||||
[Key in AppI18nKey]: Key extends `${infer Base}.other` ? (`${Base}.one` extends AppI18nKey ? Base : never) : never
|
||||
}[AppI18nKey]
|
||||
type PluralKey = AppI18nPluralKey | UiI18nPluralKey
|
||||
type AppI18nPluralLookupKey = `${AppI18nPluralKey}.${UiPluralCategory}`
|
||||
type TranslationKey<Key extends string> = Key extends AppI18nPluralLookupKey | UiI18nPluralLookupKey ? never : Key
|
||||
type Source = { dict: Record<string, string> }
|
||||
|
||||
function cookie(locale: Locale) {
|
||||
@@ -189,18 +196,23 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
||||
initialValue: dicts.get(initial) ?? base,
|
||||
})
|
||||
|
||||
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as (
|
||||
key: keyof Dictionary,
|
||||
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as <Key extends string>(
|
||||
key: TranslationKey<Key>,
|
||||
params?: Record<string, string | number | boolean>,
|
||||
) => string
|
||||
|
||||
const plural = (key: PluralKey, count: number, params?: Record<string, string | number | boolean>) => {
|
||||
const category = pluralCategory(intl(), count)
|
||||
const pluralForm = (
|
||||
key: PluralKey,
|
||||
category: UiPluralCategory,
|
||||
params?: Record<string, string | number | boolean>,
|
||||
) => {
|
||||
const current = (dict.loading ? base : (dict() ?? base)) as Record<string, string>
|
||||
const candidate = `${key}.${category}`
|
||||
const fallback = `${key}.other`
|
||||
return i18n.resolveTemplate(current[candidate] ?? current[fallback] ?? fallback, { ...params, count })
|
||||
return i18n.resolveTemplate(current[candidate] ?? current[fallback] ?? fallback, params)
|
||||
}
|
||||
const plural = (key: PluralKey, count: number, params?: Record<string, string | number | boolean>) =>
|
||||
pluralForm(key, pluralCategory(intl(), count), { ...params, count })
|
||||
|
||||
const label = (value: Locale) => DESKTOP_NATIVE_LABELS[value]
|
||||
|
||||
@@ -231,6 +243,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
||||
label,
|
||||
t,
|
||||
plural,
|
||||
pluralForm,
|
||||
setLocale(next: Locale) {
|
||||
setStore("locale", normalizeLocale(next))
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useServerSDK } from "./server-sdk"
|
||||
import { useSettings } from "./settings"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useTabs, type Tab } from "./tabs"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import {
|
||||
createPromptReady,
|
||||
createPromptSession,
|
||||
@@ -104,11 +105,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
|
||||
const scope = (): PromptScope =>
|
||||
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
|
||||
const load = (scope: PromptScope) => {
|
||||
const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
|
||||
if (current) return createTabPromptState(tabs, current, serverSDK().scope, scope)
|
||||
const load = (scope: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) => {
|
||||
const current = settings.general.newLayoutDesigns()
|
||||
? selectPromptTab(tabs.store, scope, target?.server ?? serverKey())
|
||||
: undefined
|
||||
if (current) return createTabPromptState(tabs, current, target?.scope ?? serverSDK().scope, scope)
|
||||
|
||||
const key = scopeKey(scope)
|
||||
const key = target ? `${target.scope}:${scopeKey(scope)}` : scopeKey(scope)
|
||||
const existing = cache.get(key)
|
||||
if (existing) {
|
||||
cache.delete(key)
|
||||
@@ -118,7 +121,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
|
||||
const entry = createRoot(
|
||||
(dispose) => ({
|
||||
value: createPromptSession(serverSDK().scope, scope),
|
||||
value: createPromptSession(target?.scope ?? serverSDK().scope, scope),
|
||||
dispose,
|
||||
}),
|
||||
owner,
|
||||
@@ -130,7 +133,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
}
|
||||
|
||||
const session = createMemo(() => load(scope()))
|
||||
const pick = (scope?: PromptScope) => (scope ? load(scope) : session())
|
||||
const pick = (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) =>
|
||||
scope ? load(scope, target) : session()
|
||||
const ready = createPromptReady(session)
|
||||
|
||||
const withSuspense = <T,>(cb: () => T): (() => T) =>
|
||||
@@ -146,7 +150,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
|
||||
return {
|
||||
ready,
|
||||
capture: (scope?: PromptScope) => pick(scope).capture(),
|
||||
capture: (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) =>
|
||||
pick(scope, target).capture(),
|
||||
current: withSuspense(() => session().current()),
|
||||
cursor: withSuspense(() => session().cursor()),
|
||||
dirty: withSuspense(() => session().dirty()),
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
||||
import {
|
||||
adaptServerEvent,
|
||||
applyWorkspaceOperationEvent,
|
||||
coalesceServerEvents,
|
||||
enqueueServerEvent,
|
||||
resumeStreamAfterPageShow,
|
||||
} from "./server-sdk"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
|
||||
test("a moved event completes the matching workspace operation", () => {
|
||||
WorkspaceOperation.start(ServerScope.local, "current", "move", "/workspace")
|
||||
applyWorkspaceOperationEvent(ServerScope.local, {
|
||||
directory: "/workspace",
|
||||
payload: adaptServerEvent({
|
||||
id: "moved-current",
|
||||
created: Date.now(),
|
||||
type: "session.moved",
|
||||
durable: { aggregateID: "current", seq: 1, version: 1 },
|
||||
data: { sessionID: "current", location: { directory: "/workspace" } },
|
||||
} satisfies Extract<OpenCodeEvent, { type: "session.moved" }>),
|
||||
})
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "current")?.status).toBe("complete")
|
||||
})
|
||||
|
||||
describe("resumeStreamAfterPageShow", () => {
|
||||
test("restarts a stream only after a back-forward cache restore", () => {
|
||||
@@ -72,7 +95,7 @@ describe("current event buffering", () => {
|
||||
type: "session.tool.input.delta",
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "ses", assistantMessageID: "msg", id, delta },
|
||||
} as OpenCodeEvent)
|
||||
} satisfies Extract<OpenCodeEvent, { type: "session.tool.input.delta" }>)
|
||||
const result = coalesceServerEvents([
|
||||
{ directory: "/repo", payload: current("evt_1", "call_1", "{") },
|
||||
{ directory: "/repo", payload: current("evt_2", "call_1", "}") },
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ServerConnection, useServer } from "./server"
|
||||
import { createRefCountMap } from "@/utils/refcount"
|
||||
import { useGlobal } from "./global"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
|
||||
const isAbortError = (error: unknown) =>
|
||||
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
||||
@@ -68,6 +69,16 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
||||
return output
|
||||
}
|
||||
|
||||
export function applyWorkspaceOperationEvent(scope: ServerScope, event: QueuedServerEvent) {
|
||||
if (event.payload.current?.type !== "session.moved") return false
|
||||
WorkspaceOperation.complete(
|
||||
scope,
|
||||
event.payload.current.data.sessionID,
|
||||
event.payload.current.data.location.directory,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
|
||||
if (
|
||||
event?.type === "session.text.delta" ||
|
||||
@@ -151,7 +162,10 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
last = Date.now()
|
||||
const output = coalesceServerEvents(events)
|
||||
batch(() => {
|
||||
output.forEach((event) => emitter.emit(event.directory, event.payload))
|
||||
output.forEach((event) => {
|
||||
applyWorkspaceOperationEvent(scope, event)
|
||||
emitter.emit(event.directory, event.payload)
|
||||
})
|
||||
})
|
||||
|
||||
buffer.length = 0
|
||||
|
||||
@@ -69,6 +69,63 @@ describe("v2 session reducer", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("prefers durable selection predecessors and derives them for older events", () => {
|
||||
const source: SessionMessageInfo[] = [
|
||||
{ id: "msg_previous_agent", type: "agent-switched", agent: "build", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_previous_model",
|
||||
type: "model-switched",
|
||||
model: { id: "old", providerID: "provider" },
|
||||
time: { created: 1 },
|
||||
},
|
||||
]
|
||||
const reducer = createV2SessionReducer()
|
||||
|
||||
const agent = reducer.reduce(
|
||||
source,
|
||||
event({
|
||||
...base,
|
||||
id: "evt_agent",
|
||||
type: "session.agent.selected",
|
||||
data: { sessionID: "ses_1", agent: "plan", previous: "review" },
|
||||
}),
|
||||
)
|
||||
const model = reducer.reduce(
|
||||
source,
|
||||
event({
|
||||
...base,
|
||||
id: "evt_model",
|
||||
type: "session.model.selected",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
model: { id: "new", providerID: "provider" },
|
||||
previous: { id: "durable", providerID: "provider" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
const legacyAgent = reducer.reduce(
|
||||
source,
|
||||
event({
|
||||
...base,
|
||||
id: "evt_legacy_agent",
|
||||
type: "session.agent.selected",
|
||||
data: { sessionID: "ses_1", agent: "plan" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(agent?.messages.at(-1)).toMatchObject({ type: "agent-switched", agent: "plan", previous: "review" })
|
||||
expect(model?.messages.at(-1)).toMatchObject({
|
||||
type: "model-switched",
|
||||
model: { id: "new" },
|
||||
previous: { id: "durable" },
|
||||
})
|
||||
expect(legacyAgent?.messages.at(-1)).toMatchObject({
|
||||
type: "agent-switched",
|
||||
agent: "plan",
|
||||
previous: "build",
|
||||
})
|
||||
})
|
||||
|
||||
test("folds tool, retry, and completion events", () => {
|
||||
const reducer = createV2SessionReducer()
|
||||
let messages: SessionMessageInfo[] = []
|
||||
|
||||
@@ -61,6 +61,12 @@ export function createV2SessionReducer() {
|
||||
type: "agent-switched",
|
||||
metadata: event.metadata,
|
||||
agent: event.data.agent,
|
||||
previous:
|
||||
event.data.previous ??
|
||||
source.findLast(
|
||||
(item): item is Extract<SessionMessageInfo, { type: "agent-switched" | "assistant" }> =>
|
||||
item.type === "agent-switched" || item.type === "assistant",
|
||||
)?.agent,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.model.selected":
|
||||
@@ -69,10 +75,12 @@ export function createV2SessionReducer() {
|
||||
type: "model-switched",
|
||||
metadata: event.metadata,
|
||||
model: event.data.model,
|
||||
previous: source.findLast(
|
||||
(item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
|
||||
item.type === "model-switched" || item.type === "assistant",
|
||||
)?.model,
|
||||
previous:
|
||||
event.data.previous ??
|
||||
source.findLast(
|
||||
(item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
|
||||
item.type === "model-switched" || item.type === "assistant",
|
||||
)?.model,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.synthetic":
|
||||
|
||||
@@ -358,6 +358,23 @@ describe("server session", () => {
|
||||
expect(ctx.store.lineage.peek("child")).toEqual(result)
|
||||
})
|
||||
|
||||
test("applies moved session locations without evicting cached state", () => {
|
||||
const current = { ...session("child"), location: { directory: "/repo/worktree" } }
|
||||
const ctx = setup({ child: current })
|
||||
ctx.store.remember(current)
|
||||
|
||||
ctx.store.applyV2({
|
||||
id: "evt_moved",
|
||||
created: 2,
|
||||
type: "session.moved",
|
||||
durable: { aggregateID: "child", seq: 1, version: 1 },
|
||||
location: current.location,
|
||||
data: { sessionID: "child", location: { directory: "/repo" }, subpath: "packages/app" },
|
||||
} satisfies Extract<OpenCodeEvent, { type: "session.moved" }>)
|
||||
|
||||
expect(ctx.store.get("child")).toMatchObject({ location: { directory: "/repo" }, subpath: "packages/app" })
|
||||
})
|
||||
|
||||
test("loads session content through the server client", async () => {
|
||||
const ctx = setup({ root: session("root") })
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ type MessageApi = ServerApi["message"]
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
const initialMessagePageSize = 20
|
||||
const historyMessagePageSize = 200
|
||||
const historyMessagePageSize = 50
|
||||
const sessionInfoLimit = 2_048
|
||||
const emptyIDs: ReadonlySet<string> = new Set()
|
||||
|
||||
@@ -45,6 +45,12 @@ function projectMessageSource(message: Message): SessionMessageInfo[] {
|
||||
]
|
||||
}
|
||||
|
||||
function yieldToMain() {
|
||||
const scheduler = (globalThis as { scheduler?: { yield: () => Promise<void> } }).scheduler
|
||||
if (scheduler) return scheduler.yield()
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
|
||||
const boundary = source.find(
|
||||
(message) =>
|
||||
@@ -241,7 +247,13 @@ export function createServerSession(
|
||||
const indexProjectedMessage = (message: Message) => {
|
||||
const current = data.session_message[message.sessionID] ?? []
|
||||
if (current.some((item) => item.id === message.id)) return
|
||||
setData("session_message", message.sessionID, reconcile([...current, ...projectMessageSource(message)]))
|
||||
const projected = projectMessageSource(message)
|
||||
const projectedIDs = new Set(projected.map((item) => item.id))
|
||||
setData(
|
||||
"session_message",
|
||||
message.sessionID,
|
||||
reconcile([...current.filter((item) => !projectedIDs.has(item.id)), ...projected]),
|
||||
)
|
||||
}
|
||||
|
||||
const remember = (session: SessionInfo) => {
|
||||
@@ -288,17 +300,19 @@ export function createServerSession(
|
||||
return session
|
||||
}
|
||||
|
||||
const resolve = (sessionID: string, options?: { force?: boolean }) => {
|
||||
const resolve = (sessionID: string, options?: { force?: boolean; signal?: AbortSignal }) => {
|
||||
const cached = data.info[sessionID]
|
||||
if (cached && !options?.force) return Promise.resolve(cached)
|
||||
const pending = requests.get(sessionID)
|
||||
const pending = options?.signal ? undefined : requests.get(sessionID)
|
||||
if (pending) return pending
|
||||
const active = generation(sessionID)
|
||||
const request = sessionApi.get({ sessionID })
|
||||
const request = sessionApi.get({ sessionID }, { signal: options?.signal })
|
||||
const resolved = request.then((result) => {
|
||||
if (options?.signal?.aborted) return result
|
||||
if (generations.get(sessionID) !== active) return result
|
||||
return remember(result)
|
||||
})
|
||||
if (options?.signal) return resolved
|
||||
requests.set(sessionID, resolved)
|
||||
const cleanup = () => {
|
||||
if (requests.get(sessionID) === resolved) requests.delete(sessionID)
|
||||
@@ -530,6 +544,7 @@ export function createServerSession(
|
||||
if (!response.data.length) break
|
||||
}
|
||||
const response = pages.at(-1)!
|
||||
await yieldToMain()
|
||||
const source = pages.flatMap((page) => page.data).toReversed()
|
||||
const normalized = normalizeSessionMessages(sessionID, source)
|
||||
return {
|
||||
@@ -1300,6 +1315,7 @@ export function createServerSession(
|
||||
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
|
||||
if (!items)
|
||||
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
|
||||
indexProjectedMessage(input.message)
|
||||
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]).sort(compareMessages))
|
||||
setData(
|
||||
"part_text_accum_delta",
|
||||
@@ -1333,6 +1349,9 @@ export function createServerSession(
|
||||
)
|
||||
return
|
||||
}
|
||||
setData("session_message", input.sessionID, (messages) =>
|
||||
messages?.filter((message) => message.id !== input.messageID),
|
||||
)
|
||||
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
|
||||
setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
|
||||
},
|
||||
|
||||
@@ -5,13 +5,21 @@ import type {
|
||||
SessionApi,
|
||||
SessionInfo,
|
||||
SessionListInput,
|
||||
OpenCodeEvent,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
||||
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
|
||||
import { loadActiveSessionsQuery, loadMcpQuery, loadMcpResourcesQuery, seedActiveSessionStatuses } from "./server-sync"
|
||||
import {
|
||||
captureSessionMove,
|
||||
loadActiveSessionsQuery,
|
||||
loadMcpQuery,
|
||||
loadMcpResourcesQuery,
|
||||
seedActiveSessionStatuses,
|
||||
} from "./server-sync"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { createServerSession } from "./server-session"
|
||||
import { adaptServerEvent } from "./server-sdk"
|
||||
import type { ServerApi } from "@/utils/server"
|
||||
|
||||
type McpApi = ServerApi["mcp"]
|
||||
@@ -101,6 +109,30 @@ describe("active session query", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("session move normalization", () => {
|
||||
test("captures and applies current moves from the source placement", () => {
|
||||
const session = createServerSession({} as ServerApi["session"], {} as ServerApi["message"])
|
||||
session.remember(sessionAt("/source"))
|
||||
const current = {
|
||||
id: "event-current-move",
|
||||
created: 10,
|
||||
type: "session.moved",
|
||||
durable: { aggregateID: "session", seq: 1, version: 1 },
|
||||
location: { directory: "/source" },
|
||||
data: { sessionID: "session", location: { directory: "/destination" } },
|
||||
} satisfies Extract<OpenCodeEvent, { type: "session.moved" }>
|
||||
const event = adaptServerEvent(current)
|
||||
|
||||
expect(captureSessionMove(event, session.get)).toEqual({
|
||||
sessionID: "session",
|
||||
from: "/source",
|
||||
})
|
||||
session.applyV2(current)
|
||||
session.apply(event)
|
||||
expect(session.get("session")?.location.directory).toBe("/destination")
|
||||
})
|
||||
})
|
||||
|
||||
describe("pickDirectoriesToEvict", () => {
|
||||
test("keeps pinned stores and evicts idle stores", () => {
|
||||
const now = 5_000
|
||||
@@ -171,6 +203,18 @@ function sessionInfo(id: string) {
|
||||
} as SessionInfo
|
||||
}
|
||||
|
||||
function sessionAt(directory: string): SessionInfo {
|
||||
return {
|
||||
id: "session",
|
||||
projectID: "project",
|
||||
location: { directory },
|
||||
title: "Session",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 1 },
|
||||
}
|
||||
}
|
||||
|
||||
describe("estimateRootSessionTotal", () => {
|
||||
test("keeps exact total for full fetches", () => {
|
||||
expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import type { InitError } from "../pages/error"
|
||||
import { ServerSDK } from "./server-sdk"
|
||||
import { ServerSDK, type ServerEvent } from "./server-sdk"
|
||||
import {
|
||||
bootstrapDirectory,
|
||||
bootstrapGlobal,
|
||||
@@ -55,6 +55,28 @@ import { toggleMcp } from "./global-sync/mcp"
|
||||
import { createServerSession, type ServerSession } from "./server-session"
|
||||
import { usePlatform } from "./platform"
|
||||
|
||||
export function captureSessionMove(
|
||||
event: ServerEvent,
|
||||
get: (sessionID: string) => { location: { directory: string } } | undefined,
|
||||
) {
|
||||
if (event.current?.type !== "session.moved") return
|
||||
return {
|
||||
sessionID: event.current.data.sessionID,
|
||||
from: get(event.current.data.sessionID)?.location.directory,
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldRefreshWorkspaceSessions(event: ServerEvent) {
|
||||
const type = event.current?.type ?? event.type
|
||||
return (
|
||||
type === "session.created" ||
|
||||
type === "session.deleted" ||
|
||||
type === "session.moved" ||
|
||||
type === "session.renamed" ||
|
||||
type === "session.forked"
|
||||
)
|
||||
}
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
error?: InitError
|
||||
@@ -458,15 +480,51 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
})
|
||||
}
|
||||
|
||||
const reindexSession = (sessionID: string, from?: string) => {
|
||||
const next = session.get(sessionID)
|
||||
if (!next) return
|
||||
indexSession(next)
|
||||
if (!from) return
|
||||
const source = children.children[directoryKey(from)]
|
||||
if (!source) return
|
||||
applyDirectoryEvent({
|
||||
event: {
|
||||
type: "session.moved",
|
||||
properties: {
|
||||
sessionID,
|
||||
projectID: next.projectID,
|
||||
location: next.location,
|
||||
subpath: next.subpath,
|
||||
},
|
||||
},
|
||||
directory: from,
|
||||
store: source[0],
|
||||
setStore: source[1],
|
||||
push: queue.push,
|
||||
retainedLimit: sessionMeta.get(directoryKey(from))?.limit,
|
||||
sessionContent: false,
|
||||
permission: session.data.permission,
|
||||
loadLsp() {},
|
||||
})
|
||||
}
|
||||
|
||||
const unsub = serverSDK.event.listen((e) => {
|
||||
const directory = e.name
|
||||
const key = directoryKey(directory)
|
||||
const event = e.details
|
||||
const eventType: string = event.type
|
||||
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
||||
const moved = captureSessionMove(event, session.get)
|
||||
|
||||
if (event.current) session.applyV2(event.current)
|
||||
session.apply(event)
|
||||
if (moved) reindexSession(moved.sessionID, moved.from)
|
||||
if (shouldRefreshWorkspaceSessions(event)) {
|
||||
void queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "settings-workspace-sessions",
|
||||
})
|
||||
}
|
||||
if (event.current?.type === "session.created")
|
||||
void session
|
||||
.resolve(event.current.data.sessionID, { force: true })
|
||||
@@ -519,10 +577,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.current?.type === "session.moved") {
|
||||
const info = session.get(event.current.data.sessionID)
|
||||
if (info) indexSession(info)
|
||||
}
|
||||
if (event.current?.type === "session.forked")
|
||||
void session
|
||||
.resolve(event.current.data.sessionID, { force: true })
|
||||
@@ -639,6 +693,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
updateConfig: updateConfigMutation.mutateAsync,
|
||||
project: projectApi,
|
||||
session,
|
||||
reindexSession,
|
||||
homeSessions,
|
||||
mcp: {
|
||||
toggle: async (directory: string, name: string) => {
|
||||
|
||||
@@ -2,6 +2,10 @@ import { createStore, reconcile } from "solid-js/store"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { persisted } from "@/utils/persist"
|
||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||
|
||||
export type WorkspaceDefaultDestination = "last-used" | "local" | "new"
|
||||
export type WorkspaceLastUsed = "local" | "workspace"
|
||||
|
||||
export interface NotificationSettings {
|
||||
agent: boolean
|
||||
@@ -44,6 +48,10 @@ export interface Settings {
|
||||
permissions: {
|
||||
autoApprove: boolean
|
||||
}
|
||||
workspaces: {
|
||||
defaultDestination: WorkspaceDefaultDestination
|
||||
lastUsed: Record<string, WorkspaceLastUsed>
|
||||
}
|
||||
notifications: NotificationSettings
|
||||
sounds: SoundSettings
|
||||
}
|
||||
@@ -126,6 +134,10 @@ const defaultSettings: Settings = {
|
||||
permissions: {
|
||||
autoApprove: false,
|
||||
},
|
||||
workspaces: {
|
||||
defaultDestination: "last-used",
|
||||
lastUsed: {},
|
||||
},
|
||||
notifications: {
|
||||
agent: true,
|
||||
permissions: true,
|
||||
@@ -291,6 +303,29 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setStore("permissions", "autoApprove", value)
|
||||
},
|
||||
},
|
||||
workspaces: {
|
||||
defaultDestination: withFallback(
|
||||
() => store.workspaces?.defaultDestination,
|
||||
defaultSettings.workspaces.defaultDestination,
|
||||
),
|
||||
setDefaultDestination(value: WorkspaceDefaultDestination) {
|
||||
setStore("workspaces", (current) => ({
|
||||
...defaultSettings.workspaces,
|
||||
...current,
|
||||
defaultDestination: value,
|
||||
}))
|
||||
},
|
||||
lastUsed(scope: ServerScope, projectID: string) {
|
||||
return store.workspaces?.lastUsed?.[ScopedKey.from(scope, projectID)]
|
||||
},
|
||||
setLastUsed(scope: ServerScope, projectID: string, value: WorkspaceLastUsed) {
|
||||
setStore("workspaces", (current) => ({
|
||||
...defaultSettings.workspaces,
|
||||
...current,
|
||||
lastUsed: { ...current?.lastUsed, [ScopedKey.from(scope, projectID)]: value },
|
||||
}))
|
||||
},
|
||||
},
|
||||
notifications: {
|
||||
agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent),
|
||||
setAgent(value: boolean) {
|
||||
|
||||
@@ -177,6 +177,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
const actions = {
|
||||
active() {
|
||||
if (location.pathname === "/") return
|
||||
const key = recentKey()
|
||||
return store.find((tab) => tabKey(tab) === key)
|
||||
},
|
||||
addSessionTab: (tab: Omit<SessionTab, "type">) => {
|
||||
const next = { type: "session" as const, ...tab }
|
||||
const existing = store.find((item) => tabKey(item) === tabKey(next))
|
||||
@@ -351,8 +356,8 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
const key = tabKey(tab)
|
||||
const next = { title: session.title, directory: session.location.directory }
|
||||
const current = info[key]
|
||||
console.log({ tab, session, current })
|
||||
if (current?.title === next.title && current.directory === next.directory) return
|
||||
if (current && current.title === next.title && current.directory === next.directory) return
|
||||
console.debug("[tabs] update persisted session info", { key, sessionID: session.id, current, next })
|
||||
setInfo(key, next)
|
||||
},
|
||||
select: navigateTab,
|
||||
|
||||
@@ -892,14 +892,46 @@ export const dict = {
|
||||
"settings.section.desktop": "Desktop",
|
||||
"settings.section.server": "Server",
|
||||
"settings.tab.general": "General",
|
||||
"settings.tab.preferences": "Preferences",
|
||||
"settings.tab.shortcuts": "Shortcuts",
|
||||
"settings.tab.notifications": "Notifications",
|
||||
"settings.tab.projects": "Projects",
|
||||
"settings.tab.extensions": "Extensions",
|
||||
"settings.preferences.description": "Customize preferences and theme and default behavior",
|
||||
"settings.appearance.description": "Customize theme and fonts",
|
||||
"settings.notifications.description": "Choose when to receive notifications and hear sounds",
|
||||
"settings.shortcuts.description": "Customize shortcuts for common actions",
|
||||
"settings.servers.description": "Manage server connections",
|
||||
"settings.projects.title": "Projects",
|
||||
"settings.projects.description": "Manage project settings on this server",
|
||||
"settings.projects.empty": "No projects found",
|
||||
"settings.projects.server.all": "All servers",
|
||||
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||
"settings.extensions.description": "Manage extensions available on this server",
|
||||
"settings.extensions.tab.mcps": "MCPs",
|
||||
"settings.extensions.tab.skills": "Skills",
|
||||
"settings.extensions.availableAll": "Available to all projects",
|
||||
"settings.extensions.manageConfig": "Manage in opencode.json",
|
||||
"settings.extensions.addSkills": "How to add skills",
|
||||
"settings.desktop.section.wsl": "WSL",
|
||||
"settings.desktop.wsl.title": "WSL integration",
|
||||
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
|
||||
"dialog.server.authenticate.title": "Authenticate",
|
||||
"project.settings.general.description": "Manage project name and appearance",
|
||||
"project.settings.scripts": "Scripts",
|
||||
"project.settings.scripts.description": "Configure scripts for this project",
|
||||
"project.settings.extensions.description": "View extensions available to this project",
|
||||
"project.settings.extensions.tab.lsps": "LSPs",
|
||||
"project.settings.extensions.added": "Added to this project",
|
||||
"project.settings.extensions.shared": "Shared with all projects",
|
||||
"project.settings.extensions.lsp.detected": "Detected language servers",
|
||||
"project.settings.extensions.lsp.description": "Auto-detected from file types",
|
||||
"project.settings.extensions.setupRequired": "Setup required",
|
||||
|
||||
"settings.general.section.appearance": "Appearance",
|
||||
"settings.general.section.general": "General",
|
||||
"settings.general.section.advanced": "Advanced",
|
||||
"settings.general.section.notifications": "System notifications",
|
||||
"settings.general.section.notifications": "Desktop notifications",
|
||||
"settings.general.section.updates": "Updates",
|
||||
"settings.general.section.sounds": "Sound effects",
|
||||
"settings.general.section.feed": "Feed",
|
||||
@@ -1060,7 +1092,7 @@ export const dict = {
|
||||
"settings.shortcuts.group.prompt": "Prompt",
|
||||
|
||||
"settings.providers.title": "Providers",
|
||||
"settings.providers.description": "Provider settings will be configurable here.",
|
||||
"settings.providers.description": "Connect and manage model providers",
|
||||
"settings.providers.section.connected": "Connected providers",
|
||||
"settings.providers.connected.empty": "No connected providers",
|
||||
"settings.providers.connected.environmentDescription": "Connected from your environment variables",
|
||||
@@ -1071,7 +1103,7 @@ export const dict = {
|
||||
"settings.providers.tag.custom": "Custom",
|
||||
"settings.providers.tag.other": "Other",
|
||||
"settings.models.title": "Models",
|
||||
"settings.models.description": "Model settings will be configurable here.",
|
||||
"settings.models.description": "Choose which models appear in model picker",
|
||||
"settings.agents.title": "Agents",
|
||||
"settings.agents.description": "Agent settings will be configurable here.",
|
||||
"settings.commands.title": "Commands",
|
||||
@@ -1123,6 +1155,46 @@ export const dict = {
|
||||
"session.delete.button": "Delete session",
|
||||
|
||||
"workspace.new": "New workspace",
|
||||
"common.viewAll": "View all",
|
||||
"session.new.workspace.local.tooltip": "Use current checkout",
|
||||
"session.new.workspace.new.tooltip": "Create isolated checkout",
|
||||
"session.new.workspace.fromBranch": "from {{branch}}",
|
||||
"session.new.workspace.trigger.tooltip": "Select where to run session",
|
||||
"session.new.workspace.search.placeholder": "Search workspaces",
|
||||
"settings.tab.workspaces": "Workspaces",
|
||||
"settings.workspaces.filter.all": "All projects",
|
||||
"settings.workspaces.empty": "No workspaces",
|
||||
"settings.workspaces.count.one": "{{count}} workspace",
|
||||
"settings.workspaces.count.other": "{{count}} workspaces",
|
||||
"settings.workspaces.sessions.one": "{{count}} session in {{project}}",
|
||||
"settings.workspaces.sessions.other": "{{count}} sessions in {{project}}",
|
||||
"settings.workspaces.lastActiveSession": "Last active session",
|
||||
"settings.workspaces.deleteAll": "Delete all workspaces",
|
||||
"settings.workspaces.deleteAll.confirm": "Delete all {{count}} workspaces?",
|
||||
"settings.workspaces.delete.warning":
|
||||
"The workspace directory and branch will be permanently removed. Deletion proceeds only if it is clean, inactive, and has no linked sessions.",
|
||||
"settings.workspaces.deleteAll.warning":
|
||||
"The {{count}} selected workspaces in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.",
|
||||
"settings.workspaces.delete.blocked.active": "The active workspace cannot be deleted.",
|
||||
"settings.workspaces.delete.blocked.linked": "This workspace has linked sessions and cannot be deleted.",
|
||||
"settings.workspaces.default.title": "Default environment",
|
||||
"settings.workspaces.default.description": "Choose where new sessions start",
|
||||
"settings.workspaces.default.lastUsed": "Last used per project",
|
||||
"settings.workspaces.default.local": "Local directory",
|
||||
"settings.workspaces.default.new": "New workspace",
|
||||
"workspace.move.title": "Move to workspace",
|
||||
"workspace.move.menu.title": "Move session to",
|
||||
"workspace.move.failed": "Failed to move session",
|
||||
"workspace.lifecycle.creating": "Creating workspace",
|
||||
"workspace.lifecycle.created": "Workspace created",
|
||||
"workspace.lifecycle.starting": "Starting session",
|
||||
"workspace.onboarding.title": "Isolate sessions with workspaces",
|
||||
"workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
|
||||
"workspace.lifecycle.moving": "Moving to workspace",
|
||||
"workspace.lifecycle.set": "Workspace set",
|
||||
"session.summary.title": "Session details",
|
||||
"session.summary.noBranch": "No branch",
|
||||
"session.summary.basedOn": "Based on {{branch}}",
|
||||
"workspace.type.local": "local",
|
||||
"workspace.type.sandbox": "sandbox",
|
||||
"workspace.create.failed.title": "Failed to create workspace",
|
||||
|
||||
@@ -65,7 +65,7 @@ const appLocales = [
|
||||
"uz",
|
||||
] as const
|
||||
const desktopLocales = appLocales
|
||||
const pluralCategories = new Map(
|
||||
const pluralCategories = new Map<string, string[]>(
|
||||
appLocales.map(
|
||||
(locale) =>
|
||||
[
|
||||
@@ -74,6 +74,53 @@ const pluralCategories = new Map(
|
||||
] as const,
|
||||
),
|
||||
)
|
||||
const appFallbackKeys = new Set([
|
||||
"dialog.provider.custom.label",
|
||||
"dialog.model.unpaid.viewMoreProviders",
|
||||
"session.header.reveal.finder",
|
||||
"session.header.reveal.fileExplorer",
|
||||
"session.header.reveal.containingFolder",
|
||||
"command.session.export",
|
||||
"command.session.export.description",
|
||||
"context.export.session",
|
||||
"toast.session.export.success.title",
|
||||
"toast.session.export.success.description",
|
||||
"toast.session.export.failed.title",
|
||||
"toast.session.export.failed.description",
|
||||
"common.export",
|
||||
"settings.tab.preferences",
|
||||
"settings.tab.notifications",
|
||||
"settings.tab.projects",
|
||||
"settings.tab.extensions",
|
||||
"settings.preferences.description",
|
||||
"settings.appearance.description",
|
||||
"settings.notifications.description",
|
||||
"settings.shortcuts.description",
|
||||
"settings.servers.description",
|
||||
"settings.projects.title",
|
||||
"settings.projects.description",
|
||||
"settings.projects.empty",
|
||||
"settings.projects.server.all",
|
||||
"settings.mcps.description",
|
||||
"settings.extensions.description",
|
||||
"settings.extensions.tab.mcps",
|
||||
"settings.extensions.tab.skills",
|
||||
"settings.extensions.availableAll",
|
||||
"settings.extensions.manageConfig",
|
||||
"settings.extensions.addSkills",
|
||||
"settings.general.section.general",
|
||||
"dialog.server.authenticate.title",
|
||||
"project.settings.general.description",
|
||||
"project.settings.scripts",
|
||||
"project.settings.scripts.description",
|
||||
"project.settings.extensions.description",
|
||||
"project.settings.extensions.tab.lsps",
|
||||
"project.settings.extensions.added",
|
||||
"project.settings.extensions.shared",
|
||||
"project.settings.extensions.lsp.detected",
|
||||
"project.settings.extensions.lsp.description",
|
||||
"project.settings.extensions.setupRequired",
|
||||
])
|
||||
|
||||
const domains = [
|
||||
{
|
||||
@@ -97,23 +144,22 @@ const domains = [
|
||||
] as const
|
||||
|
||||
describe("i18n parity", () => {
|
||||
test("non-English locales have every English key and required plural variants", async () => {
|
||||
test("non-English locales contain only English keys and their plural variants", async () => {
|
||||
for (const domain of domains) {
|
||||
const source = await dictionary(domain.source)
|
||||
const families = new Set(pluralFamilies(source))
|
||||
for (const locale of domain.locales) {
|
||||
const target = await dictionary(domain.target(locale))
|
||||
const missing = Object.keys(source).filter((key) => !Object.hasOwn(target, key))
|
||||
const missing = Object.keys(source).filter(
|
||||
(key) => !Object.hasOwn(target, key) && (domain.name !== "app" || !appFallbackKeys.has(key)),
|
||||
)
|
||||
const extra = Object.keys(target)
|
||||
.filter((key) => !Object.hasOwn(source, key))
|
||||
.filter((key) => !Object.hasOwn(source, key) && !isPluralVariant(key, families))
|
||||
.sort()
|
||||
const expected = pluralFamilies(source)
|
||||
.flatMap((key) => (pluralCategories.get(locale) ?? []).map((category) => `${key}.${category}`))
|
||||
.sort()
|
||||
expect({ domain: domain.name, locale, missing, extra }).toEqual({
|
||||
expect({ domain: domain.name, locale, extra }).toEqual({
|
||||
domain: domain.name,
|
||||
locale,
|
||||
missing: [],
|
||||
extra: expected,
|
||||
extra: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -127,11 +173,11 @@ describe("i18n parity", () => {
|
||||
const mismatched = Object.keys(source).filter(
|
||||
(key) => Object.hasOwn(target, key) && placeholders(source[key]).join() !== placeholders(target[key]).join(),
|
||||
)
|
||||
const pluralMismatched = pluralFamilies(source).flatMap((key) =>
|
||||
(pluralCategories.get(locale) ?? [])
|
||||
.map((category) => `${key}.${category}`)
|
||||
.filter((variant) => placeholders(source[`${key}.other`]).join() !== placeholders(target[variant]).join()),
|
||||
)
|
||||
const pluralMismatched = Object.keys(target).filter((key) => {
|
||||
const family = pluralFamily(key)
|
||||
if (!family || !Object.hasOwn(source, `${family}.other`)) return false
|
||||
return placeholders(source[`${family}.other`]).join() !== placeholders(target[key]).join()
|
||||
})
|
||||
expect({ domain: domain.name, locale, mismatched, pluralMismatched }).toEqual({
|
||||
domain: domain.name,
|
||||
locale,
|
||||
@@ -169,38 +215,6 @@ describe("i18n parity", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("i18n plural parity", () => {
|
||||
test("locale-specific categories exist and preserve count placeholders", async () => {
|
||||
for (const domain of domains.slice(0, 2)) {
|
||||
const source = await dictionary(domain.source)
|
||||
const families = pluralFamilies(source)
|
||||
for (const locale of domain.locales) {
|
||||
const target = await dictionary(domain.target(locale))
|
||||
const missing = families.flatMap((key) =>
|
||||
(pluralCategories.get(locale) ?? [])
|
||||
.map((category) => `${key}.${category}`)
|
||||
.filter((variant) => !Object.hasOwn(target, variant)),
|
||||
)
|
||||
const mismatched = families.flatMap((key) =>
|
||||
(pluralCategories.get(locale) ?? [])
|
||||
.map((category) => `${key}.${category}`)
|
||||
.filter(
|
||||
(variant) =>
|
||||
Object.hasOwn(target, variant) &&
|
||||
placeholders(source[`${key}.other`]).join() !== placeholders(target[variant]).join(),
|
||||
),
|
||||
)
|
||||
expect({ domain: domain.name, locale, missing, mismatched }).toEqual({
|
||||
domain: domain.name,
|
||||
locale,
|
||||
missing: [],
|
||||
mismatched: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
async function dictionary(file: string) {
|
||||
const module: unknown = await import(file)
|
||||
if (typeof module !== "object" || module === null || !("dict" in module) || !isDictionary(module.dict)) {
|
||||
@@ -220,11 +234,17 @@ function placeholders(value: string) {
|
||||
|
||||
function pluralFamilies(dictionary: Record<string, string>) {
|
||||
return Object.keys(dictionary)
|
||||
.filter(
|
||||
(key) =>
|
||||
key.endsWith(".one") &&
|
||||
dictionary[key].includes("{{count}}") &&
|
||||
dictionary[`${key.slice(0, -4)}.other`]?.includes("{{count}}"),
|
||||
)
|
||||
.filter((key) => key.endsWith(".one") && Object.hasOwn(dictionary, `${key.slice(0, -4)}.other`))
|
||||
.map((key) => key.slice(0, -4))
|
||||
}
|
||||
|
||||
function pluralFamily(key: string) {
|
||||
const split = key.lastIndexOf(".")
|
||||
if (split === -1 || !pluralCategories.has(key.slice(split + 1))) return
|
||||
return key.slice(0, split)
|
||||
}
|
||||
|
||||
function isPluralVariant(key: string, families: Set<string>) {
|
||||
const family = pluralFamily(key)
|
||||
return family !== undefined && families.has(family)
|
||||
}
|
||||
|
||||
@@ -327,4 +327,9 @@
|
||||
animation-range: 0 0.1px;
|
||||
}
|
||||
}
|
||||
|
||||
body[data-new-layout] [data-slot="session-turn-diffs-header"] {
|
||||
height: 24px;
|
||||
padding-block: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,3 +28,4 @@ export {
|
||||
} from "./wsl/types"
|
||||
export { ServerConnection } from "./context/server"
|
||||
export { createDraftStore, type DraftStore } from "./utils/draft-store"
|
||||
export { preloadSessionRoute } from "./pages/session-lazy"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { createHomeController } from "./home/home-controller"
|
||||
import { createHomeProjectsController } from "./home/home-projects-controller"
|
||||
import { HomeUtilityNav } from "./home/home-projects-view"
|
||||
@@ -7,8 +8,23 @@ import { createHomeScrollController } from "./home/home-scroll-controller"
|
||||
import { createHomeSessionSearchController } from "./home/home-session-search-controller"
|
||||
import { createHomeSessionsController } from "./home/home-sessions-controller"
|
||||
import { HomeSessions } from "./home/home-sessions"
|
||||
import { preloadSessionRoute } from "./session-lazy"
|
||||
|
||||
export function Home() {
|
||||
onMount(() => {
|
||||
let idle: number | undefined
|
||||
const timer = setTimeout(() => {
|
||||
if ("requestIdleCallback" in window) {
|
||||
idle = requestIdleCallback(() => void preloadSessionRoute(), { timeout: 3_000 })
|
||||
return
|
||||
}
|
||||
void preloadSessionRoute()
|
||||
}, 1_500)
|
||||
onCleanup(() => {
|
||||
clearTimeout(timer)
|
||||
if (idle !== undefined) cancelIdleCallback(idle)
|
||||
})
|
||||
})
|
||||
const home = createHomeController()
|
||||
const projects = createHomeProjectsController(home)
|
||||
const sessions = createHomeSessionsController(home)
|
||||
|
||||
@@ -19,6 +19,7 @@ import { compareSessionTime, displayName, errorMessage, projectForSession } from
|
||||
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { archiveHomeSession } from "../home-session-archive"
|
||||
import type { HomeController } from "./home-controller"
|
||||
@@ -208,6 +209,7 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
const conn = home.server.focused()
|
||||
const ctx = home.server.focusedContext()
|
||||
if (!conn || !ctx) return
|
||||
if (WorkspaceOperation.get(ctx.sdk.scope, session.id)?.status === "pending") return
|
||||
const [, setStore] = ctx.sync.child(session.location.directory)
|
||||
await archiveHomeSession({
|
||||
server: ServerConnection.key(conn),
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { createPromptProjectController } from "@/components/prompt-project-selector"
|
||||
import { useSettingsDialog } from "@/components/settings-dialog"
|
||||
import { useTitlebarRightMount } from "@/components/titlebar"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { createEffect, createResource } from "solid-js"
|
||||
import { useTabs, type DraftTab } from "@/context/tabs"
|
||||
import { useSearchParams } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createResource } from "solid-js"
|
||||
import { createNewSessionDraftController } from "./new-session/new-session-draft-controller"
|
||||
import { NewSessionStatus, NewSessionView } from "./new-session/new-session-view"
|
||||
import { createNewSessionWorkspaceController } from "./new-session/new-session-workspace-controller"
|
||||
@@ -11,10 +14,23 @@ import { useNewSessionCommands } from "./new-session/use-new-session-commands"
|
||||
export default function NewSessionPage() {
|
||||
const settings = useSettings()
|
||||
const rightMount = useTitlebarRightMount()
|
||||
const workspace = createNewSessionWorkspaceController()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const tabs = useTabs()
|
||||
const openWorkspaces = useSettingsDialog("workspaces")
|
||||
const draftTab = createMemo(() =>
|
||||
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId),
|
||||
)
|
||||
const workspace = createNewSessionWorkspaceController({
|
||||
selected: () => draftTab()?.worktree,
|
||||
setSelected: (worktree) => {
|
||||
if (search.draftId) tabs.updateDraft(search.draftId, { worktree })
|
||||
},
|
||||
onViewAll: openWorkspaces,
|
||||
})
|
||||
const draft = createNewSessionDraftController({
|
||||
worktree: workspace.selection.value,
|
||||
resetWorktree: workspace.selection.reset,
|
||||
onSubmit: workspace.selection.remember,
|
||||
})
|
||||
const project = createPromptProjectController({
|
||||
controls: draft.project.controls,
|
||||
|
||||
@@ -10,7 +10,11 @@ import { createPromptModelSelection } from "@/pages/session/composer/prompt-mode
|
||||
import { useSessionKey } from "@/pages/session/session-layout"
|
||||
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
||||
|
||||
export function createNewSessionDraftController(workspace: { worktree: () => string; resetWorktree: () => void }) {
|
||||
export function createNewSessionDraftController(workspace: {
|
||||
worktree: () => string
|
||||
resetWorktree: () => void
|
||||
onSubmit: () => void
|
||||
}) {
|
||||
const prompt = usePrompt()
|
||||
const serverSync = useServerSync()
|
||||
const comments = useComments()
|
||||
@@ -36,7 +40,10 @@ export function createNewSessionDraftController(workspace: { worktree: () => str
|
||||
return workspace.worktree()
|
||||
},
|
||||
onNewSessionWorktreeReset: workspace.resetWorktree,
|
||||
onSubmit: comments.clear,
|
||||
onSubmit: () => {
|
||||
workspace.onSubmit()
|
||||
comments.clear()
|
||||
},
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2"
|
||||
import { Show, createMemo, createSignal, type Accessor } from "solid-js"
|
||||
@@ -31,6 +31,15 @@ export function NewSessionView(props: {
|
||||
project: PromptProjectController
|
||||
workspace: NewSessionWorkspaceController
|
||||
}) {
|
||||
const [onboarding, setOnboarding, , onboardingReady] = persisted(
|
||||
Persist.global("workspace-onboarding"),
|
||||
createStore({ used: false }),
|
||||
)
|
||||
const select = (value: string) => {
|
||||
props.workspace.selection.set(value)
|
||||
if (value !== "main") setOnboarding("used", true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
|
||||
<div
|
||||
@@ -41,7 +50,7 @@ export function NewSessionView(props: {
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
|
||||
<div class="mt-8 flex flex-col gap-8">
|
||||
<PromptInputV2Composer controller={props.input} />
|
||||
<PromptInputV2Composer controller={props.input} accentSubmit={props.workspace.selection.workspace()} />
|
||||
<Show when={props.project.empty()}>
|
||||
<PromptProjectAddButton controller={props.project} />
|
||||
</Show>
|
||||
@@ -59,8 +68,10 @@ export function NewSessionView(props: {
|
||||
projectRoot={props.workspace.project.root()}
|
||||
workspaces={props.workspace.project.workspaces()}
|
||||
branch={props.workspace.bar.branch()}
|
||||
onChange={props.workspace.selection.set}
|
||||
onboarding={onboardingReady() && !onboarding.used}
|
||||
onChange={select}
|
||||
onDone={props.input.restoreFocus}
|
||||
onViewAll={props.workspace.project.openAll}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -137,7 +148,7 @@ function ProviderTip() {
|
||||
>
|
||||
<span class="truncate">{language.t("home.providerTip")}</span>
|
||||
<span class="flex size-6 shrink-0 items-center justify-center" aria-hidden="true">
|
||||
<IconV2 name="chevron-down" size="small" class="-rotate-90" />
|
||||
<Icon name="chevron-down" size="small" class="-rotate-90" />
|
||||
</span>
|
||||
</button>
|
||||
<TooltipV2
|
||||
@@ -152,7 +163,7 @@ function ProviderTip() {
|
||||
aria-label={language.t("common.dismiss")}
|
||||
onClick={() => setPersistedState("dismissedAt", Date.now())}
|
||||
>
|
||||
<IconV2 name="xmark-small" />
|
||||
<Icon name="xmark-small" />
|
||||
</button>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { createMemo } from "solid-js"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSync } from "@/context/sync"
|
||||
|
||||
const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import {
|
||||
isWorkspaceDirectory,
|
||||
isWorkspaceSelection,
|
||||
workspaceDefaultSelection,
|
||||
workspaceDirectories,
|
||||
} from "@/utils/workspace"
|
||||
|
||||
export function resolveNewSessionWorktree(input: {
|
||||
enabled: boolean
|
||||
selected?: string
|
||||
directory: string
|
||||
projectWorktree?: string
|
||||
fallback?: string
|
||||
}) {
|
||||
if (!input.enabled) return "main"
|
||||
if (input.selected) return input.selected
|
||||
if (input.projectWorktree && input.directory !== input.projectWorktree) return input.directory
|
||||
return "main"
|
||||
return input.fallback ?? "main"
|
||||
}
|
||||
|
||||
export function normalizeNewSessionWorktree(value: string, directory: string, projectWorktree?: string) {
|
||||
@@ -31,18 +39,38 @@ export function resolveNewSessionBranch(input: {
|
||||
return input.worktreeBranch(input.worktree) ?? input.local
|
||||
}
|
||||
|
||||
export function createNewSessionWorkspaceController() {
|
||||
export function createNewSessionWorkspaceController(input: {
|
||||
selected: () => string | undefined
|
||||
setSelected: (worktree: string | undefined) => void
|
||||
onViewAll: () => void
|
||||
}) {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const [worktree, setWorktree] = createSignal<string>()
|
||||
const visible = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git")
|
||||
const settings = useSettings()
|
||||
const visible = createMemo(() => sync().project?.vcs === "git")
|
||||
const selected = createMemo(() => {
|
||||
const project = sync().project
|
||||
const worktree = input.selected()
|
||||
if (!project || !worktree) return
|
||||
return isWorkspaceSelection(project, worktree) ? worktree : undefined
|
||||
})
|
||||
const fallback = createMemo(() => {
|
||||
const project = sync().project
|
||||
if (!project) return "main"
|
||||
return workspaceDefaultSelection(
|
||||
settings.workspaces.defaultDestination(),
|
||||
settings.workspaces.lastUsed(serverSDK().scope, project.id),
|
||||
)
|
||||
})
|
||||
const value = createMemo(() =>
|
||||
resolveNewSessionWorktree({
|
||||
enabled: visible(),
|
||||
selected: worktree(),
|
||||
selected: selected(),
|
||||
directory: sdk().directory,
|
||||
projectWorktree: sync().project?.worktree,
|
||||
fallback: fallback(),
|
||||
}),
|
||||
)
|
||||
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
|
||||
@@ -54,18 +82,36 @@ export function createNewSessionWorkspaceController() {
|
||||
worktreeBranch: (worktree) => serverSync().child(worktree)[0].vcs?.branch,
|
||||
}),
|
||||
)
|
||||
const remember = (worktree = value()) => {
|
||||
const project = sync().project
|
||||
if (!project) return
|
||||
const local = worktree === "main" || pathKey(worktree) === pathKey(project.worktree)
|
||||
settings.workspaces.setLastUsed(serverSDK().scope, project.id, local ? "local" : "workspace")
|
||||
}
|
||||
|
||||
return {
|
||||
selection: {
|
||||
value,
|
||||
reset: () => setWorktree(),
|
||||
set: (worktree: string) =>
|
||||
setWorktree(normalizeNewSessionWorktree(worktree, sdk().directory, sync().project?.worktree)),
|
||||
workspace: createMemo(() => {
|
||||
const project = sync().project
|
||||
const current = value()
|
||||
return current === "create" || (!!project && isWorkspaceDirectory(project, current))
|
||||
}),
|
||||
reset: () => input.setSelected(undefined),
|
||||
remember,
|
||||
set: (worktree: string) => {
|
||||
input.setSelected(normalizeNewSessionWorktree(worktree, sdk().directory, sync().project?.worktree))
|
||||
remember(worktree)
|
||||
},
|
||||
},
|
||||
project: {
|
||||
root: projectRoot,
|
||||
workspaces: () => sync().project?.sandboxes ?? [],
|
||||
workspaces: () => {
|
||||
const project = sync().project
|
||||
return project ? workspaceDirectories(project) : []
|
||||
},
|
||||
git: () => sync().project?.vcs === "git",
|
||||
openAll: input.onViewAll,
|
||||
},
|
||||
bar: {
|
||||
visible,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { lazy } from "solid-js"
|
||||
|
||||
export const TargetSessionRoute = lazy(() => import("./target-session-route"))
|
||||
export const preloadSessionRoute = TargetSessionRoute.preload
|
||||
@@ -38,6 +38,7 @@ import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
||||
import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { isWorkspaceDirectory } from "@/utils/workspace"
|
||||
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
|
||||
import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { NewSessionView, SessionHeader } from "@/components/session"
|
||||
@@ -101,6 +102,7 @@ import { Persist, persisted } from "@/utils/persist"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
|
||||
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
||||
import { canMoveSessionToWorkspace, WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
|
||||
import { createSessionLineage } from "./session/session-lineage"
|
||||
|
||||
@@ -521,6 +523,9 @@ export default function Page() {
|
||||
if (!controller.layout.view().reviewPanel.opened()) controller.layout.view().reviewPanel.open()
|
||||
}
|
||||
|
||||
const workspaceSession = createMemo(() =>
|
||||
isWorkspaceDirectory(sync().project, controller.data.info()?.location.directory ?? sdk().directory),
|
||||
)
|
||||
const timeline = createTimelineModel({ session: controller })
|
||||
const historyLoading = timeline.history.loading
|
||||
const historyMore = timeline.history.more
|
||||
@@ -575,6 +580,7 @@ export default function Page() {
|
||||
const [store, setStore] = createStore({
|
||||
...sessionViewState(),
|
||||
newSessionWorktree: "main",
|
||||
sessionDetailsOpen: false,
|
||||
deferRender: false,
|
||||
})
|
||||
|
||||
@@ -677,6 +683,19 @@ export default function Page() {
|
||||
: skipToken,
|
||||
}
|
||||
})
|
||||
const sessionDetailsQuery = createQuery(() => ({
|
||||
queryKey: [...vcsKey(), "git"] as const,
|
||||
enabled: store.sessionDetailsOpen && sync().project?.vcs === "git",
|
||||
queryFn: () =>
|
||||
sdk()
|
||||
.api.vcs.diff({ location: { directory: sdk().directory }, mode: "working" })
|
||||
.then((result) => result.data)
|
||||
.catch((error) => {
|
||||
console.debug("[session-review] failed to load session details diff", { error })
|
||||
return []
|
||||
}),
|
||||
}))
|
||||
const sessionDetailsDiffs = () => (sessionDetailsQuery.isFetched ? (sessionDetailsQuery.data ?? []) : [])
|
||||
const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100)
|
||||
createEffect(
|
||||
on(
|
||||
@@ -1671,6 +1690,8 @@ export default function Page() {
|
||||
}
|
||||
|
||||
const busy = (sessionID: string) => sync().data.session_working(sessionID)
|
||||
const workspaceOperationPending = (sessionID: string) =>
|
||||
WorkspaceOperation.get(serverSDK().scope, sessionID)?.status === "pending"
|
||||
|
||||
const queuedFollowups = createMemo(() => {
|
||||
const id = controller.identity.params.id
|
||||
@@ -1684,8 +1705,20 @@ export default function Page() {
|
||||
return followup.edit[id]
|
||||
})
|
||||
|
||||
const workspaceMoveEligible = createMemo(() => {
|
||||
const id = controller.identity.params.id
|
||||
if (!id) return false
|
||||
return canMoveSessionToWorkspace({
|
||||
queued: followup.items[id]?.length ?? 0,
|
||||
failed: !!followup.failed[id],
|
||||
paused: !!followup.paused[id],
|
||||
editing: !!followup.edit[id],
|
||||
})
|
||||
})
|
||||
|
||||
const followupMutation = useMutation(() => ({
|
||||
mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
|
||||
if (workspaceOperationPending(input.sessionID)) return
|
||||
const owner = controller.ownership.capture()
|
||||
const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
|
||||
if (!item) return
|
||||
@@ -1695,6 +1728,7 @@ export default function Page() {
|
||||
|
||||
const ok = await sendFollowupDraft({
|
||||
api: sdk().api.session,
|
||||
scope: serverSDK().scope,
|
||||
sync: sync(),
|
||||
serverSync: serverSync(),
|
||||
session: () => sync().session.get(input.sessionID),
|
||||
@@ -1763,6 +1797,7 @@ export default function Page() {
|
||||
|
||||
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
|
||||
if (sync().session.get(sessionID)?.parentID) return Promise.resolve()
|
||||
if (workspaceOperationPending(sessionID)) return Promise.resolve()
|
||||
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
|
||||
if (!item) return Promise.resolve()
|
||||
if (followupBusy(sessionID)) return Promise.resolve()
|
||||
@@ -1802,6 +1837,7 @@ export default function Page() {
|
||||
|
||||
const revertMutation = useMutation(() => ({
|
||||
mutationFn: async (input: { sessionID: string; messageID: string }) => {
|
||||
if (workspaceOperationPending(input.sessionID)) return
|
||||
const api = sdk().api.session
|
||||
const target = sync()
|
||||
const last = target.session.get(input.sessionID)?.revert
|
||||
@@ -1824,6 +1860,7 @@ export default function Page() {
|
||||
mutationFn: async (id: string) => {
|
||||
const sessionID = controller.identity.params.id
|
||||
if (!sessionID) return
|
||||
if (workspaceOperationPending(sessionID)) return
|
||||
|
||||
const api = sdk().api.session
|
||||
const target = sync()
|
||||
@@ -1853,7 +1890,10 @@ export default function Page() {
|
||||
},
|
||||
}))
|
||||
|
||||
const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending)
|
||||
const reverting = createMemo(() => {
|
||||
const id = controller.identity.params.id
|
||||
return revertMutation.isPending || restoreMutation.isPending || (!!id && workspaceOperationPending(id))
|
||||
})
|
||||
const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined))
|
||||
|
||||
const revert = (input: { sessionID: string; messageID: string }) => {
|
||||
@@ -1913,6 +1953,7 @@ export default function Page() {
|
||||
if (controller.data.isChild()) return
|
||||
if (composer.blocked()) return
|
||||
if (controller.data.working()) return
|
||||
if (workspaceOperationPending(sessionID)) return
|
||||
|
||||
void sendFollowup(sessionID, item.id)
|
||||
})
|
||||
@@ -2020,7 +2061,7 @@ export default function Page() {
|
||||
>
|
||||
{hasReview()
|
||||
? language.t("session.review.filesChanged", { count: reviewCount() })
|
||||
: language.t("session.review.change.other")}
|
||||
: language.plural("session.review.change", 0)}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
@@ -2088,6 +2129,9 @@ export default function Page() {
|
||||
if (root) scheduleScrollState(root)
|
||||
}}
|
||||
userMessages={visibleUserMessages()}
|
||||
diffs={sessionDetailsDiffs}
|
||||
workspaceMoveEligible={workspaceMoveEligible()}
|
||||
onSummaryOpenChange={(open) => setStore("sessionDetailsOpen", open)}
|
||||
setHistoryAnchor={(handlers) => {
|
||||
captureHistoryAnchor = handlers.capture
|
||||
restoreHistoryAnchor = handlers.restore
|
||||
@@ -2215,7 +2259,13 @@ export default function Page() {
|
||||
setFollowup("paused", id, true)
|
||||
},
|
||||
})
|
||||
return <PromptInputV2Composer controller={promptInputController} borderUnderlay />
|
||||
return (
|
||||
<PromptInputV2Composer
|
||||
controller={promptInputController}
|
||||
borderUnderlay
|
||||
accentSubmit={workspaceSession()}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ export function createPromptProjectControls() {
|
||||
const target = global.ensureServerCtx(conn)
|
||||
target.projects.open(worktree)
|
||||
target.projects.touch(worktree)
|
||||
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree })
|
||||
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree, worktree: undefined })
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -782,10 +782,7 @@ export function SessionSidePanel(props: {
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
fallback={
|
||||
<>
|
||||
{props.reviewCount()}{" "}
|
||||
{language.t(
|
||||
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
|
||||
)}
|
||||
{props.reviewCount()} {language.plural("session.review.change", props.reviewCount())}
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -33,6 +33,8 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
|
||||
@@ -41,7 +43,7 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
|
||||
import type { AssistantMessage, ToolPart, UserMessage } from "@/types"
|
||||
import type { AssistantMessage, Project, ToolPart, UserMessage } from "@/types"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
||||
import { normalize } from "@opencode-ai/session-ui/session-diff"
|
||||
@@ -49,11 +51,21 @@ import { useFileComponent } from "@opencode-ai/ui/context/file"
|
||||
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { scheduleConnectedMeasure } from "./measure"
|
||||
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
|
||||
import { filterVirtualIndexes } from "./virtual-items"
|
||||
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
|
||||
import { isWorkspaceDirectory } from "@/utils/workspace"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
import { SessionWorkspaceMenu } from "@/components/session-workspace-menu"
|
||||
import { getProjectAvatarVariant } from "@/context/layout"
|
||||
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||
|
||||
const emptyTools: ToolPart[] = []
|
||||
const emptyAssistantMessages: AssistantMessage[] = []
|
||||
@@ -108,7 +120,7 @@ function TimelineThinkingRow(props: { reasoningHeading?: string; showReasoningSu
|
||||
)
|
||||
}
|
||||
|
||||
function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) {
|
||||
function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[]; action?: JSX.Element }) {
|
||||
const language = useLanguage()
|
||||
const maxFiles = 10
|
||||
const [state, setState] = createStore({
|
||||
@@ -136,6 +148,7 @@ function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) {
|
||||
{showAll() ? language.t("ui.sessionTurn.diffs.showLess") : language.t("ui.sessionTurn.diffs.showAll")}
|
||||
</span>
|
||||
</Show>
|
||||
{props.action}
|
||||
</div>
|
||||
<div data-component="session-turn-diffs-content">
|
||||
<Accordion
|
||||
@@ -190,6 +203,179 @@ function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceLocationLoader() {
|
||||
const dots = ["left-0 top-0", "right-0 top-0", "left-0 bottom-0", "right-0 bottom-0"]
|
||||
return (
|
||||
<span data-component="workspace-location-loader" class="relative block size-4" aria-hidden="true">
|
||||
<span class="absolute left-[7px] top-[7px] size-0.5 bg-current" />
|
||||
<For each={dots}>
|
||||
{(position, index) => (
|
||||
<span
|
||||
class={`absolute size-1 bg-current ${position} animate-pulse`}
|
||||
style={{ "animation-delay": `${index() * -180}ms` }}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceMoveAction(props: {
|
||||
variant: "inline" | "panel"
|
||||
eligible: boolean
|
||||
sessionID: string
|
||||
project: Project
|
||||
directory: string
|
||||
messageID?: string
|
||||
dismissed: boolean
|
||||
onDismiss: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const inline = () => props.variant === "inline"
|
||||
return (
|
||||
<div
|
||||
classList={{
|
||||
"group/workspace-move relative shrink-0": true,
|
||||
"ml-auto h-5 w-[167px]": inline(),
|
||||
"-mt-2.5 h-[46px] w-full rounded-b-[6px] bg-v2-background-bg-layer-02 hover:bg-v2-background-bg-layer-03 transition-colors":
|
||||
!inline(),
|
||||
invisible: props.dismissed,
|
||||
}}
|
||||
>
|
||||
<SessionWorkspaceMenu
|
||||
eligible={props.eligible}
|
||||
sessionID={props.sessionID}
|
||||
project={props.project}
|
||||
directory={props.directory}
|
||||
messageID={props.messageID}
|
||||
placement={inline() ? "bottom-end" : "left-start"}
|
||||
gutter={inline() ? 4 : -22}
|
||||
contentClass={inline() ? undefined : "relative top-3.5"}
|
||||
class={
|
||||
inline()
|
||||
? "flex h-5 w-full items-center gap-1.5 rounded-[4px] pr-6 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed"
|
||||
: "flex h-[46px] w-full items-center gap-2 rounded-b-[6px] px-3 pr-9 pt-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted focus-visible:outline-none"
|
||||
}
|
||||
>
|
||||
<IconV2 name="workspace-new" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{language.t("workspace.move.title")}</span>
|
||||
</SessionWorkspaceMenu>
|
||||
<button
|
||||
type="button"
|
||||
class={`absolute flex size-5 -translate-y-1/2 items-center justify-center rounded-[4px] text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-icon-icon-base focus-visible:outline-none ${
|
||||
inline()
|
||||
? "right-0 top-1/2"
|
||||
: "hover-reveal right-3 top-[calc(50%+5px)] group-hover/workspace-move:opacity-100 group-focus-within/workspace-move:opacity-100"
|
||||
}`}
|
||||
aria-label={language.t("common.dismiss")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
props.onDismiss()
|
||||
}}
|
||||
>
|
||||
<IconV2 name="xmark-small" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionSummaryPanel(props: {
|
||||
project: Project
|
||||
directory: string
|
||||
local: boolean
|
||||
branch?: string
|
||||
baseBranch?: string
|
||||
diffs: { additions: number; deletions: number }[]
|
||||
sessionID: string
|
||||
moveEligible: boolean
|
||||
messageID?: string
|
||||
moveDismissed: boolean
|
||||
onMoveDismiss: () => void
|
||||
onReview: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const location = () => (props.local ? language.t("session.new.workspace.local") : getFilename(props.directory))
|
||||
const branch = () => props.branch ?? props.baseBranch
|
||||
const row =
|
||||
"flex h-7 w-full items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base"
|
||||
|
||||
return (
|
||||
<div data-component="session-summary-panel" class="w-[280px]">
|
||||
<div class="relative z-10 flex flex-col gap-1 overflow-hidden rounded-[6px] bg-v2-background-bg-base px-0.5 py-1.5 shadow-[var(--v2-elevation-raised)]">
|
||||
<div class={row}>
|
||||
<ProjectAvatar
|
||||
fallback={displayName(props.project)}
|
||||
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate text-v2-text-text-muted">{displayName(props.project)}</span>
|
||||
</div>
|
||||
<SessionWorkspaceMenu
|
||||
eligible={props.moveEligible}
|
||||
sessionID={props.sessionID}
|
||||
project={props.project}
|
||||
directory={props.directory}
|
||||
messageID={props.messageID}
|
||||
placement="left-start"
|
||||
gutter={-22}
|
||||
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed`}
|
||||
>
|
||||
<IconV2 name={props.local ? "monitor" : "workspace-isolated"} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 flex-1 truncate text-left">{location()}</span>
|
||||
<IconV2 name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</SessionWorkspaceMenu>
|
||||
<div class={row}>
|
||||
<IconV2 name="branch" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<Show
|
||||
when={props.branch}
|
||||
fallback={
|
||||
<span class="flex min-w-0 items-center gap-1.5">
|
||||
<span>{language.t("session.summary.noBranch")}</span>
|
||||
<Show when={props.baseBranch}>
|
||||
{(base) => (
|
||||
<>
|
||||
<span class="text-v2-text-text-muted">·</span>
|
||||
<span class="truncate text-v2-text-text-faint">
|
||||
{language.t("session.summary.basedOn", { branch: base() })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span class="min-w-0 truncate">{branch()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none`}
|
||||
onClick={props.onReview}
|
||||
>
|
||||
<IconV2 name="review" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<Show when={props.diffs.length > 0} fallback={<span>{language.t("session.review.noChanges")}</span>}>
|
||||
<span>{language.plural("ui.sessionTurn.diffs.changed", props.diffs.length)}</span>
|
||||
<span class="text-v2-text-text-muted">·</span>
|
||||
<DiffChanges changes={props.diffs} />
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
<Show when={props.local && props.diffs.length > 0 && props.moveEligible}>
|
||||
<WorkspaceMoveAction
|
||||
variant="panel"
|
||||
eligible={props.moveEligible}
|
||||
sessionID={props.sessionID}
|
||||
project={props.project}
|
||||
directory={props.directory}
|
||||
messageID={props.messageID}
|
||||
dismissed={props.moveDismissed}
|
||||
onDismiss={props.onMoveDismiss}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TimelineDiffView(props: { diff: SummaryDiff }) {
|
||||
const fileComponent = useFileComponent()
|
||||
const view = normalize(props.diff)
|
||||
@@ -218,6 +404,9 @@ type MessageTimelineProps = {
|
||||
centered: boolean
|
||||
setContentRef: (el: HTMLDivElement) => void
|
||||
userMessages: UserMessage[]
|
||||
diffs: Accessor<{ additions: number; deletions: number }[]>
|
||||
workspaceMoveEligible: boolean
|
||||
onSummaryOpenChange: (open: boolean) => void
|
||||
anchor: (id: string) => string
|
||||
setRevealMessage?: (fn: (id: string) => void) => void
|
||||
setScrollToEnd?: (fn: () => void) => void
|
||||
@@ -240,6 +429,11 @@ function MessageTimelineView(
|
||||
) {
|
||||
let touchGesture: number | undefined
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const command = useCommand()
|
||||
const ownerSessionKey = props.data.sessionKey()
|
||||
const cached = timelineCache.get(ownerSessionKey)
|
||||
const initialMeasurements = cached?.measurements
|
||||
@@ -254,18 +448,87 @@ function MessageTimelineView(
|
||||
const parentID = props.data.parentID
|
||||
const parentTitle = props.data.parentTitle
|
||||
const childTitle = props.data.childTitle
|
||||
const showHeader = props.data.showHeader
|
||||
const getMsgParts = props.data.parts
|
||||
const getMsgPart = props.data.part
|
||||
const projection = props.data.projection
|
||||
const sessionDirectory = createMemo(
|
||||
() => props.session.data.info()?.location.directory ?? sdk().directory,
|
||||
)
|
||||
const workspaceSession = createMemo(() => isWorkspaceDirectory(sync().project, sessionDirectory()))
|
||||
const [workspaceSuggestionDismissed, setWorkspaceSuggestionDismissed] = createSignal(false)
|
||||
const [summaryOpen, setSummaryOpen] = createSignal(false)
|
||||
const setSummary = (open: boolean) => {
|
||||
setSummaryOpen(open)
|
||||
props.onSummaryOpenChange(open)
|
||||
}
|
||||
const sessionDiffs = createMemo(props.diffs)
|
||||
createEffect(
|
||||
on(sessionID, () => {
|
||||
setSummary(false)
|
||||
setWorkspaceSuggestionDismissed(false)
|
||||
}),
|
||||
)
|
||||
const turnPadding = () => "px-4 md:px-5"
|
||||
const workspaceOperation = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return
|
||||
return WorkspaceOperation.get(serverSDK().scope, id)
|
||||
})
|
||||
const lifecycleTitle = createMemo(() => {
|
||||
const operation = workspaceOperation()
|
||||
if (operation?.status === "pending") {
|
||||
return {
|
||||
kind: "pending" as const,
|
||||
text: language.t(operation.type === "create" ? "workspace.lifecycle.creating" : "workspace.lifecycle.moving"),
|
||||
}
|
||||
}
|
||||
if (operation?.type === "create" && !props.data.titleValue())
|
||||
return { kind: "created" as const, text: language.t("workspace.lifecycle.created") }
|
||||
if (!props.data.titleValue())
|
||||
return { kind: "starting" as const, text: language.t("workspace.lifecycle.starting") }
|
||||
return
|
||||
})
|
||||
const workspaceOperationPending = (sessionID: string) =>
|
||||
WorkspaceOperation.get(serverSDK().scope, sessionID)?.status === "pending"
|
||||
const showHeader = createMemo(() => props.data.showHeader() || workspaceSession())
|
||||
const activeMessageID = projection.activeMessageID
|
||||
const assistantMessagesByParent = projection.assistantMessagesByParent
|
||||
const lastAssistantGroupKey = projection.lastAssistantGroupKey
|
||||
const messageByID = projection.messageByID
|
||||
const messageLastRowIndex = projection.messageLastRowIndex
|
||||
const messageRowIndex = projection.messageRowIndex
|
||||
const timelineRowByKey = projection.rowByKey
|
||||
const timelineRows = projection.rows
|
||||
const timelineRows = createMemo(() => {
|
||||
const rows = projection.rows()
|
||||
const operation = workspaceOperation()
|
||||
const userMessageID = operation?.messageID ?? props.userMessages.at(-1)?.id
|
||||
if (!operation || !userMessageID) return rows
|
||||
const index = rows.findIndex((row) => row._tag === "UserMessage" && row.userMessageID === userMessageID)
|
||||
if (index < 0) return rows
|
||||
return [
|
||||
...rows.slice(0, index + 1),
|
||||
new TimelineRow.WorkspaceLifecycle({
|
||||
userMessageID,
|
||||
notice: { type: "operation", operation },
|
||||
}),
|
||||
...rows.slice(index + 1),
|
||||
]
|
||||
})
|
||||
const timelineRowByKey = createMemo(
|
||||
() => new Map(timelineRows().map((row) => [TimelineRow.key(row), row] as const)),
|
||||
)
|
||||
const messageRowIndex = createMemo(() => {
|
||||
const result = new Map<string, number>()
|
||||
timelineRows().forEach((row, index) => {
|
||||
if (!("userMessageID" in row) || result.has(row.userMessageID)) return
|
||||
result.set(row.userMessageID, index)
|
||||
})
|
||||
return result
|
||||
})
|
||||
const messageLastRowIndex = createMemo(() => {
|
||||
const result = new Map<string, number>()
|
||||
timelineRows().forEach((row, index) => {
|
||||
if ("userMessageID" in row) result.set(row.userMessageID, index)
|
||||
})
|
||||
return result
|
||||
})
|
||||
|
||||
let prependAnchor: { key: string; offset: number } | undefined
|
||||
let prependAnchorFrame: number | undefined
|
||||
@@ -749,7 +1012,7 @@ function MessageTimelineView(
|
||||
)
|
||||
return (
|
||||
<TimelineRowFrame row={commentStripRow}>
|
||||
<div class="w-full px-4 md:px-5 pb-2">
|
||||
<div class={`w-full pb-2 ${turnPadding()}`}>
|
||||
<div class="ms-auto max-w-[82%] overflow-x-auto no-scrollbar">
|
||||
<div class="flex w-max min-w-full justify-end gap-2">
|
||||
<Index each={comments()}>
|
||||
@@ -800,7 +1063,7 @@ function MessageTimelineView(
|
||||
<TimelineRowFrame row={userMessageRow}>
|
||||
<Show when={message()}>
|
||||
{(message) => (
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||
<div data-slot="session-turn-message-content" aria-live="off">
|
||||
<Message
|
||||
message={message()}
|
||||
@@ -816,11 +1079,55 @@ function MessageTimelineView(
|
||||
</TimelineRowFrame>
|
||||
)
|
||||
}
|
||||
case "WorkspaceLifecycle": {
|
||||
const workspaceRow = row as Accessor<TimelineRowByTag<"WorkspaceLifecycle">>
|
||||
const operation = () => workspaceRow().notice.operation
|
||||
const pending = () => operation().status === "pending"
|
||||
const status = () => {
|
||||
if (operation().status === "failed") return language.t("workspace.move.failed")
|
||||
if (operation().type === "create")
|
||||
return language.t(pending() ? "workspace.lifecycle.creating" : "workspace.lifecycle.created")
|
||||
return language.t(pending() ? "workspace.lifecycle.moving" : "workspace.lifecycle.set")
|
||||
}
|
||||
const directory = () => getFilename(operation().directory)
|
||||
return (
|
||||
<TimelineRowFrame row={workspaceRow}>
|
||||
<div class={`w-full ${turnPadding()}`} aria-live="polite">
|
||||
<div class="flex h-7 items-center py-1 text-[13px] font-[440] leading-none tracking-[-0.04px]">
|
||||
<Show
|
||||
when={!pending()}
|
||||
fallback={
|
||||
<div class="flex items-center gap-1.5">
|
||||
<TextShimmer text={status()} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"flex items-center gap-1.5": true,
|
||||
"text-v2-state-fg-danger": operation().status === "failed",
|
||||
}}
|
||||
>
|
||||
<span class={operation().status === "failed" ? "" : "text-v2-text-text-base"}>{status()}</span>
|
||||
<Show when={operation().status !== "failed"}>
|
||||
<span class="text-[11px] font-[530] italic text-v2-text-text-muted">·</span>
|
||||
<IconV2 name="workspace-isolated" class="shrink-0 text-v2-icon-icon-accent" />
|
||||
<Show when={directory()}>
|
||||
<span class="max-w-[240px] truncate text-v2-text-text-base">{directory()}</span>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</TimelineRowFrame>
|
||||
)
|
||||
}
|
||||
case "TurnDivider": {
|
||||
const turnDividerRow = row as Accessor<TimelineRowByTag<"TurnDivider">>
|
||||
return (
|
||||
<TimelineRowFrame row={turnDividerRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||
<div data-slot="session-turn-compaction">
|
||||
<MessageDivider
|
||||
label={language.t(
|
||||
@@ -836,7 +1143,7 @@ function MessageTimelineView(
|
||||
const assistantPartRow = row as Accessor<TimelineRowByTag<"AssistantPart">>
|
||||
return (
|
||||
<TimelineRowFrame row={assistantPartRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||
<div
|
||||
data-slot="session-turn-assistant-content"
|
||||
aria-hidden={workingTurn(assistantPartRow().userMessageID)}
|
||||
@@ -851,7 +1158,7 @@ function MessageTimelineView(
|
||||
const thinkingRow = row as Accessor<TimelineRowByTag<"Thinking">>
|
||||
return (
|
||||
<TimelineRowFrame row={thinkingRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||
<TimelineThinkingRow
|
||||
reasoningHeading={thinkingRow().reasoningHeading}
|
||||
showReasoningSummaries={props.data.showReasoningSummaries()}
|
||||
@@ -864,7 +1171,7 @@ function MessageTimelineView(
|
||||
const retryRow = row as Accessor<TimelineRowByTag<"Retry">>
|
||||
return (
|
||||
<TimelineRowFrame row={retryRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||
<SessionRetry status={sessionStatus()} show={activeMessageID() === retryRow().userMessageID} />
|
||||
</div>
|
||||
</TimelineRowFrame>
|
||||
@@ -872,10 +1179,35 @@ function MessageTimelineView(
|
||||
}
|
||||
case "DiffSummary": {
|
||||
const diffSummaryRow = row as Accessor<TimelineRowByTag<"DiffSummary">>
|
||||
const canMove = () =>
|
||||
props.data.newLayoutDesigns() &&
|
||||
diffSummaryRow().userMessageID === props.userMessages.at(-1)?.id &&
|
||||
!workspaceSession() &&
|
||||
props.workspaceMoveEligible &&
|
||||
sync().project?.vcs === "git" &&
|
||||
sessionStatus().type === "idle"
|
||||
return (
|
||||
<TimelineRowFrame row={diffSummaryRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<TimelineDiffSummaryRow diffs={diffSummaryRow().diffs} />
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||
<TimelineDiffSummaryRow
|
||||
diffs={diffSummaryRow().diffs}
|
||||
action={
|
||||
<Show when={canMove() && sync().project}>
|
||||
{(project) => (
|
||||
<WorkspaceMoveAction
|
||||
variant="inline"
|
||||
eligible={props.workspaceMoveEligible}
|
||||
sessionID={sessionID()!}
|
||||
project={project()}
|
||||
directory={sessionDirectory()}
|
||||
messageID={diffSummaryRow().userMessageID}
|
||||
dismissed={workspaceSuggestionDismissed()}
|
||||
onDismiss={() => setWorkspaceSuggestionDismissed(true)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TimelineRowFrame>
|
||||
)
|
||||
@@ -884,7 +1216,7 @@ function MessageTimelineView(
|
||||
const errorRow = row as Accessor<TimelineRowByTag<"Error">>
|
||||
return (
|
||||
<TimelineRowFrame row={errorRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||
<Card variant="error" class="error-card">
|
||||
{errorRow().text}
|
||||
</Card>
|
||||
@@ -966,7 +1298,7 @@ function MessageTimelineView(
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="relative w-full h-full min-w-0">
|
||||
<div class="relative w-full h-full min-w-0" data-workspace-session={workspaceSession() ? "" : undefined}>
|
||||
<div
|
||||
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
||||
classList={{
|
||||
@@ -1061,6 +1393,39 @@ function MessageTimelineView(
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center min-w-0 flex-1 w-full">
|
||||
<Show when={props.data.newLayoutDesigns()}>
|
||||
<Show
|
||||
when={workspaceOperation()?.status !== "pending"}
|
||||
fallback={
|
||||
<span class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-muted">
|
||||
<WorkspaceLocationLoader />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={workspaceSession()}
|
||||
fallback={
|
||||
<span class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-muted">
|
||||
<IconV2 name="monitor" />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<TooltipV2
|
||||
placement="bottom-start"
|
||||
value={sessionDirectory()}
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<span
|
||||
tabIndex={0}
|
||||
aria-label={sessionDirectory()}
|
||||
class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-accent"
|
||||
>
|
||||
<IconV2 name="workspace-isolated" />
|
||||
</span>
|
||||
</TooltipV2>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={parentID()}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1078,56 +1443,71 @@ function MessageTimelineView(
|
||||
/
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={childTitle() || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
fallback={
|
||||
<h1
|
||||
data-slot="session-title-child"
|
||||
classList={{
|
||||
"truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
|
||||
"w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover":
|
||||
props.data.newLayoutDesigns(),
|
||||
"grow-1 min-w-0": !props.data.newLayoutDesigns(),
|
||||
<Show
|
||||
when={!lifecycleTitle()}
|
||||
fallback={
|
||||
<span
|
||||
class="px-2 text-[13px] font-[530] leading-4 tracking-[-0.04px]"
|
||||
classList={{ "text-v2-text-text-base": lifecycleTitle()?.kind === "created" }}
|
||||
aria-live="polite"
|
||||
>
|
||||
<Show when={lifecycleTitle()?.kind !== "created"} fallback={lifecycleTitle()?.text}>
|
||||
<TextShimmer text={lifecycleTitle()!.text} />
|
||||
</Show>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Show when={childTitle() || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
fallback={
|
||||
<h1
|
||||
data-slot="session-title-child"
|
||||
classList={{
|
||||
"truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
|
||||
"w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover":
|
||||
props.data.newLayoutDesigns(),
|
||||
"grow-1 min-w-0": !props.data.newLayoutDesigns(),
|
||||
}}
|
||||
onClick={openTitleEditor}
|
||||
>
|
||||
{childTitle()}
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
<InlineInput
|
||||
ref={(el) => {
|
||||
titleRef = el
|
||||
}}
|
||||
onClick={openTitleEditor}
|
||||
>
|
||||
{childTitle()}
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
<InlineInput
|
||||
ref={(el) => {
|
||||
titleRef = el
|
||||
}}
|
||||
data-slot="session-title-child"
|
||||
value={title.draft}
|
||||
disabled={props.pending.rename()}
|
||||
classList={{
|
||||
"block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
|
||||
"w-full flex-1 grow-1 min-w-0 pl-1 -ml-1 rounded-[6px]": !props.data.newLayoutDesigns(),
|
||||
"field-sizing-content self-start rounded-[6px] px-2 py-1 ": props.data.newLayoutDesigns(),
|
||||
}}
|
||||
style={{
|
||||
"--inline-input-shadow": props.data.newLayoutDesigns()
|
||||
? "none"
|
||||
: "var(--shadow-xs-border-select)",
|
||||
}}
|
||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveTitleEditor()
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeTitleEditor()
|
||||
}
|
||||
}}
|
||||
onBlur={closeTitleEditor}
|
||||
/>
|
||||
data-slot="session-title-child"
|
||||
value={title.draft}
|
||||
disabled={props.pending.rename()}
|
||||
classList={{
|
||||
"block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
|
||||
"w-full flex-1 grow-1 min-w-0 pl-1 -ml-1 rounded-[6px]": !props.data.newLayoutDesigns(),
|
||||
"field-sizing-content self-start rounded-[6px] px-2 py-1 ": props.data.newLayoutDesigns(),
|
||||
}}
|
||||
style={{
|
||||
"--inline-input-shadow": props.data.newLayoutDesigns()
|
||||
? "none"
|
||||
: "var(--shadow-xs-border-select)",
|
||||
}}
|
||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveTitleEditor()
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeTitleEditor()
|
||||
}
|
||||
}}
|
||||
onBlur={closeTitleEditor}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -1145,6 +1525,47 @@ function MessageTimelineView(
|
||||
placement="bottom"
|
||||
buttonAppearance={props.data.newLayoutDesigns() ? "v2" : "default"}
|
||||
/>
|
||||
<Show when={props.data.newLayoutDesigns() && !parentID() && sync().project}>
|
||||
{(project) => (
|
||||
<KobaltePopover
|
||||
open={summaryOpen()}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
onOpenChange={setSummary}
|
||||
>
|
||||
<KobaltePopover.Trigger
|
||||
as={IconButtonV2}
|
||||
icon={<IconV2 name="window-analytics" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
state={summaryOpen() ? "pressed" : undefined}
|
||||
aria-label={language.t("session.summary.title")}
|
||||
aria-expanded={summaryOpen()}
|
||||
/>
|
||||
<KobaltePopover.Portal>
|
||||
<KobaltePopover.Content class="z-50 border-0 bg-transparent p-0 outline-none">
|
||||
<SessionSummaryPanel
|
||||
project={project()}
|
||||
directory={sessionDirectory()}
|
||||
local={!workspaceSession()}
|
||||
branch={sync().data.vcs?.branch}
|
||||
baseBranch={serverSync().child(project().worktree)[0].vcs?.branch}
|
||||
diffs={sessionDiffs()}
|
||||
sessionID={id}
|
||||
moveEligible={props.workspaceMoveEligible}
|
||||
messageID={props.userMessages.at(-1)?.id}
|
||||
moveDismissed={workspaceSuggestionDismissed()}
|
||||
onMoveDismiss={() => setWorkspaceSuggestionDismissed(true)}
|
||||
onReview={() => {
|
||||
setSummary(false)
|
||||
command.trigger("review.toggle")
|
||||
}}
|
||||
/>
|
||||
</KobaltePopover.Content>
|
||||
</KobaltePopover.Portal>
|
||||
</KobaltePopover>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!parentID()}>
|
||||
<Show
|
||||
when={props.data.newLayoutDesigns()}
|
||||
@@ -1210,12 +1631,21 @@ function MessageTimelineView(
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<DropdownMenu.Item onSelect={() => void props.action.export(id)}>
|
||||
<DropdownMenu.Item
|
||||
disabled={workspaceOperationPending(id)}
|
||||
onSelect={() => void props.action.export(id)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.export")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
{/* TODO: Need a V2 session archive API. */}
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onSelect={() => props.action.showDelete(id)}>
|
||||
<DropdownMenu.Item
|
||||
disabled={workspaceOperationPending(id)}
|
||||
onSelect={() => {
|
||||
if (workspaceOperationPending(id)) return
|
||||
props.action.showDelete(id)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
@@ -1280,12 +1710,21 @@ function MessageTimelineView(
|
||||
{language.t("session.share.action.share")}...
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
<MenuV2.Item onSelect={() => void props.action.export(id)}>
|
||||
<MenuV2.Item
|
||||
disabled={workspaceOperationPending(id)}
|
||||
onSelect={() => void props.action.export(id)}
|
||||
>
|
||||
{language.t("common.export")}...
|
||||
</MenuV2.Item>
|
||||
{/* TODO: Need a V2 session archive API. */}
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item onSelect={() => props.action.showDelete(id)}>
|
||||
<MenuV2.Item
|
||||
disabled={workspaceOperationPending(id)}
|
||||
onSelect={() => {
|
||||
if (workspaceOperationPending(id)) return
|
||||
props.action.showDelete(id)
|
||||
}}
|
||||
>
|
||||
{language.t("common.delete")}...
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
|
||||
import { reuseTimelineRows } from "./row-reconciliation"
|
||||
import { insertAfterUserMessage, reuseTimelineRows } from "./row-reconciliation"
|
||||
import { TimelineRow } from "./timeline-row"
|
||||
|
||||
const context = (key: string, partIDs: string[], userMessageID = "user-1") =>
|
||||
@@ -94,3 +94,20 @@ describe("reuseTimelineRows", () => {
|
||||
reused.forEach(([resultIndex, previousIndex]) => expect(result[resultIndex]).toBe(previous[previousIndex]))
|
||||
})
|
||||
})
|
||||
|
||||
test("inserts lifecycle extensions immediately after the user message", () => {
|
||||
const rows: TimelineRow.TimelineRow[] = [user(), new TimelineRow.DiffSummary({ userMessageID: "user-1", diffs: [] })]
|
||||
const lifecycle = new TimelineRow.WorkspaceLifecycle({
|
||||
userMessageID: "user-1",
|
||||
notice: {
|
||||
type: "operation",
|
||||
operation: { type: "move", status: "complete", directory: "/workspace", messageID: "user-1" },
|
||||
},
|
||||
})
|
||||
|
||||
expect(insertAfterUserMessage(rows, [lifecycle]).map((row) => row._tag)).toEqual([
|
||||
"UserMessage",
|
||||
"WorkspaceLifecycle",
|
||||
"DiffSummary",
|
||||
])
|
||||
})
|
||||
|
||||
@@ -14,6 +14,8 @@ export function createTimelineProjection(input: {
|
||||
status: Accessor<SessionStatus>
|
||||
showReasoningSummaries: Accessor<boolean>
|
||||
inlineComments: Accessor<boolean>
|
||||
extensionRevision?: Accessor<unknown>
|
||||
afterUser?: (message: UserMessage) => TimelineRow.TimelineRow[]
|
||||
}) {
|
||||
const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const)))
|
||||
const assistantMessagesByParent = createMemo(() => {
|
||||
@@ -29,8 +31,9 @@ export function createTimelineProjection(input: {
|
||||
})
|
||||
return result
|
||||
})
|
||||
const projection = createMemo(() =>
|
||||
Timeline.constructSessionMessageRows(
|
||||
const projection = createMemo(() => {
|
||||
const extension = input.extensionRevision?.()
|
||||
return Timeline.constructSessionMessageRows(
|
||||
input.sessionMessages(),
|
||||
(messageID) => messageByID().get(messageID) as UserMessage | AssistantMessage | undefined,
|
||||
input.parts,
|
||||
@@ -38,8 +41,9 @@ export function createTimelineProjection(input: {
|
||||
input.status().type,
|
||||
input.inlineComments(),
|
||||
input.userMessages(),
|
||||
),
|
||||
)
|
||||
input.extensionRevision && extension === undefined ? undefined : input.afterUser,
|
||||
)
|
||||
})
|
||||
const activeMessageID = createMemo(() => projection().activeMessageID)
|
||||
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
|
||||
reuseTimelineRows(previous, projection().rows),
|
||||
|
||||
@@ -3,6 +3,12 @@ import { TimelineRow } from "./timeline-row"
|
||||
type ContextRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
|
||||
type PriorContext = { index: number; row: ContextRow }
|
||||
|
||||
export function insertAfterUserMessage(rows: TimelineRow.TimelineRow[], extensions: TimelineRow.TimelineRow[]) {
|
||||
const index = rows.findIndex((row) => row._tag === "UserMessage")
|
||||
rows.splice(index + 1, 0, ...extensions)
|
||||
return rows
|
||||
}
|
||||
|
||||
export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) {
|
||||
if (!previous?.length) return rows
|
||||
const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const))
|
||||
|
||||
@@ -13,6 +13,14 @@ mock.module("@opencode-ai/session-ui/message-part", () => ({
|
||||
}))
|
||||
|
||||
const { Timeline, TimelineRow } = await import("./rows")
|
||||
const lifecycle = (userMessageID: string) =>
|
||||
new TimelineRow.WorkspaceLifecycle({
|
||||
userMessageID,
|
||||
notice: {
|
||||
type: "operation",
|
||||
operation: { type: "create", status: "complete", directory: "/workspace", messageID: userMessageID },
|
||||
},
|
||||
})
|
||||
|
||||
describe("current session timeline rows", () => {
|
||||
test("derives turns and tagged rows from chronological current messages", () => {
|
||||
@@ -47,6 +55,7 @@ describe("current session timeline rows", () => {
|
||||
"busy",
|
||||
true,
|
||||
normalized.messages.filter((message) => message.role === "user"),
|
||||
(message) => (message.id === "msg_3" ? [lifecycle(message.id)] : []),
|
||||
)
|
||||
|
||||
expect(result.activeMessageID).toBe("msg_3")
|
||||
@@ -55,6 +64,7 @@ describe("current session timeline rows", () => {
|
||||
"assistant-part:msg_1:msg_2:text:0",
|
||||
"turn-gap:msg_3",
|
||||
"user-message:msg_3",
|
||||
"workspace-lifecycle:msg_3:operation",
|
||||
"assistant-part:msg_3:msg_4:reasoning:0",
|
||||
])
|
||||
})
|
||||
@@ -83,11 +93,13 @@ describe("current session timeline rows", () => {
|
||||
"idle",
|
||||
true,
|
||||
normalized.messages.filter((message) => message.role === "user"),
|
||||
(message) => [lifecycle(message.id)],
|
||||
)
|
||||
|
||||
expect(result.activeMessageID).toBe("msg_shell")
|
||||
expect(result.rows.map(TimelineRow.key)).toEqual([
|
||||
"user-message:msg_shell",
|
||||
"workspace-lifecycle:msg_shell:operation",
|
||||
"assistant-part:msg_shell:msg_shell:tool",
|
||||
])
|
||||
})
|
||||
@@ -157,6 +169,7 @@ describe("current session timeline rows", () => {
|
||||
"busy",
|
||||
true,
|
||||
[...normalized.messages.filter((message) => message.role === "user"), optimistic],
|
||||
(message) => (message.id === optimistic.id ? [lifecycle(message.id)] : []),
|
||||
)
|
||||
|
||||
expect(result.activeMessageID).toBe(optimistic.id)
|
||||
@@ -164,6 +177,7 @@ describe("current session timeline rows", () => {
|
||||
"user-message:msg_z",
|
||||
"turn-gap:msg_a",
|
||||
"user-message:msg_a",
|
||||
"workspace-lifecycle:msg_a:operation",
|
||||
"thinking:msg_a",
|
||||
])
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/
|
||||
import { TimelineRow, type SummaryDiff } from "./timeline-row"
|
||||
import { uniqueSummaryDiffs } from "./summary-diffs"
|
||||
import { compareMessages } from "@/utils/session-message"
|
||||
import { insertAfterUserMessage } from "./row-reconciliation"
|
||||
|
||||
export { TimelineRow, type SummaryDiff } from "./timeline-row"
|
||||
|
||||
@@ -28,6 +29,10 @@ export type TimelineRowMap = {
|
||||
}
|
||||
Thinking: { userMessageID: string; reasoningHeading?: string }
|
||||
Retry: { userMessageID: string }
|
||||
WorkspaceLifecycle: {
|
||||
userMessageID: string
|
||||
notice: TimelineRow.WorkspaceLifecycle["notice"]
|
||||
}
|
||||
DiffSummary: { userMessageID: string; diffs: SummaryDiff[] }
|
||||
Error: { userMessageID: string; text: string }
|
||||
}
|
||||
@@ -41,6 +46,7 @@ export namespace Timeline {
|
||||
status: SessionStatus["type"],
|
||||
inlineComments: boolean,
|
||||
projectedUserMessages: UserMessage[],
|
||||
afterUser?: (message: UserMessage) => TimelineRow.TimelineRow[],
|
||||
) {
|
||||
const turns: { user: UserMessage; assistants: AssistantMessage[] }[] = []
|
||||
const turnByUserID = new Map<string, (typeof turns)[number]>()
|
||||
@@ -83,8 +89,8 @@ export namespace Timeline {
|
||||
const activeMessageID = turns.at(-1)?.user.id
|
||||
return {
|
||||
activeMessageID,
|
||||
rows: turns.flatMap((turn, index) =>
|
||||
constructMessageRows(
|
||||
rows: turns.flatMap((turn, index) => {
|
||||
const rows = constructMessageRows(
|
||||
turn.user,
|
||||
getMessageParts,
|
||||
turn.assistants,
|
||||
@@ -93,8 +99,10 @@ export namespace Timeline {
|
||||
status,
|
||||
turn.user.id === activeMessageID,
|
||||
inlineComments,
|
||||
),
|
||||
),
|
||||
)
|
||||
if (!afterUser) return rows
|
||||
return insertAfterUserMessage(rows, afterUser(turn.user))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
|
||||
import { Data, Equal } from "effect"
|
||||
import type { WorkspaceOperationState } from "@/utils/workspace-operation"
|
||||
|
||||
export type SummaryDiff = FileDiffInfo
|
||||
|
||||
@@ -39,6 +40,10 @@ export namespace TimelineRow {
|
||||
export class Retry extends Data.TaggedClass("Retry")<{
|
||||
userMessageID: string
|
||||
}> {}
|
||||
export class WorkspaceLifecycle extends Data.TaggedClass("WorkspaceLifecycle")<{
|
||||
userMessageID: string
|
||||
notice: { type: "operation"; operation: WorkspaceOperationState }
|
||||
}> {}
|
||||
|
||||
export type TimelineRow =
|
||||
| TurnGap
|
||||
@@ -50,6 +55,7 @@ export namespace TimelineRow {
|
||||
| DiffSummary
|
||||
| Error
|
||||
| Retry
|
||||
| WorkspaceLifecycle
|
||||
|
||||
export const key = (row: TimelineRow) => {
|
||||
switch (row._tag) {
|
||||
@@ -71,6 +77,8 @@ export namespace TimelineRow {
|
||||
return `error:${row.userMessageID}`
|
||||
case "Retry":
|
||||
return `retry:${row.userMessageID}`
|
||||
case "WorkspaceLifecycle":
|
||||
return `workspace-lifecycle:${row.userMessageID}:${row.notice.type}`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,10 +13,9 @@ import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||
import { findLast } from "@opencode-ai/core/util/array"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import type { UserMessage } from "@/types"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
import type { SessionController } from "./session-controller"
|
||||
|
||||
type SessionCommandSource = {
|
||||
@@ -54,7 +53,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const sync = useSync()
|
||||
const terminal = useTerminal()
|
||||
const layout = useLayout()
|
||||
const local = useLocal()
|
||||
const navigate = useNavigate()
|
||||
const openDialog = async <T,>(load: () => Promise<T>, show: (value: T) => void) => {
|
||||
const owner = actions.session.ownership.capture()
|
||||
@@ -73,6 +71,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
input.owner.run(input.updateViewport)
|
||||
}
|
||||
|
||||
const workspaceOperationPending = (sessionID: string) =>
|
||||
WorkspaceOperation.get(sdk().scope, sessionID)?.status === "pending"
|
||||
const shown = settings.visibility.fileTree
|
||||
|
||||
const showAllFiles = () => {
|
||||
@@ -291,6 +291,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const undo = async () => {
|
||||
const sessionID = actions.session.identity.params.id
|
||||
if (!sessionID) return
|
||||
if (workspaceOperationPending(sessionID)) return
|
||||
const owner = actions.session.ownership.capture()
|
||||
const session = sdk().api.session
|
||||
const directory = sdk().directory
|
||||
@@ -321,6 +322,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const redo = async () => {
|
||||
const sessionID = actions.session.identity.params.id
|
||||
if (!sessionID) return
|
||||
if (workspaceOperationPending(sessionID)) return
|
||||
const owner = actions.session.ownership.capture()
|
||||
const session = sdk().api.session
|
||||
const messages = actions.session.history.userMessages()
|
||||
@@ -355,11 +357,15 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const compact = async () => {
|
||||
const sessionID = actions.session.identity.params.id
|
||||
if (!sessionID) return
|
||||
if (workspaceOperationPending(sessionID)) return
|
||||
|
||||
await sdk().api.session.compact({ sessionID })
|
||||
}
|
||||
|
||||
const fork = () => {
|
||||
const sessionID = actions.session.identity.params.id
|
||||
if (!sessionID) return
|
||||
if (workspaceOperationPending(sessionID)) return
|
||||
void openDialog(
|
||||
() => import("@/components/dialog-fork"),
|
||||
(x) => dialog.show(() => <x.DialogFork />),
|
||||
@@ -415,7 +421,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
title: language.t("command.session.undo"),
|
||||
description: language.t("command.session.undo.description"),
|
||||
slash: "undo",
|
||||
disabled: !actions.session.identity.params.id || actions.session.history.visibleUserMessages().length === 0,
|
||||
disabled:
|
||||
!actions.session.identity.params.id ||
|
||||
actions.session.history.visibleUserMessages().length === 0 ||
|
||||
workspaceOperationPending(actions.session.identity.params.id),
|
||||
onSelect: undo,
|
||||
}),
|
||||
sessionCommand({
|
||||
@@ -423,7 +432,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
title: language.t("command.session.redo"),
|
||||
description: language.t("command.session.redo.description"),
|
||||
slash: "redo",
|
||||
disabled: !actions.session.identity.params.id || !actions.session.data.info()?.revert?.messageID,
|
||||
disabled:
|
||||
!actions.session.identity.params.id ||
|
||||
!actions.session.data.info()?.revert?.messageID ||
|
||||
workspaceOperationPending(actions.session.identity.params.id),
|
||||
onSelect: redo,
|
||||
}),
|
||||
sessionCommand({
|
||||
@@ -431,7 +443,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
title: language.t("command.session.compact"),
|
||||
description: language.t("command.session.compact.description"),
|
||||
slash: "compact",
|
||||
disabled: !actions.session.identity.params.id || actions.session.history.visibleUserMessages().length === 0,
|
||||
disabled:
|
||||
!actions.session.identity.params.id ||
|
||||
actions.session.history.visibleUserMessages().length === 0 ||
|
||||
workspaceOperationPending(actions.session.identity.params.id),
|
||||
onSelect: compact,
|
||||
}),
|
||||
sessionCommand({
|
||||
@@ -439,7 +454,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
title: language.t("command.session.fork"),
|
||||
description: language.t("command.session.fork.description"),
|
||||
slash: "fork",
|
||||
disabled: !actions.session.identity.params.id || actions.session.history.visibleUserMessages().length === 0,
|
||||
disabled:
|
||||
!actions.session.identity.params.id ||
|
||||
actions.session.history.visibleUserMessages().length === 0 ||
|
||||
workspaceOperationPending(actions.session.identity.params.id),
|
||||
onSelect: fork,
|
||||
}),
|
||||
sessionCommand({
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { ServerSDKProvider } from "@/context/server-sdk"
|
||||
import { ServerSyncProvider } from "@/context/server-sync"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
import { TargetSessionRouteContent } from "./session"
|
||||
|
||||
export default function TargetSessionRoute() {
|
||||
const params = useParams<{ serverKey: string }>()
|
||||
const global = useGlobal()
|
||||
const connection = createMemo(() => {
|
||||
const key = requireServerKey(params.serverKey)
|
||||
return global.servers.list().find((item) => ServerConnection.key(item) === key)
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={requireServerKey(params.serverKey)} keyed>
|
||||
<ServerSDKProvider server={connection}>
|
||||
<ServerSyncProvider server={connection}>
|
||||
<TargetSessionRouteContent />
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ServerScope } from "./server-scope"
|
||||
import { canMoveSessionToWorkspace, WorkspaceOperation } from "./workspace-operation"
|
||||
|
||||
test("workspace moves require settled followup state", () => {
|
||||
expect(canMoveSessionToWorkspace({ queued: 0, failed: false, paused: false, editing: false })).toBe(true)
|
||||
expect(canMoveSessionToWorkspace({ queued: 1, failed: false, paused: false, editing: false })).toBe(false)
|
||||
expect(canMoveSessionToWorkspace({ queued: 0, failed: true, paused: false, editing: false })).toBe(false)
|
||||
expect(canMoveSessionToWorkspace({ queued: 0, failed: false, paused: true, editing: false })).toBe(false)
|
||||
expect(canMoveSessionToWorkspace({ queued: 0, failed: false, paused: false, editing: true })).toBe(false)
|
||||
})
|
||||
|
||||
describe("WorkspaceOperation", () => {
|
||||
test("settles only the matching pending operation", () => {
|
||||
WorkspaceOperation.start(ServerScope.local, "session", "move", "/workspace")
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session")?.status).toBe("pending")
|
||||
WorkspaceOperation.complete(ServerScope.local, "session", "/other")
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session")?.status).toBe("pending")
|
||||
WorkspaceOperation.complete(ServerScope.local, "session", "/workspace")
|
||||
WorkspaceOperation.fail(ServerScope.local, "session")
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session")?.status).toBe("complete")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
|
||||
export type WorkspaceOperationType = "create" | "move"
|
||||
export type WorkspaceOperationState = {
|
||||
type: WorkspaceOperationType
|
||||
status: "pending" | "complete" | "failed"
|
||||
directory: string
|
||||
messageID?: string
|
||||
}
|
||||
|
||||
export function canMoveSessionToWorkspace(input: {
|
||||
queued: number
|
||||
failed: boolean
|
||||
paused: boolean
|
||||
editing: boolean
|
||||
}) {
|
||||
return input.queued === 0 && !input.failed && !input.paused && !input.editing
|
||||
}
|
||||
|
||||
const state = new Map<string, WorkspaceOperationState>()
|
||||
const [version, setVersion] = createSignal(0)
|
||||
const key = (scope: ServerScope, sessionID: string) => ScopedKey.from(scope, sessionID)
|
||||
const write = (scope: ServerScope, sessionID: string, value: WorkspaceOperationState) => {
|
||||
if (!state.has(key(scope, sessionID)) && state.size >= 100) {
|
||||
const terminal = [...state].find(([, item]) => item.status !== "pending")?.[0] ?? state.keys().next().value
|
||||
if (terminal) state.delete(terminal)
|
||||
}
|
||||
state.set(key(scope, sessionID), value)
|
||||
setVersion((current) => current + 1)
|
||||
}
|
||||
export const WorkspaceOperation = {
|
||||
get(scope: ServerScope, sessionID: string) {
|
||||
version()
|
||||
return state.get(key(scope, sessionID))
|
||||
},
|
||||
start(scope: ServerScope, sessionID: string, type: WorkspaceOperationType, directory: string, messageID?: string) {
|
||||
write(scope, sessionID, { type, directory, messageID, status: "pending" })
|
||||
},
|
||||
complete(scope: ServerScope, sessionID: string, directory?: string) {
|
||||
const current = state.get(key(scope, sessionID))
|
||||
if (!current) return
|
||||
if (directory && pathKey(directory) !== pathKey(current.directory)) return
|
||||
write(scope, sessionID, { ...current, status: "complete" })
|
||||
},
|
||||
fail(scope: ServerScope, sessionID: string) {
|
||||
const current = state.get(key(scope, sessionID))
|
||||
if (!current || current.status === "complete") return
|
||||
write(scope, sessionID, { ...current, status: "failed" })
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export const WORKSPACE_PREPARATION_TIMEOUT_MS = 5 * 60 * 1000
|
||||
export const WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS = 30_000
|
||||
|
||||
export async function workspaceRequestWithTimeout<T>(
|
||||
request: (signal: AbortSignal) => Promise<T>,
|
||||
message: string,
|
||||
timeoutMs: number,
|
||||
) {
|
||||
const controller = new AbortController()
|
||||
const timer = { id: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer.id = setTimeout(() => {
|
||||
controller.abort()
|
||||
reject(new Error(message))
|
||||
}, timeoutMs)
|
||||
})
|
||||
return Promise.race([request(controller.signal), timeout])
|
||||
.catch((error) => {
|
||||
if (controller.signal.aborted) throw new Error(message)
|
||||
throw error
|
||||
})
|
||||
.finally(() => {
|
||||
if (timer.id !== undefined) clearTimeout(timer.id)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import {
|
||||
filterWorkspaceInventory,
|
||||
inspectWorkspaceDeletion,
|
||||
isWorkspaceDirectory,
|
||||
isWorkspaceSelection,
|
||||
mergeWorkspaceSessionInventory,
|
||||
sessionsForWorkspace,
|
||||
workspaceInventory,
|
||||
} from "./workspace"
|
||||
|
||||
describe("isWorkspaceDirectory", () => {
|
||||
const project = {
|
||||
worktree: "C:\\repo\\",
|
||||
sandboxes: ["C:\\repo-workspaces\\feature\\", "C:\\repo-workspaces\\other"],
|
||||
}
|
||||
|
||||
test("distinguishes managed workspaces from the local repository", () => {
|
||||
expect(isWorkspaceDirectory(project, "C:\\repo")).toBe(false)
|
||||
expect(isWorkspaceDirectory(project, "C:\\repo-workspaces\\feature")).toBe(true)
|
||||
expect(isWorkspaceDirectory(project, "c:\\repo-workspaces\\feature\\packages\\app")).toBe(true)
|
||||
expect(
|
||||
isWorkspaceDirectory({ worktree: "/repo", sandboxes: ["/repo/.worktrees/feature"] }, "/repo/.worktrees/feature"),
|
||||
).toBe(true)
|
||||
expect(isWorkspaceDirectory(project, "C:\\other")).toBe(false)
|
||||
expect(isWorkspaceDirectory(undefined, "C:\\repo-workspaces\\feature")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isWorkspaceSelection", () => {
|
||||
const project = { worktree: "/repo", sandboxes: ["/workspaces/feature"] }
|
||||
|
||||
test("accepts local, new, and managed workspace selections", () => {
|
||||
expect(isWorkspaceSelection(project, "main")).toBe(true)
|
||||
expect(isWorkspaceSelection(project, "create")).toBe(true)
|
||||
expect(isWorkspaceSelection(project, "/repo/")).toBe(true)
|
||||
expect(isWorkspaceSelection(project, "/workspaces/feature/")).toBe(true)
|
||||
expect(isWorkspaceSelection({ worktree: "C:\\repo" }, "c:\\repo\\")).toBe(true)
|
||||
expect(isWorkspaceSelection(project, "/other/workspace")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test("groups and filters workspace inventory by project", () => {
|
||||
const inventory = workspaceInventory([
|
||||
{ id: "a", worktree: "/a", sandboxes: ["/a", "/a/one", "/a/two"] },
|
||||
{ id: "b", worktree: "/b", sandboxes: ["/b/one"] },
|
||||
])
|
||||
|
||||
expect(inventory.map((item) => [item.project.id, item.directory])).toEqual([
|
||||
["a", "/a/one"],
|
||||
["a", "/a/two"],
|
||||
["b", "/b/one"],
|
||||
])
|
||||
expect(filterWorkspaceInventory(inventory, "a").map((item) => item.directory)).toEqual(["/a/one", "/a/two"])
|
||||
expect(filterWorkspaceInventory(inventory, "all")).toEqual(inventory)
|
||||
})
|
||||
|
||||
test("blocks unsafe workspace deletion", () => {
|
||||
const session = (directory: string) =>
|
||||
({ location: { directory }, time: { created: 1, updated: 1 } }) as SessionInfo
|
||||
expect(
|
||||
inspectWorkspaceDeletion({
|
||||
workspace: "/workspace",
|
||||
activeDirectory: "/workspace/app",
|
||||
sessions: [],
|
||||
status: "dirty",
|
||||
}),
|
||||
).toBe("active")
|
||||
expect(
|
||||
inspectWorkspaceDeletion({
|
||||
workspace: "/workspace",
|
||||
sessions: [session("/workspace/packages/app")],
|
||||
status: "dirty",
|
||||
}),
|
||||
).toBe("linked")
|
||||
expect(inspectWorkspaceDeletion({ workspace: "/workspace", sessions: [], status: "dirty" })).toBe("dirty")
|
||||
expect(inspectWorkspaceDeletion({ workspace: "/workspace", sessions: [], status: "clean" })).toBe("safe")
|
||||
expect(
|
||||
inspectWorkspaceDeletion({
|
||||
workspace: "/workspace",
|
||||
sessions: [
|
||||
{ location: { directory: "/workspace" }, time: { created: 1, updated: 1, archived: 2 } } as SessionInfo,
|
||||
],
|
||||
status: "clean",
|
||||
}),
|
||||
).toBe("safe")
|
||||
})
|
||||
|
||||
test("groups nested non-archived workspace sessions by latest activity", () => {
|
||||
const session = (id: string, directory: string, updated: number, archived?: number) =>
|
||||
({ id, location: { directory }, time: { created: 1, updated, archived } }) as SessionInfo
|
||||
const sessions = sessionsForWorkspace(
|
||||
[
|
||||
session("old", "/workspace", 2),
|
||||
session("nested", "/workspace/packages/app", 3),
|
||||
session("archived", "/workspace", 4, 5),
|
||||
session("other", "/other", 6),
|
||||
],
|
||||
"/workspace",
|
||||
)
|
||||
expect(sessions.map((item) => item.id)).toEqual(["nested", "old"])
|
||||
})
|
||||
|
||||
test("merges workspace placement by freshness with authoritative server ties", () => {
|
||||
const session = (directory: string, updated: number) =>
|
||||
({ id: "session", location: { directory }, time: { created: 1, updated } }) as SessionInfo
|
||||
|
||||
expect(
|
||||
mergeWorkspaceSessionInventory([session("/destination", 3)], [session("/source", 2)])[0]?.location.directory,
|
||||
).toBe("/destination")
|
||||
expect(
|
||||
mergeWorkspaceSessionInventory([session("/destination", 3)], [session("/source", 3)])[0]?.location.directory,
|
||||
).toBe("/destination")
|
||||
expect(
|
||||
mergeWorkspaceSessionInventory([session("/destination", 2)], [session("/source", 3)])[0]?.location.directory,
|
||||
).toBe("/source")
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import type { WorkspaceDefaultDestination, WorkspaceLastUsed } from "@/context/settings"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
type WorkspaceProject = { worktree: string; sandboxes?: readonly string[] }
|
||||
|
||||
export function workspaceDirectories(project: WorkspaceProject) {
|
||||
return (project.sandboxes ?? []).filter(
|
||||
(directory) => !containsDirectory(project.worktree, directory) || !containsDirectory(directory, project.worktree),
|
||||
)
|
||||
}
|
||||
|
||||
export function workspaceInventory<T extends WorkspaceProject & { id: string }>(projects: readonly T[]) {
|
||||
return projects.flatMap((project) => workspaceDirectories(project).map((directory) => ({ directory, project })))
|
||||
}
|
||||
|
||||
export function filterWorkspaceInventory<T extends { project: { id: string } }>(
|
||||
workspaces: readonly T[],
|
||||
project: string,
|
||||
) {
|
||||
if (project === "all") return [...workspaces]
|
||||
return workspaces.filter((workspace) => workspace.project.id === project)
|
||||
}
|
||||
|
||||
export function sessionsForWorkspace(sessions: readonly SessionInfo[], workspace: string) {
|
||||
return sessions
|
||||
.filter((session) => session.time.archived === undefined)
|
||||
.filter((session) => containsDirectory(workspace, session.location.directory))
|
||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
}
|
||||
|
||||
export function mergeWorkspaceSessionInventory(server: readonly SessionInfo[], cached: readonly SessionInfo[]) {
|
||||
const sessions = new Map(server.map((session) => [session.id, session]))
|
||||
cached.forEach((session) => {
|
||||
const current = sessions.get(session.id)
|
||||
if (!current || session.time.updated > current.time.updated) sessions.set(session.id, session)
|
||||
})
|
||||
return [...sessions.values()]
|
||||
}
|
||||
|
||||
export function removeWorkspacesSequentially<T>(workspaces: readonly T[], remove: (workspace: T) => Promise<void>) {
|
||||
return workspaces.reduce((previous, workspace) => previous.then(() => remove(workspace)), Promise.resolve())
|
||||
}
|
||||
|
||||
export type WorkspaceDeleteInspection = "safe" | "active" | "linked" | "dirty"
|
||||
|
||||
export function inspectWorkspaceDeletion(input: {
|
||||
workspace: string
|
||||
activeDirectory?: string
|
||||
sessions: readonly SessionInfo[]
|
||||
status: "clean" | "dirty"
|
||||
}): WorkspaceDeleteInspection {
|
||||
if (input.activeDirectory && containsDirectory(input.workspace, input.activeDirectory)) return "active"
|
||||
if (
|
||||
input.sessions.some(
|
||||
(session) =>
|
||||
session.time.archived === undefined && containsDirectory(input.workspace, session.location.directory),
|
||||
)
|
||||
)
|
||||
return "linked"
|
||||
if (input.status === "dirty") return "dirty"
|
||||
return "safe"
|
||||
}
|
||||
|
||||
export function isWorkspaceDirectory(project: WorkspaceProject | undefined, directory: string) {
|
||||
if (!project || (containsDirectory(project.worktree, directory) && containsDirectory(directory, project.worktree)))
|
||||
return false
|
||||
return workspaceDirectories(project).some((workspace) => containsDirectory(workspace, directory))
|
||||
}
|
||||
|
||||
export function isProjectDirectory(project: WorkspaceProject | undefined, directory: string) {
|
||||
if (!project) return false
|
||||
return [project.worktree, ...(project.sandboxes ?? [])].some((root) => containsDirectory(root, directory))
|
||||
}
|
||||
|
||||
export function containsDirectory(parent: string, child: string) {
|
||||
const normalize = (value: string) => {
|
||||
const key = pathKey(value)
|
||||
return /^[a-z]:\//i.test(key) || key.startsWith("//") ? key.toLowerCase() : key
|
||||
}
|
||||
const root = normalize(parent)
|
||||
const target = normalize(child)
|
||||
return target === root || target.startsWith(root.endsWith("/") ? root : `${root}/`)
|
||||
}
|
||||
|
||||
export function isWorkspaceSelection(project: WorkspaceProject | undefined, selection: string) {
|
||||
if (selection === "main" || selection === "create") return true
|
||||
if (!project) return false
|
||||
if (containsDirectory(project.worktree, selection) && containsDirectory(selection, project.worktree)) return true
|
||||
return isWorkspaceDirectory(project, selection)
|
||||
}
|
||||
|
||||
export function workspaceDefaultSelection(
|
||||
setting: WorkspaceDefaultDestination,
|
||||
lastUsed: WorkspaceLastUsed | undefined,
|
||||
) {
|
||||
if (setting === "local") return "main"
|
||||
if (setting === "new") return "create"
|
||||
return lastUsed === "workspace" ? "create" : "main"
|
||||
}
|
||||
@@ -42,8 +42,8 @@
|
||||
"solid-js": "catalog:",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"uqr": "0.1.3",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"ws": "8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-executable-page-protection</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -422,8 +422,7 @@ async function loadCatalog(client: OpenCodeClient, cwd: string): Promise<Catalog
|
||||
defaultModel: {
|
||||
providerID: defaultModel.providerID,
|
||||
id: defaultModel.id,
|
||||
variant:
|
||||
defaultModel.variants.find((variant) => variant.id === "default")?.id ?? defaultModel.variants[0]?.id,
|
||||
variant: defaultModel.variants.find((variant) => variant.id === "default")?.id,
|
||||
},
|
||||
modes: agents.map((agent) => ({ id: agent.id, name: agent.name, description: agent.description })),
|
||||
defaultModeID: defaultAgent.id,
|
||||
|
||||
@@ -3,6 +3,38 @@ import type { SessionConfigOption } from "@agentclientprotocol/sdk"
|
||||
import { makeACPFixture, makeSession, secondModel } from "./service-fixture"
|
||||
|
||||
describe("acp service lifecycle", () => {
|
||||
test("does not persist the first catalog variant when no explicit default exists", async () => {
|
||||
const model = { ...secondModel, variants: [{ id: "none" }, { id: "high" }] }
|
||||
await using fixture = makeACPFixture({
|
||||
models: [model],
|
||||
defaultModel: model,
|
||||
fetch(request) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
return Response.json({
|
||||
data: makeSession("ses_default_variant", {
|
||||
model: { providerID: model.providerID, id: model.id },
|
||||
}),
|
||||
})
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
const created = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
|
||||
expect(fixture.requests).toContainEqual({
|
||||
method: "POST",
|
||||
path: "/api/session",
|
||||
query: {},
|
||||
body: {
|
||||
location: { directory: "/workspace" },
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "second-model" },
|
||||
},
|
||||
})
|
||||
expect(currentValue(created, "effort")).toBe("none")
|
||||
})
|
||||
|
||||
test("loads and forks with paginated replay while resume does not replay", async () => {
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request) {
|
||||
|
||||
@@ -339,7 +339,11 @@ export type Endpoint5_31Output =
|
||||
readonly type: "session.agent.selected"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly agent: Agent.ID }
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly previous?: Agent.ID | undefined
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
@@ -348,7 +352,11 @@ export type Endpoint5_31Output =
|
||||
readonly type: "session.model.selected"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly model: Model.Ref }
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly model: Model.Ref
|
||||
readonly previous?: Model.Ref | undefined
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
|
||||
@@ -436,7 +436,7 @@ export type SessionAgentSelected = {
|
||||
type: "session.agent.selected"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; agent: string }
|
||||
data: { sessionID: string; agent: string; previous?: string }
|
||||
}
|
||||
|
||||
export type SessionModelSelected = {
|
||||
@@ -446,7 +446,7 @@ export type SessionModelSelected = {
|
||||
type: "session.model.selected"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; model: ModelRef }
|
||||
data: { sessionID: string; model: ModelRef; previous?: ModelRef }
|
||||
}
|
||||
|
||||
export type SessionMoved = {
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
},
|
||||
"imports": {
|
||||
"#sqlite": {
|
||||
"workerd": "./src/database/sqlite.workerd.ts",
|
||||
"bun": "./src/database/sqlite.bun.ts",
|
||||
"node": "./src/database/sqlite.node.ts",
|
||||
"default": "./src/database/sqlite.bun.ts"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "15060ec5-05f7-4b86-b2a5-9108609432b3",
|
||||
"prevIds": ["1551a157-8959-4ba9-a52b-4ea3b7b28cae"],
|
||||
"id": "00924d88-1842-4d71-ac74-5682ddc47e1c",
|
||||
"prevIds": ["15060ec5-05f7-4b86-b2a5-9108609432b3"],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "account_state",
|
||||
@@ -1302,6 +1302,16 @@
|
||||
"entityType": "columns",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "0",
|
||||
"generated": null,
|
||||
"name": "resume_attempts",
|
||||
"entityType": "columns",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
|
||||
@@ -194,11 +194,10 @@ async function formatTypescript(input: string) {
|
||||
|
||||
function renderRegistry(names: string[]) {
|
||||
return `import type { DatabaseMigration } from "./migration"
|
||||
${names.map((name, index) => `import m${index.toString().padStart(2, "0")} from "./migration/${name}"`).join("\n")}
|
||||
|
||||
export const migrations: DatabaseMigration.Migration[] = (
|
||||
await Promise.all([
|
||||
${names.map((name) => ` import("./migration/${name}"),`).join("\n")}
|
||||
])
|
||||
).map((module) => module.default)
|
||||
export const migrations = [
|
||||
${names.map((_, index) => ` m${index.toString().padStart(2, "0")},`).join("\n")}
|
||||
] satisfies DatabaseMigration.Migration[]
|
||||
`
|
||||
}
|
||||
|
||||
@@ -88,9 +88,13 @@ function mapBedrockSettings(
|
||||
: typeof settings.bearerToken === "string"
|
||||
? settings.bearerToken
|
||||
: undefined
|
||||
const credentials = mapBedrockCredentials(settings)
|
||||
const region = bedrockRegion(settings)
|
||||
const credentials = mapBedrockCredentials(settings, region)
|
||||
return {
|
||||
...baseSettings,
|
||||
...(typeof baseSettings.baseURL === "string" && region !== undefined
|
||||
? { baseURL: baseSettings.baseURL.replaceAll("${AWS_REGION}", region) }
|
||||
: {}),
|
||||
...(typeof settings.baseURL !== "string" && typeof settings.endpoint === "string"
|
||||
? { baseURL: settings.endpoint }
|
||||
: {}),
|
||||
@@ -155,14 +159,8 @@ function mapBedrockRequest(input: MapInput): Pick<Mapping, "headers" | "body"> {
|
||||
}
|
||||
}
|
||||
|
||||
function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>) {
|
||||
function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>, region: string | undefined) {
|
||||
const credentials = isRecord(settings.credentials) ? settings.credentials : settings
|
||||
const region =
|
||||
typeof settings.region === "string"
|
||||
? settings.region
|
||||
: typeof credentials.region === "string"
|
||||
? credentials.region
|
||||
: undefined
|
||||
if (
|
||||
region === undefined ||
|
||||
typeof credentials.accessKeyId !== "string" ||
|
||||
@@ -177,6 +175,15 @@ function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>) {
|
||||
}
|
||||
}
|
||||
|
||||
function bedrockRegion(settings: Readonly<Record<string, unknown>>) {
|
||||
const credentials = isRecord(settings.credentials) ? settings.credentials : settings
|
||||
return typeof settings.region === "string"
|
||||
? settings.region
|
||||
: typeof credentials.region === "string"
|
||||
? credentials.region
|
||||
: undefined
|
||||
}
|
||||
|
||||
function mapOpenAIOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
const options = {
|
||||
...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),
|
||||
|
||||
@@ -508,8 +508,23 @@ function toolOutput(result: ToolResultValue) {
|
||||
case "text":
|
||||
case "error":
|
||||
return { type: "text" as const, value: messageValue(result.value) }
|
||||
case "content":
|
||||
return {
|
||||
type: "content" as const,
|
||||
value: result.value.map((item) => {
|
||||
if (item.type === "text") return { type: "text" as const, text: item.text }
|
||||
const data = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(item.uri)?.[1]
|
||||
const image = item.mime.toLowerCase().startsWith("image/")
|
||||
if (data !== undefined)
|
||||
return image
|
||||
? { type: "image-data" as const, data, mediaType: item.mime }
|
||||
: { type: "file-data" as const, data, mediaType: item.mime, filename: item.name }
|
||||
return image ? { type: "image-url" as const, url: item.uri } : { type: "file-url" as const, url: item.uri }
|
||||
}),
|
||||
}
|
||||
case "json":
|
||||
return { type: "json" as const, value: jsonValue(result.value) }
|
||||
}
|
||||
return { type: "json" as const, value: jsonValue(result.value) }
|
||||
}
|
||||
|
||||
function tool(input: ToolDefinition): LanguageModelV3FunctionTool {
|
||||
|
||||
@@ -204,13 +204,13 @@ export const layer = (options?: Options) =>
|
||||
const claude = [
|
||||
...new Set([
|
||||
...((yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
|
||||
...discovered.filter((item) => path.basename(item) === ".claude"),
|
||||
...discovered.filter((item) => path.basename(item) === ".claude").toReversed(),
|
||||
]),
|
||||
].map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) }))
|
||||
const agents = [
|
||||
...new Set([
|
||||
...((yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
|
||||
...discovered.filter((item) => path.basename(item) === ".agents"),
|
||||
...discovered.filter((item) => path.basename(item) === ".agents").toReversed(),
|
||||
]),
|
||||
].map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) }))
|
||||
|
||||
|
||||
@@ -83,8 +83,14 @@ export function normalize(input: unknown): Result {
|
||||
if (legacySnapshots !== undefined) encoded.snapshots = legacySnapshots
|
||||
if (legacyShare !== undefined) encoded.share = legacyShare
|
||||
|
||||
const legacyReferences = decodeEncodedMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics)
|
||||
const nativeReferences = decodeEncodedMap(input.references, ConfigReference.Entry, ["references"], diagnostics)
|
||||
const legacyReferences = decodeMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics, decodeEncoded)
|
||||
const nativeReferences = decodeMap(
|
||||
input.references,
|
||||
ConfigReference.Entry,
|
||||
["references"],
|
||||
diagnostics,
|
||||
decodeEncoded,
|
||||
)
|
||||
mergeMap(
|
||||
encoded,
|
||||
"references",
|
||||
@@ -94,13 +100,13 @@ export function normalize(input: unknown): Result {
|
||||
diagnostics,
|
||||
)
|
||||
|
||||
const legacyCommands = decodeMap(input.command, ConfigCommandV1.Info, ["command"], diagnostics)
|
||||
const legacyCommands = decodeMap(input.command, ConfigCommandV1.Info, ["command"], diagnostics, decodeValue)
|
||||
diagnoseSelectionMap(input.command, ["command"], diagnostics)
|
||||
const migratedCommands = mapValues(legacyCommands, (value) => {
|
||||
const migrated = ConfigMigrateV1.commands({ value })?.value
|
||||
return migrated === undefined ? undefined : canonical(ConfigCommand.Info, migrated)
|
||||
})
|
||||
const nativeCommands = decodeEncodedMap(input.commands, ConfigCommand.Info, ["commands"], diagnostics)
|
||||
const nativeCommands = decodeMap(input.commands, ConfigCommand.Info, ["commands"], diagnostics, decodeEncoded)
|
||||
mergeMap(
|
||||
encoded,
|
||||
"commands",
|
||||
@@ -110,8 +116,9 @@ export function normalize(input: unknown): Result {
|
||||
diagnostics,
|
||||
)
|
||||
|
||||
const legacyAgents = mapValues(decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics), (value) =>
|
||||
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)),
|
||||
const legacyAgents = mapValues(
|
||||
decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics, decodeValue),
|
||||
(value) => canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)),
|
||||
)
|
||||
const legacySmallModel = own(input, "small_model")
|
||||
? decodeValue(Schema.String, input.small_model, ["small_model"], diagnostics)
|
||||
@@ -130,11 +137,11 @@ export function normalize(input: unknown): Result {
|
||||
model: migratedSmallModel,
|
||||
...legacyAgents.title,
|
||||
}
|
||||
const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics), (value) =>
|
||||
const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics, decodeValue), (value) =>
|
||||
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent({ ...value, mode: "primary" })),
|
||||
)
|
||||
const migratedAgents = mergeMaps(legacyAgents, modeAgents, ["agents"], diagnostics)
|
||||
const nativeAgents = decodeEncodedMap(input.agents, ConfigAgent.Info, ["agents"], diagnostics)
|
||||
const nativeAgents = decodeMap(input.agents, ConfigAgent.Info, ["agents"], diagnostics, decodeEncoded)
|
||||
diagnoseAgentUnsupported(input.agent, ["agent"], diagnostics)
|
||||
diagnoseAgentUnsupported(input.mode, ["mode"], diagnostics)
|
||||
mergeMap(
|
||||
@@ -147,7 +154,7 @@ export function normalize(input: unknown): Result {
|
||||
)
|
||||
|
||||
const legacyProviders = migrateProviders(input.provider, diagnostics)
|
||||
const nativeProviders = decodeEncodedMap(input.providers, ConfigProvider.Info, ["providers"], diagnostics)
|
||||
const nativeProviders = decodeMap(input.providers, ConfigProvider.Info, ["providers"], diagnostics, decodeEncoded)
|
||||
mergeMap(
|
||||
encoded,
|
||||
"providers",
|
||||
@@ -159,14 +166,14 @@ export function normalize(input: unknown): Result {
|
||||
|
||||
const toolRules = migrateTools(input.tools, diagnostics)
|
||||
const permissionRules = migratePermissions(input.permission, diagnostics)
|
||||
const nativePermissions = decodeEncodedList(input.permissions, Permission.Rule, ["permissions"], diagnostics)
|
||||
const nativePermissions = decodeList(input.permissions, Permission.Rule, ["permissions"], diagnostics, decodeEncoded)
|
||||
const permissions = [...toolRules, ...permissionRules, ...nativePermissions]
|
||||
if (permissions.length || Array.isArray(input.permissions)) encoded.permissions = permissions
|
||||
|
||||
const legacyPlugins = decodeList(input.plugin, ConfigPluginV1.Spec, ["plugin"], diagnostics).map((plugin) =>
|
||||
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
||||
const legacyPlugins = decodeList(input.plugin, ConfigPluginV1.Spec, ["plugin"], diagnostics, decodeValue).map(
|
||||
(plugin) => (typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] }),
|
||||
)
|
||||
const nativePlugins = decodeEncodedList(input.plugins, ConfigPlugin.Plugin, ["plugins"], diagnostics)
|
||||
const nativePlugins = decodeList(input.plugins, ConfigPlugin.Plugin, ["plugins"], diagnostics, decodeEncoded)
|
||||
if (legacyPlugins.length || nativePlugins.length || Array.isArray(input.plugin) || Array.isArray(input.plugins))
|
||||
encoded.plugins = [...legacyPlugins, ...nativePlugins]
|
||||
|
||||
@@ -200,7 +207,7 @@ export function normalize(input: unknown): Result {
|
||||
overlay(encoded, key, value, [key], diagnostics)
|
||||
})
|
||||
|
||||
const instructions = decodeEncodedList(input.instructions, Schema.String, ["instructions"], diagnostics)
|
||||
const instructions = decodeList(input.instructions, Schema.String, ["instructions"], diagnostics, decodeEncoded)
|
||||
if (instructions.length || Array.isArray(input.instructions)) encoded.instructions = instructions
|
||||
|
||||
return { type: "normalized", encoded, diagnostics }
|
||||
@@ -209,7 +216,7 @@ export function normalize(input: unknown): Result {
|
||||
function normalizeSkills(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
|
||||
if (!own(input, "skills")) return
|
||||
if (Array.isArray(input.skills)) {
|
||||
encoded.skills = decodeEncodedList(input.skills, Schema.String, ["skills"], diagnostics)
|
||||
encoded.skills = decodeList(input.skills, Schema.String, ["skills"], diagnostics, decodeEncoded)
|
||||
return
|
||||
}
|
||||
if (!isRecord(input.skills)) {
|
||||
@@ -217,8 +224,8 @@ function normalizeSkills(input: Record<string, unknown>, encoded: Record<string,
|
||||
return
|
||||
}
|
||||
encoded.skills = [
|
||||
...decodeEncodedList(input.skills.paths, Schema.String, ["skills", "paths"], diagnostics),
|
||||
...decodeEncodedList(input.skills.urls, Schema.String, ["skills", "urls"], diagnostics),
|
||||
...decodeList(input.skills.paths, Schema.String, ["skills", "paths"], diagnostics, decodeEncoded),
|
||||
...decodeList(input.skills.urls, Schema.String, ["skills", "urls"], diagnostics, decodeEncoded),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -248,8 +255,8 @@ function normalizeMcp(input: Record<string, unknown>, encoded: Record<string, un
|
||||
return
|
||||
}
|
||||
if (name === "servers" && !isDirectLegacyMcp(value)) {
|
||||
Object.entries(decodeEncodedMap(value, ConfigMCP.Server, path, diagnostics)).forEach(([key, server]) =>
|
||||
setOwn(nativeServers, key, server),
|
||||
Object.entries(decodeMap(value, ConfigMCP.Server, path, diagnostics, decodeEncoded)).forEach(
|
||||
([key, server]) => setOwn(nativeServers, key, server),
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -404,7 +411,13 @@ function normalizeExperimental(
|
||||
if (value !== undefined) result.subagent_depth = value
|
||||
}
|
||||
native.push(
|
||||
...decodeEncodedList(experimental.policies, ConfigPolicy.Info, ["experimental", "policies"], diagnostics),
|
||||
...decodeList(
|
||||
experimental.policies,
|
||||
ConfigPolicy.Info,
|
||||
["experimental", "policies"],
|
||||
diagnostics,
|
||||
decodeEncoded,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -420,7 +433,7 @@ function normalizeWatcher(input: Record<string, unknown>, encoded: Record<string
|
||||
invalid(["watcher"], diagnostics)
|
||||
return
|
||||
}
|
||||
const ignore = decodeEncodedList(input.watcher.ignore, Schema.String, ["watcher", "ignore"], diagnostics)
|
||||
const ignore = decodeList(input.watcher.ignore, Schema.String, ["watcher", "ignore"], diagnostics, decodeEncoded)
|
||||
encoded.watcher = ignore.length || Array.isArray(input.watcher.ignore) ? { ignore } : {}
|
||||
}
|
||||
|
||||
@@ -435,7 +448,7 @@ function normalizeFormatter(
|
||||
if (value !== undefined) encoded.formatter = value
|
||||
return
|
||||
}
|
||||
const entries = decodeEncodedMap(input.formatter, ConfigFormatter.Entry, ["formatter"], diagnostics)
|
||||
const entries = decodeMap(input.formatter, ConfigFormatter.Entry, ["formatter"], diagnostics, decodeEncoded)
|
||||
if (isRecord(input.formatter) && (!Object.keys(input.formatter).length || Object.keys(entries).length))
|
||||
encoded.formatter = entries
|
||||
}
|
||||
@@ -447,7 +460,7 @@ function normalizeLsp(input: Record<string, unknown>, encoded: Record<string, un
|
||||
if (value !== undefined) encoded.lsp = value
|
||||
return
|
||||
}
|
||||
const entries = decodeEncodedMap(input.lsp, ConfigLSP.Entry, ["lsp"], diagnostics)
|
||||
const entries = decodeMap(input.lsp, ConfigLSP.Entry, ["lsp"], diagnostics, decodeEncoded)
|
||||
if (isRecord(input.lsp) && (!Object.keys(input.lsp).length || Object.keys(entries).length)) encoded.lsp = entries
|
||||
}
|
||||
|
||||
@@ -597,78 +610,44 @@ function decodeProviderList(
|
||||
return {
|
||||
present: true,
|
||||
nonEmpty: input[key].length > 0,
|
||||
values: decodeList(input[key], Schema.String, [key], diagnostics),
|
||||
values: decodeList(input[key], Schema.String, [key], diagnostics, decodeValue),
|
||||
}
|
||||
}
|
||||
|
||||
function decodeEncodedMap<S extends Schema.Codec<unknown, unknown, never, never>>(
|
||||
function decodeMap<S extends Schema.Codec<unknown, unknown, never>, A>(
|
||||
value: unknown,
|
||||
schema: S,
|
||||
path: string[],
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
decode: (schema: S, value: unknown, path: string[], diagnostics: Diagnostic[]) => A | undefined,
|
||||
): Record<string, A> {
|
||||
if (value === undefined) return {}
|
||||
if (!isRecord(value)) {
|
||||
invalid(path, diagnostics)
|
||||
return {}
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).flatMap(([name, raw]) => {
|
||||
const decoded = decodeEncoded(schema, raw, [...path, name], diagnostics)
|
||||
Object.entries(value).flatMap(([name, raw]): [string, A][] => {
|
||||
const decoded = decode(schema, raw, [...path, name], diagnostics)
|
||||
return decoded === undefined ? [] : [[name, decoded]]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function decodeMap<S extends Schema.Codec<unknown, unknown, never, never>>(
|
||||
function decodeList<S extends Schema.Codec<unknown, unknown, never>, A>(
|
||||
value: unknown,
|
||||
schema: S,
|
||||
path: string[],
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
if (value === undefined) return {} as Record<string, S["Type"]>
|
||||
if (!isRecord(value)) {
|
||||
invalid(path, diagnostics)
|
||||
return {} as Record<string, S["Type"]>
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).flatMap(([name, raw]) => {
|
||||
const decoded = decodeValue(schema, raw, [...path, name], diagnostics)
|
||||
return decoded === undefined ? [] : [[name, decoded]]
|
||||
}),
|
||||
) as Record<string, S["Type"]>
|
||||
}
|
||||
|
||||
function decodeEncodedList<S extends Schema.Codec<unknown, unknown, never, never>>(
|
||||
value: unknown,
|
||||
schema: S,
|
||||
path: string[],
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
if (value === undefined) return [] as S["Encoded"][]
|
||||
decode: (schema: S, value: unknown, path: string[], diagnostics: Diagnostic[]) => A | undefined,
|
||||
): A[] {
|
||||
if (value === undefined) return []
|
||||
if (!Array.isArray(value)) {
|
||||
invalid(path, diagnostics)
|
||||
return [] as S["Encoded"][]
|
||||
return []
|
||||
}
|
||||
return value.flatMap((item, index) => {
|
||||
const decoded = decodeEncoded(schema, item, [...path, String(index)], diagnostics)
|
||||
return decoded === undefined ? [] : [decoded]
|
||||
})
|
||||
}
|
||||
|
||||
function decodeList<S extends Schema.Codec<unknown, unknown, never, never>>(
|
||||
value: unknown,
|
||||
schema: S,
|
||||
path: string[],
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
if (value === undefined) return [] as S["Type"][]
|
||||
if (!Array.isArray(value)) {
|
||||
invalid(path, diagnostics)
|
||||
return [] as S["Type"][]
|
||||
}
|
||||
return value.flatMap((item, index) => {
|
||||
const decoded = decodeValue(schema, item, [...path, String(index)], diagnostics)
|
||||
const decoded = decode(schema, item, [...path, String(index)], diagnostics)
|
||||
return decoded === undefined ? [] : [decoded]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -27,8 +27,10 @@ export const Plugin = define({
|
||||
const changes = yield* PubSub.sliding<string>(1)
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const start = yield* fs.resolve(location.directory)
|
||||
const stop = yield* fs.resolve(location.project.directory)
|
||||
const project = discovery.project && FSUtil.contains(stop, start)
|
||||
const root = yield* fs.resolve(location.project.directory)
|
||||
const home = yield* fs.resolve(global.home)
|
||||
const project = discovery.project && FSUtil.contains(root, start)
|
||||
const stop = FSUtil.contains(home, start) ? home : root
|
||||
const globalFile = yield* fs.resolve(join(global.config, "AGENTS.md"))
|
||||
const loaded: { current: Loaded } = { current: { type: "available", files: [] } }
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Directory, Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Option, PubSub, Scope, Stream } from "effect"
|
||||
import { Context, Effect, Layer, Option, Predicate, PubSub, Schema, Scope, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { Config } from "../../config"
|
||||
@@ -153,22 +153,70 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
|
||||
})
|
||||
})
|
||||
|
||||
const sourceDirectories = ["plugin", "plugins"] as const
|
||||
const Package = Schema.Struct({
|
||||
exports: Schema.optional(Schema.Unknown),
|
||||
module: Schema.optional(Schema.Unknown),
|
||||
main: Schema.optional(Schema.Unknown),
|
||||
})
|
||||
const decodePackage = Schema.decodeUnknownOption(Package)
|
||||
|
||||
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
|
||||
return Effect.gen(function* () {
|
||||
const files = yield* fs
|
||||
.scan("{plugin,plugins}/*.{ts,js}", {
|
||||
cwd: directory,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})
|
||||
.pipe(Effect.orElseSucceed(() => []))
|
||||
return files.sort().map((target): Operation => ({ type: "add", target, options: {} }))
|
||||
const children = (yield* Effect.forEach(sourceDirectories, (source) =>
|
||||
fs.readDirectoryEntries(path.join(directory, source)).pipe(
|
||||
Effect.orElseSucceed(() => []),
|
||||
Effect.map((entries) =>
|
||||
entries.map((entry) => ({ ...entry, target: path.join(directory, source, entry.name) })),
|
||||
),
|
||||
),
|
||||
))
|
||||
.flat()
|
||||
.sort((a, b) => (a.target < b.target ? -1 : a.target > b.target ? 1 : 0))
|
||||
const targets = yield* Effect.forEach(children, (entry) => discoverChild(fs, entry))
|
||||
return targets.flatMap(Option.toArray).map((target): Operation => ({ type: "add", target, options: {} }))
|
||||
})
|
||||
}
|
||||
|
||||
const sourceDirectories = ["plugin", "plugins"] as const
|
||||
function discoverChild(fs: FSUtil.Interface, entry: FSUtil.DirEntry & { target: string }) {
|
||||
return Effect.gen(function* () {
|
||||
const source = entry.target.endsWith(".ts") || entry.target.endsWith(".js")
|
||||
if (entry.type === "file" && source) return Option.some(entry.target)
|
||||
if (entry.type === "directory") return yield* discoverPackage(fs, entry.target)
|
||||
if (entry.type !== "symlink") return Option.none<string>()
|
||||
if (source && (yield* fs.isFile(entry.target))) return Option.some(entry.target)
|
||||
if (yield* fs.isDir(entry.target)) return yield* discoverPackage(fs, entry.target)
|
||||
return Option.none<string>()
|
||||
})
|
||||
}
|
||||
|
||||
function discoverPackage(fs: FSUtil.Interface, directory: string) {
|
||||
return Effect.gen(function* () {
|
||||
const root = yield* fs.resolve(directory)
|
||||
const manifest = yield* fs
|
||||
.readJson(path.join(directory, "package.json"))
|
||||
.pipe(Effect.map(decodePackage), Effect.orElseSucceed(Option.none))
|
||||
const configured = Option.isSome(manifest)
|
||||
? [manifest.value.exports, manifest.value.module, manifest.value.main].filter(Predicate.isString)
|
||||
: []
|
||||
return yield* Effect.findFirst(
|
||||
[...configured, "index.ts", "index.js"]
|
||||
.filter((entry) => !path.isAbsolute(entry))
|
||||
.map((entry) => path.resolve(directory, entry))
|
||||
.filter((entry) => FSUtil.contains(directory, entry)),
|
||||
(entry) =>
|
||||
fs
|
||||
.isFile(entry)
|
||||
.pipe(
|
||||
Effect.flatMap((exists) =>
|
||||
exists
|
||||
? fs.resolve(entry).pipe(Effect.map((resolved) => FSUtil.contains(root, resolved)))
|
||||
: Effect.succeed(false),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function isPluginSource(entries: readonly Entry[], file: string) {
|
||||
return entries.some(
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
export * as Database from "./database"
|
||||
|
||||
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
||||
import { sqliteLayer } from "#sqlite"
|
||||
import { sqliteLayer, supportsForeignKeyToggle, supportsTuningPragmas } from "#sqlite"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import type { SqlClient } from "effect/unstable/sql"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { isAbsolute, join } from "path"
|
||||
import { DatabaseMigration } from "./migration"
|
||||
@@ -27,12 +28,15 @@ const databaseLayer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDatabase
|
||||
|
||||
yield* db.run("PRAGMA journal_mode = WAL")
|
||||
yield* db.run("PRAGMA synchronous = NORMAL")
|
||||
yield* db.run("PRAGMA busy_timeout = 5000")
|
||||
yield* db.run("PRAGMA cache_size = -64000")
|
||||
yield* db.run("PRAGMA foreign_keys = ON")
|
||||
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
|
||||
if (supportsTuningPragmas) {
|
||||
yield* db.run("PRAGMA journal_mode = WAL")
|
||||
yield* db.run("PRAGMA synchronous = NORMAL")
|
||||
yield* db.run("PRAGMA busy_timeout = 5000")
|
||||
yield* db.run("PRAGMA cache_size = -64000")
|
||||
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
|
||||
}
|
||||
// Durable Object SQLite always enforces foreign keys and rejects the pragma.
|
||||
if (supportsForeignKeyToggle) yield* db.run("PRAGMA foreign_keys = ON")
|
||||
yield* DatabaseMigration.apply(db)
|
||||
|
||||
return { db }
|
||||
@@ -42,15 +46,21 @@ const databaseLayer = Layer.effect(
|
||||
export function layer(options: Options = { path: ":memory:" }) {
|
||||
return Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const global = yield* Global.Service
|
||||
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
|
||||
const provide = (filename: string) => layerFromClient.pipe(Layer.provide(sqliteLayer({ filename })))
|
||||
const filename = options.path ?? ":memory:"
|
||||
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
|
||||
const global = yield* Global.Service
|
||||
return provide(join(global.data, filename))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// The database service over an injected SqlClient, for runtimes that receive
|
||||
// database storage instead of opening a filesystem path. Any client provided
|
||||
// here still goes through the pragma guards and migrations; Global is required
|
||||
// because migrations may read it (the v1 import).
|
||||
export const layerFromClient: Layer.Layer<Service, never, SqlClient.SqlClient | Global.Service> = databaseLayer
|
||||
|
||||
export function configured(options?: Options) {
|
||||
return makeGlobalNode({ service: Service, layer: layer(options), deps: [Global.node] })
|
||||
}
|
||||
|
||||
+86
-45
@@ -1,47 +1,88 @@
|
||||
import type { DatabaseMigration } from "./migration"
|
||||
import m00 from "./migration/20260127222353_familiar_lady_ursula"
|
||||
import m01 from "./migration/20260211171708_add_project_commands"
|
||||
import m02 from "./migration/20260213144116_wakeful_the_professor"
|
||||
import m03 from "./migration/20260225215848_workspace"
|
||||
import m04 from "./migration/20260227213759_add_session_workspace_id"
|
||||
import m05 from "./migration/20260228203230_blue_harpoon"
|
||||
import m06 from "./migration/20260303231226_add_workspace_fields"
|
||||
import m07 from "./migration/20260309230000_move_org_to_state"
|
||||
import m08 from "./migration/20260312043431_session_message_cursor"
|
||||
import m09 from "./migration/20260323234822_events"
|
||||
import m10 from "./migration/20260410174513_workspace-name"
|
||||
import m11 from "./migration/20260413175956_chief_energizer"
|
||||
import m12 from "./migration/20260423070820_add_icon_url_override"
|
||||
import m13 from "./migration/20260427172553_slow_nightmare"
|
||||
import m14 from "./migration/20260428004200_add_session_path"
|
||||
import m15 from "./migration/20260501142318_next_venus"
|
||||
import m16 from "./migration/20260504145000_add_sync_owner"
|
||||
import m17 from "./migration/20260507164347_add_workspace_time"
|
||||
import m18 from "./migration/20260510033149_session_usage"
|
||||
import m19 from "./migration/20260511000411_data_migration_state"
|
||||
import m20 from "./migration/20260511173437_session-metadata"
|
||||
import m21 from "./migration/20260601010001_normalize_storage_paths"
|
||||
import m22 from "./migration/20260601202201_amazing_prowler"
|
||||
import m23 from "./migration/20260602002951_lowly_union_jack"
|
||||
import m24 from "./migration/20260602182828_add_project_directories"
|
||||
import m25 from "./migration/20260603001617_session_message_projection_indexes"
|
||||
import m26 from "./migration/20260603040000_session_message_projection_order"
|
||||
import m27 from "./migration/20260603141458_session_input_inbox"
|
||||
import m28 from "./migration/20260603160727_jittery_ezekiel_stane"
|
||||
import m29 from "./migration/20260604172448_event_sourced_session_input"
|
||||
import m30 from "./migration/20260605003541_add_session_context_snapshot"
|
||||
import m31 from "./migration/20260605042240_add_context_epoch_agent"
|
||||
import m32 from "./migration/20260611035744_credential"
|
||||
import m33 from "./migration/20260611192811_lush_chimera"
|
||||
import m34 from "./migration/20260612174303_project_dir_strategy"
|
||||
import m35 from "./migration/20260622142730_simplify_session_context_epoch"
|
||||
import m36 from "./migration/20260622170816_reset_v2_session_state"
|
||||
import m37 from "./migration/20260622202450_simplify_session_input"
|
||||
import m38 from "./migration/20260804233008_loose_psylocke"
|
||||
import m39 from "./migration/20260805200742_import_legacy_credentials"
|
||||
import m40 from "./migration/20260808023530_workspace_domain"
|
||||
import m41 from "./migration/20260811161259_execution_claim_attempts"
|
||||
|
||||
export const migrations: DatabaseMigration.Migration[] = (
|
||||
await Promise.all([
|
||||
import("./migration/20260127222353_familiar_lady_ursula"),
|
||||
import("./migration/20260211171708_add_project_commands"),
|
||||
import("./migration/20260213144116_wakeful_the_professor"),
|
||||
import("./migration/20260225215848_workspace"),
|
||||
import("./migration/20260227213759_add_session_workspace_id"),
|
||||
import("./migration/20260228203230_blue_harpoon"),
|
||||
import("./migration/20260303231226_add_workspace_fields"),
|
||||
import("./migration/20260309230000_move_org_to_state"),
|
||||
import("./migration/20260312043431_session_message_cursor"),
|
||||
import("./migration/20260323234822_events"),
|
||||
import("./migration/20260410174513_workspace-name"),
|
||||
import("./migration/20260413175956_chief_energizer"),
|
||||
import("./migration/20260423070820_add_icon_url_override"),
|
||||
import("./migration/20260427172553_slow_nightmare"),
|
||||
import("./migration/20260428004200_add_session_path"),
|
||||
import("./migration/20260501142318_next_venus"),
|
||||
import("./migration/20260504145000_add_sync_owner"),
|
||||
import("./migration/20260507164347_add_workspace_time"),
|
||||
import("./migration/20260510033149_session_usage"),
|
||||
import("./migration/20260511000411_data_migration_state"),
|
||||
import("./migration/20260511173437_session-metadata"),
|
||||
import("./migration/20260601010001_normalize_storage_paths"),
|
||||
import("./migration/20260601202201_amazing_prowler"),
|
||||
import("./migration/20260602002951_lowly_union_jack"),
|
||||
import("./migration/20260602182828_add_project_directories"),
|
||||
import("./migration/20260603001617_session_message_projection_indexes"),
|
||||
import("./migration/20260603040000_session_message_projection_order"),
|
||||
import("./migration/20260603141458_session_input_inbox"),
|
||||
import("./migration/20260603160727_jittery_ezekiel_stane"),
|
||||
import("./migration/20260604172448_event_sourced_session_input"),
|
||||
import("./migration/20260605003541_add_session_context_snapshot"),
|
||||
import("./migration/20260605042240_add_context_epoch_agent"),
|
||||
import("./migration/20260611035744_credential"),
|
||||
import("./migration/20260611192811_lush_chimera"),
|
||||
import("./migration/20260612174303_project_dir_strategy"),
|
||||
import("./migration/20260622142730_simplify_session_context_epoch"),
|
||||
import("./migration/20260622170816_reset_v2_session_state"),
|
||||
import("./migration/20260622202450_simplify_session_input"),
|
||||
import("./migration/20260804233008_loose_psylocke"),
|
||||
import("./migration/20260805200742_import_legacy_credentials"),
|
||||
import("./migration/20260808023530_workspace_domain"),
|
||||
])
|
||||
).map((module) => module.default)
|
||||
export const migrations = [
|
||||
m00,
|
||||
m01,
|
||||
m02,
|
||||
m03,
|
||||
m04,
|
||||
m05,
|
||||
m06,
|
||||
m07,
|
||||
m08,
|
||||
m09,
|
||||
m10,
|
||||
m11,
|
||||
m12,
|
||||
m13,
|
||||
m14,
|
||||
m15,
|
||||
m16,
|
||||
m17,
|
||||
m18,
|
||||
m19,
|
||||
m20,
|
||||
m21,
|
||||
m22,
|
||||
m23,
|
||||
m24,
|
||||
m25,
|
||||
m26,
|
||||
m27,
|
||||
m28,
|
||||
m29,
|
||||
m30,
|
||||
m31,
|
||||
m32,
|
||||
m33,
|
||||
m34,
|
||||
m35,
|
||||
m36,
|
||||
m37,
|
||||
m38,
|
||||
m39,
|
||||
m40,
|
||||
m41,
|
||||
] satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -2,6 +2,7 @@ export * as DatabaseMigration from "./migration"
|
||||
|
||||
import { sql } from "drizzle-orm"
|
||||
import { Effect, Semaphore } from "effect"
|
||||
import { supportsForeignKeyToggle } from "#sqlite"
|
||||
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
||||
import { migrations } from "./migration.gen"
|
||||
import schema from "./schema.gen"
|
||||
@@ -20,8 +21,10 @@ export type Migration = {
|
||||
export function apply(db: Database) {
|
||||
return lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
// OpenCode owns the unprefixed table namespace. Embedders sharing this
|
||||
// database may own underscore-prefixed tables, which bootstrap ignores.
|
||||
const tables = yield* db.all<{ name: string }>(
|
||||
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`,
|
||||
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND substr(name, 1, 1) <> '_'`,
|
||||
)
|
||||
if (tables.some((table) => table.name === "session" || table.name === "session_v2"))
|
||||
return yield* applyOnly(db, migrations)
|
||||
@@ -103,9 +106,15 @@ export function applyOnly(db: Database, input: Migration[]) {
|
||||
})
|
||||
continue
|
||||
}
|
||||
yield* db.run(sql`PRAGMA foreign_keys = OFF`)
|
||||
// Durable Object SQLite rejects the foreign_keys toggle; the closest
|
||||
// allowlisted relaxation is deferring enforcement to transaction commit.
|
||||
const relaxForeignKeys = supportsForeignKeyToggle
|
||||
? db.run(sql`PRAGMA foreign_keys = OFF`)
|
||||
: db.run(sql`PRAGMA defer_foreign_keys = ON`)
|
||||
const restoreForeignKeys = supportsForeignKeyToggle ? db.run(sql`PRAGMA foreign_keys = ON`) : Effect.void
|
||||
yield* relaxForeignKeys
|
||||
yield* apply.pipe(
|
||||
Effect.ensuring(db.run(sql`PRAGMA foreign_keys = ON`).pipe(Effect.orDie)),
|
||||
Effect.ensuring(restoreForeignKeys.pipe(Effect.orDie)),
|
||||
Effect.tapError((error) =>
|
||||
Effect.logError("database migration failed", {
|
||||
migration: migration.id,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260811161259_execution_claim_attempts",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`session_v2\` ADD \`resume_attempts\` integer DEFAULT 0 NOT NULL;`)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default migration
|
||||
@@ -200,6 +200,7 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
|
||||
\`time_compacting\` integer,
|
||||
\`time_archived\` integer,
|
||||
\`time_suspended\` integer,
|
||||
\`resume_attempts\` integer DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT \`fk_session_v2_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
|
||||
@@ -1,58 +1,37 @@
|
||||
import { Database } from "bun:sqlite"
|
||||
import { Database, type SQLQueryBindings } from "bun:sqlite"
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite"
|
||||
import { Context, Effect, Fiber, Layer, Scope, Semaphore, Stream } from "effect"
|
||||
import { identity } from "effect/Function"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Reactivity } from "effect/unstable/reactivity"
|
||||
import { SqlClient, Statement } from "effect/unstable/sql"
|
||||
import type { Connection } from "effect/unstable/sql/SqlConnection"
|
||||
import { SqlClient } from "effect/unstable/sql"
|
||||
import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"
|
||||
import { Sqlite } from "./sqlite"
|
||||
|
||||
const ATTR_DB_SYSTEM_NAME = "db.system.name"
|
||||
|
||||
const TypeId = "~@opencode-ai/core/database/SqliteBun" as const
|
||||
type TypeId = typeof TypeId
|
||||
|
||||
interface SqliteClient extends SqlClient.SqlClient {
|
||||
readonly [TypeId]: TypeId
|
||||
readonly config: Config
|
||||
readonly export: Effect.Effect<Uint8Array, SqlError>
|
||||
readonly loadExtension: (path: string) => Effect.Effect<void, SqlError>
|
||||
readonly updateValues: never
|
||||
}
|
||||
export const supportsTuningPragmas = true
|
||||
|
||||
interface Config {
|
||||
// Foreign keys default OFF and can be toggled per connection.
|
||||
export const supportsForeignKeyToggle = true
|
||||
|
||||
interface Config extends Sqlite.ClientConfig {
|
||||
readonly filename: string
|
||||
readonly readonly?: boolean
|
||||
readonly create?: boolean
|
||||
readonly readwrite?: boolean
|
||||
readonly disableWAL?: boolean
|
||||
readonly spanAttributes?: Record<string, unknown>
|
||||
readonly transformResultNames?: (str: string) => string
|
||||
readonly transformQueryNames?: (str: string) => string
|
||||
}
|
||||
|
||||
interface SqliteConnection extends Connection {
|
||||
readonly export: Effect.Effect<Uint8Array, SqlError>
|
||||
readonly loadExtension: (path: string) => Effect.Effect<void, SqlError>
|
||||
}
|
||||
|
||||
const make = (options: Config) =>
|
||||
Effect.gen(function* () {
|
||||
const native = (yield* Sqlite.Native) as Database
|
||||
|
||||
const compiler = Statement.makeCompilerSqlite(options.transformQueryNames)
|
||||
const transformRows = options.transformResultNames
|
||||
? Statement.defaultTransforms(options.transformResultNames).array
|
||||
: undefined
|
||||
|
||||
const run = (query: string, params: ReadonlyArray<unknown> = []) =>
|
||||
Effect.withFiber<Array<Record<string, unknown>>, SqlError>((fiber) => {
|
||||
const statement = native.query(query)
|
||||
const statement = native.query<Record<string, unknown>, SQLQueryBindings[]>(query)
|
||||
// @ts-ignore bun-types missing safeIntegers method, fixed in https://github.com/oven-sh/bun/pull/26627
|
||||
statement.safeIntegers(Context.get(fiber.context, SqlClient.SafeIntegers))
|
||||
try {
|
||||
return Effect.succeed((statement.all(...(params as any)) ?? []) as Array<Record<string, unknown>>)
|
||||
return Effect.succeed(statement.all(...(params as SQLQueryBindings[])) ?? [])
|
||||
} catch (cause) {
|
||||
return Effect.fail(
|
||||
new SqlError({
|
||||
@@ -64,11 +43,11 @@ const make = (options: Config) =>
|
||||
|
||||
const runValues = (query: string, params: ReadonlyArray<unknown> = []) =>
|
||||
Effect.withFiber<Array<unknown[]>, SqlError>((fiber) => {
|
||||
const statement = native.query(query)
|
||||
const statement = native.query<unknown, SQLQueryBindings[]>(query)
|
||||
// @ts-ignore bun-types missing safeIntegers method, fixed in https://github.com/oven-sh/bun/pull/26627
|
||||
statement.safeIntegers(Context.get(fiber.context, SqlClient.SafeIntegers))
|
||||
try {
|
||||
return Effect.succeed((statement.values(...(params as any)) ?? []) as Array<unknown[]>)
|
||||
return Effect.succeed(statement.values(...(params as SQLQueryBindings[])) ?? [])
|
||||
} catch (cause) {
|
||||
return Effect.fail(
|
||||
new SqlError({
|
||||
@@ -78,25 +57,7 @@ const make = (options: Config) =>
|
||||
}
|
||||
})
|
||||
|
||||
const connection = identity<SqliteConnection>({
|
||||
execute(query, params, transformRows) {
|
||||
return transformRows ? Effect.map(run(query, params), transformRows) : run(query, params)
|
||||
},
|
||||
executeRaw(query, params) {
|
||||
return run(query, params)
|
||||
},
|
||||
executeValues(query, params) {
|
||||
return runValues(query, params)
|
||||
},
|
||||
executeValuesUnprepared(query, params) {
|
||||
return runValues(query, params)
|
||||
},
|
||||
executeUnprepared(query, params, transformRows) {
|
||||
return this.execute(query, params, transformRows)
|
||||
},
|
||||
executeStream() {
|
||||
return Stream.die("executeStream not implemented")
|
||||
},
|
||||
const connection = Sqlite.makeConnection(run, runValues, {
|
||||
export: Effect.try({
|
||||
try: () => native.serialize(),
|
||||
catch: (cause) =>
|
||||
@@ -104,7 +65,7 @@ const make = (options: Config) =>
|
||||
reason: classifySqliteError(cause, { message: "Failed to export database", operation: "export" }),
|
||||
}),
|
||||
}),
|
||||
loadExtension: (path) =>
|
||||
loadExtension: (path: string) =>
|
||||
Effect.try({
|
||||
try: () => native.loadExtension(path),
|
||||
catch: (cause) =>
|
||||
@@ -114,37 +75,10 @@ const make = (options: Config) =>
|
||||
}),
|
||||
})
|
||||
|
||||
const semaphore = yield* Semaphore.make(1)
|
||||
const acquirer = semaphore.withPermits(1)(Effect.succeed(connection))
|
||||
const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
|
||||
const fiber = Fiber.getCurrent()!
|
||||
const scope = Context.getUnsafe(fiber.context, Scope.Scope)
|
||||
return Effect.as(
|
||||
Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))),
|
||||
connection,
|
||||
)
|
||||
})
|
||||
|
||||
const client = Object.assign(
|
||||
(yield* SqlClient.make({
|
||||
acquirer,
|
||||
compiler,
|
||||
transactionAcquirer,
|
||||
spanAttributes: [
|
||||
...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
|
||||
[ATTR_DB_SYSTEM_NAME, "sqlite"],
|
||||
],
|
||||
transformRows,
|
||||
})) as SqliteClient,
|
||||
{
|
||||
[TypeId]: TypeId,
|
||||
config: options,
|
||||
export: Effect.flatMap(acquirer, (_) => _.export),
|
||||
loadExtension: (path: string) => Effect.flatMap(acquirer, (_) => _.loadExtension(path)),
|
||||
},
|
||||
)
|
||||
|
||||
return client
|
||||
return yield* Sqlite.makeClient(options, connection, TypeId, (acquirer) => ({
|
||||
export: Effect.flatMap(acquirer, (_) => _.export),
|
||||
loadExtension: (path: string) => Effect.flatMap(acquirer, (_) => _.loadExtension(path)),
|
||||
}))
|
||||
})
|
||||
|
||||
const nativeLayer = (config: Config) =>
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
import { DatabaseSync, type SQLInputValue } from "node:sqlite"
|
||||
import { drizzle } from "drizzle-orm/node-sqlite"
|
||||
import { Context, Effect, Fiber, Layer, Scope, Semaphore, Stream } from "effect"
|
||||
import { identity } from "effect/Function"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Reactivity } from "effect/unstable/reactivity"
|
||||
import { SqlClient, Statement } from "effect/unstable/sql"
|
||||
import type { Connection } from "effect/unstable/sql/SqlConnection"
|
||||
import { SqlClient } from "effect/unstable/sql"
|
||||
import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"
|
||||
import { Sqlite } from "./sqlite"
|
||||
|
||||
const ATTR_DB_SYSTEM_NAME = "db.system.name"
|
||||
|
||||
const TypeId = "~@opencode-ai/core/database/SqliteNode" as const
|
||||
type TypeId = typeof TypeId
|
||||
|
||||
interface SqliteClient extends SqlClient.SqlClient {
|
||||
readonly [TypeId]: TypeId
|
||||
readonly config: Config
|
||||
readonly loadExtension: (path: string) => Effect.Effect<void, SqlError>
|
||||
readonly updateValues: never
|
||||
}
|
||||
export const supportsTuningPragmas = true
|
||||
|
||||
interface Config {
|
||||
// Foreign keys default OFF and can be toggled per connection.
|
||||
export const supportsForeignKeyToggle = true
|
||||
|
||||
interface Config extends Sqlite.ClientConfig {
|
||||
readonly filename: string
|
||||
readonly readonly?: boolean
|
||||
readonly create?: boolean
|
||||
@@ -28,24 +21,12 @@ interface Config {
|
||||
readonly disableWAL?: boolean
|
||||
readonly timeout?: number
|
||||
readonly allowExtension?: boolean
|
||||
readonly spanAttributes?: Record<string, unknown>
|
||||
readonly transformResultNames?: (str: string) => string
|
||||
readonly transformQueryNames?: (str: string) => string
|
||||
}
|
||||
|
||||
interface SqliteConnection extends Connection {
|
||||
readonly loadExtension: (path: string) => Effect.Effect<void, SqlError>
|
||||
}
|
||||
|
||||
const make = (options: Config) =>
|
||||
Effect.gen(function* () {
|
||||
const native = (yield* Sqlite.Native) as DatabaseSync
|
||||
|
||||
const compiler = Statement.makeCompilerSqlite(options.transformQueryNames)
|
||||
const transformRows = options.transformResultNames
|
||||
? Statement.defaultTransforms(options.transformResultNames).array
|
||||
: undefined
|
||||
|
||||
const run = (query: string, params: ReadonlyArray<unknown> = []) =>
|
||||
Effect.withFiber<Array<Record<string, unknown>>, SqlError>((fiber) => {
|
||||
const statement = native.prepare(query)
|
||||
@@ -79,26 +60,8 @@ const make = (options: Config) =>
|
||||
}
|
||||
})
|
||||
|
||||
const connection = identity<SqliteConnection>({
|
||||
execute(query, params, transformRows) {
|
||||
return transformRows ? Effect.map(run(query, params), transformRows) : run(query, params)
|
||||
},
|
||||
executeRaw(query, params) {
|
||||
return run(query, params)
|
||||
},
|
||||
executeValues(query, params) {
|
||||
return runValues(query, params)
|
||||
},
|
||||
executeValuesUnprepared(query, params) {
|
||||
return runValues(query, params)
|
||||
},
|
||||
executeUnprepared(query, params, transformRows) {
|
||||
return this.execute(query, params, transformRows)
|
||||
},
|
||||
executeStream() {
|
||||
return Stream.die("executeStream not implemented")
|
||||
},
|
||||
loadExtension: (path) =>
|
||||
const connection = Sqlite.makeConnection(run, runValues, {
|
||||
loadExtension: (path: string) =>
|
||||
Effect.try({
|
||||
try: () => native.loadExtension(path),
|
||||
catch: (cause) =>
|
||||
@@ -108,36 +71,9 @@ const make = (options: Config) =>
|
||||
}),
|
||||
})
|
||||
|
||||
const semaphore = yield* Semaphore.make(1)
|
||||
const acquirer = semaphore.withPermits(1)(Effect.succeed(connection))
|
||||
const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
|
||||
const fiber = Fiber.getCurrent()!
|
||||
const scope = Context.getUnsafe(fiber.context, Scope.Scope)
|
||||
return Effect.as(
|
||||
Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))),
|
||||
connection,
|
||||
)
|
||||
})
|
||||
|
||||
const client = Object.assign(
|
||||
(yield* SqlClient.make({
|
||||
acquirer,
|
||||
compiler,
|
||||
transactionAcquirer,
|
||||
spanAttributes: [
|
||||
...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
|
||||
[ATTR_DB_SYSTEM_NAME, "sqlite"],
|
||||
],
|
||||
transformRows,
|
||||
})) as SqliteClient,
|
||||
{
|
||||
[TypeId]: TypeId,
|
||||
config: options,
|
||||
loadExtension: (path: string) => Effect.flatMap(acquirer, (_) => _.loadExtension(path)),
|
||||
},
|
||||
)
|
||||
|
||||
return client
|
||||
return yield* Sqlite.makeClient(options, connection, TypeId, (acquirer) => ({
|
||||
loadExtension: (path: string) => Effect.flatMap(acquirer, (_) => _.loadExtension(path)),
|
||||
}))
|
||||
})
|
||||
|
||||
const nativeLayer = (config: Config) =>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user