mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 17:19:49 -04:00
Compare commits
188 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75e4b8eb99 | |||
| 6697cf3fd8 | |||
| 87aa62d1e2 | |||
| 3136b1ba97 | |||
| 246dea3a58 | |||
| 4b1c7f28a0 | |||
| 9410eda4e3 | |||
| f014686b07 | |||
| 8b68dc0d7f | |||
| 2f4d36d64e | |||
| 6f2ff35656 | |||
| cd00422298 | |||
| 34ee94232f | |||
| 4d91f0cf28 | |||
| bf18cc971f | |||
| 5e1a1ed2e2 | |||
| bd7f7ad98b | |||
| af72cec799 | |||
| 55552c521f | |||
| 2b611a5b14 | |||
| 45a437cbb2 | |||
| 439ec9b76f | |||
| ae3fefb42a | |||
| f1407e41c4 | |||
| c88ca972cf | |||
| 928c37bc02 | |||
| afff74eb2c | |||
| 3a669d528b | |||
| 6789214b31 | |||
| 6636683323 | |||
| 3af9f64265 | |||
| 5a23bdcb69 | |||
| e78726854a | |||
| 472d0f376e | |||
| c8fde60ad4 | |||
| a4b6047e64 | |||
| 6387f955cf | |||
| 5691f36e98 | |||
| 74e7a378bb | |||
| fced9c5a2f | |||
| 20445ca031 | |||
| 6fc9405618 | |||
| c86066efef | |||
| fa9ba2938e | |||
| 63e24880e5 | |||
| eaf42c4ab0 | |||
| 8e10ab0aa7 | |||
| b329fcceb2 | |||
| bd83177c48 | |||
| 06cb0de0b1 | |||
| 3ca89ac796 | |||
| 3aa2860058 | |||
| ad63cf1590 | |||
| aa56750f8b | |||
| 4aaed42640 | |||
| f69f677a32 | |||
| 8289883de8 | |||
| 003c22b4a3 | |||
| 1982d9832b | |||
| 90f0576222 | |||
| e34822db6b | |||
| b19ae6547f | |||
| 6b074ed718 | |||
| e687eb936b | |||
| 797cb530e5 | |||
| 0a5bed2bc2 | |||
| ba41dadc91 | |||
| 451876b0bd | |||
| 91561bbdfb | |||
| 018ce34b39 | |||
| ce82b21073 | |||
| 1f37c26574 | |||
| cb3e28d207 | |||
| 8f1e13f299 | |||
| 2fe68b5e91 | |||
| 2ace237938 | |||
| 5b62211167 | |||
| 60b6229c4e | |||
| 4caec80e67 | |||
| e9d4ca7e0f | |||
| d71454c701 | |||
| 884c256033 | |||
| 78235385dd | |||
| fd213e6df6 | |||
| 3726052307 | |||
| 7d33a6f7c9 | |||
| 7a035d7fc0 | |||
| 9151af7045 | |||
| 15bcbb1d7a | |||
| 4d3294727c | |||
| aae0e89519 | |||
| 93a7b4ab76 | |||
| 0ebe74b625 | |||
| b9dae8593c | |||
| d8f9388610 | |||
| be14739cce | |||
| 762588c251 | |||
| d6e54e9042 | |||
| d4fd528152 | |||
| de185559dd | |||
| bc5ce5eab1 | |||
| b10d617c80 | |||
| 18466b8020 | |||
| 71ec022b47 | |||
| f7eeb08942 | |||
| 9205dfe724 | |||
| a3776429aa | |||
| c0e43c0c65 | |||
| 08c5a2a5e8 | |||
| 7077c70d60 | |||
| 1fd8bf526d | |||
| 6d9539f469 | |||
| e5101d9651 | |||
| b0151e1d02 | |||
| c3637753bc | |||
| 48fc9e3cc3 | |||
| 82a482b36d | |||
| 7fac84319d | |||
| 0a5e617da8 | |||
| b5f92c9f48 | |||
| 2070fd9bc2 | |||
| 48dc05eee7 | |||
| 84bb706537 | |||
| be8cfa7e08 | |||
| 56a789d926 | |||
| 5409151ad4 | |||
| f90d154465 | |||
| 078385d386 | |||
| beaaa174ea | |||
| fb59606bb4 | |||
| 846d548154 | |||
| 84336e4f91 | |||
| 01a5c69244 | |||
| d78f91afeb | |||
| 33762292f7 | |||
| 5d6aa3b41a | |||
| 683aca5dbe | |||
| 92025e9a47 | |||
| 411e053572 | |||
| b862d178bf | |||
| bda0ddc207 | |||
| 58ba99e505 | |||
| 6ee817d041 | |||
| dfeb1b5051 | |||
| 61a7f6db35 | |||
| 6446b8ae3b | |||
| ae53163cad | |||
| 41202819a4 | |||
| a31698f99b | |||
| 6248542c49 | |||
| d25c91e5eb | |||
| 5d63020dcd | |||
| 062f54590e | |||
| a76c6918d2 | |||
| 2b91a6f210 | |||
| 10579cceb2 | |||
| 25702e01ce | |||
| ecc5c44d9a | |||
| f5a0b920a2 | |||
| 2caa016fe1 | |||
| 6861fedd09 | |||
| 3d072112ce | |||
| bdfea046db | |||
| a2d08fb63b | |||
| 5a55135d89 | |||
| 8870d36e0f | |||
| eb923c27ca | |||
| 9903abc704 | |||
| 225a1fbf35 | |||
| 93159bccbf | |||
| fab8ec4f54 | |||
| a3035c53ea | |||
| 36c416e143 | |||
| e1e0304a96 | |||
| 71c3a7c8f2 | |||
| ecdfff5a42 | |||
| 4b948c5d74 | |||
| cd56c51e2d | |||
| 43e39d7f68 | |||
| 7a17925495 | |||
| 5acb2530b4 | |||
| 44a6787359 | |||
| 42e6b7db32 | |||
| 2c02f8bace | |||
| 2ec20e576b | |||
| 20f47fec7a | |||
| 65210f2d97 | |||
| af0b7ffae7 |
@@ -34,10 +34,48 @@ jobs:
|
|||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const twoHours = 2 * 60 * 60 * 1000;
|
const twoHours = 2 * 60 * 60 * 1000;
|
||||||
|
const orgMemberAssociations = new Set(['OWNER', 'MEMBER']);
|
||||||
|
const agentLogin = 'opencode-agent[bot]';
|
||||||
|
const { data: file } = await github.rest.repos.getContent({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
path: '.github/TEAM_MEMBERS',
|
||||||
|
ref: 'dev',
|
||||||
|
});
|
||||||
|
const teamMembers = new Set(
|
||||||
|
Buffer.from(file.content, 'base64')
|
||||||
|
.toString()
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim().toLowerCase())
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
|
function isExempt(item) {
|
||||||
|
const login = item.user?.login?.toLowerCase();
|
||||||
|
return (
|
||||||
|
login === agentLogin ||
|
||||||
|
orgMemberAssociations.has(item.author_association) ||
|
||||||
|
(login && teamMembers.has(login))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const isPR = !!item.pull_request;
|
const isPR = !!item.pull_request;
|
||||||
const kind = isPR ? 'PR' : 'issue';
|
const kind = isPR ? 'PR' : 'issue';
|
||||||
|
const login = item.user?.login;
|
||||||
|
|
||||||
|
if (isExempt(item)) {
|
||||||
|
core.info(`Skipping ${kind} #${item.number}; author ${login || 'unknown'} is exempt`);
|
||||||
|
try {
|
||||||
|
await github.rest.issues.removeLabel({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: item.number,
|
||||||
|
name: 'needs:compliance',
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const { data: comments } = await github.rest.issues.listComments({
|
const { data: comments } = await github.rest.issues.listComments({
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
|
|||||||
@@ -17,12 +17,31 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Check exempt issue author
|
||||||
|
id: author
|
||||||
|
run: |
|
||||||
|
LOGIN="${{ github.event.issue.user.login }}"
|
||||||
|
ASSOCIATION="${{ github.event.issue.author_association }}"
|
||||||
|
|
||||||
|
if [ "$LOGIN" = "opencode-agent[bot]" ] ||
|
||||||
|
[ "$ASSOCIATION" = "OWNER" ] ||
|
||||||
|
[ "$ASSOCIATION" = "MEMBER" ] ||
|
||||||
|
grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then
|
||||||
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)"
|
||||||
|
else
|
||||||
|
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
- uses: ./.github/actions/setup-bun
|
- uses: ./.github/actions/setup-bun
|
||||||
|
if: steps.author.outputs.skip != 'true'
|
||||||
|
|
||||||
- name: Install opencode
|
- name: Install opencode
|
||||||
|
if: steps.author.outputs.skip != 'true'
|
||||||
run: curl -fsSL https://opencode.ai/install | bash
|
run: curl -fsSL https://opencode.ai/install | bash
|
||||||
|
|
||||||
- name: Check duplicates and compliance
|
- name: Check duplicates and compliance
|
||||||
|
if: steps.author.outputs.skip != 'true'
|
||||||
env:
|
env:
|
||||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -38,6 +57,7 @@ jobs:
|
|||||||
opencode run -m opencode/claude-sonnet-4-6 "A new issue has been created:
|
opencode run -m opencode/claude-sonnet-4-6 "A new issue has been created:
|
||||||
|
|
||||||
Issue number: ${{ github.event.issue.number }}
|
Issue number: ${{ github.event.issue.number }}
|
||||||
|
Issue author association: ${{ github.event.issue.author_association }}
|
||||||
|
|
||||||
Lookup this issue with gh issue view ${{ github.event.issue.number }}.
|
Lookup this issue with gh issue view ${{ github.event.issue.number }}.
|
||||||
|
|
||||||
@@ -49,6 +69,8 @@ jobs:
|
|||||||
|
|
||||||
Check whether the issue follows our contributing guidelines and issue templates.
|
Check whether the issue follows our contributing guidelines and issue templates.
|
||||||
|
|
||||||
|
If the issue author association is OWNER or MEMBER, skip this compliance check. Do not add the needs:compliance label for organization-owned issues.
|
||||||
|
|
||||||
This project has three issue templates that every issue MUST use one of:
|
This project has three issue templates that every issue MUST use one of:
|
||||||
|
|
||||||
1. Bug Report - requires a Description field with real content
|
1. Bug Report - requires a Description field with real content
|
||||||
@@ -83,7 +105,7 @@ jobs:
|
|||||||
|
|
||||||
Based on your findings, post a SINGLE comment on issue #${{ github.event.issue.number }}. Build the comment as follows:
|
Based on your findings, post a SINGLE comment on issue #${{ github.event.issue.number }}. Build the comment as follows:
|
||||||
|
|
||||||
If the issue is NOT compliant, start the comment with:
|
If the issue is NOT compliant and the author association is not OWNER or MEMBER, start the comment with:
|
||||||
<!-- issue-compliance -->
|
<!-- issue-compliance -->
|
||||||
Then explain what needs to be fixed and that they have 2 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance
|
Then explain what needs to be fixed and that they have 2 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance
|
||||||
|
|
||||||
@@ -129,12 +151,31 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Check exempt issue author
|
||||||
|
id: author
|
||||||
|
run: |
|
||||||
|
LOGIN="${{ github.event.issue.user.login }}"
|
||||||
|
ASSOCIATION="${{ github.event.issue.author_association }}"
|
||||||
|
|
||||||
|
if [ "$LOGIN" = "opencode-agent[bot]" ] ||
|
||||||
|
[ "$ASSOCIATION" = "OWNER" ] ||
|
||||||
|
[ "$ASSOCIATION" = "MEMBER" ] ||
|
||||||
|
grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then
|
||||||
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)"
|
||||||
|
else
|
||||||
|
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
- uses: ./.github/actions/setup-bun
|
- uses: ./.github/actions/setup-bun
|
||||||
|
if: steps.author.outputs.skip != 'true'
|
||||||
|
|
||||||
- name: Install opencode
|
- name: Install opencode
|
||||||
|
if: steps.author.outputs.skip != 'true'
|
||||||
run: curl -fsSL https://opencode.ai/install | bash
|
run: curl -fsSL https://opencode.ai/install | bash
|
||||||
|
|
||||||
- name: Recheck compliance
|
- name: Recheck compliance
|
||||||
|
if: steps.author.outputs.skip != 'true'
|
||||||
env:
|
env:
|
||||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -148,9 +189,12 @@ jobs:
|
|||||||
}
|
}
|
||||||
run: |
|
run: |
|
||||||
opencode run -m opencode/claude-sonnet-4-6 "Issue #${{ github.event.issue.number }} was previously flagged as non-compliant and has been edited.
|
opencode run -m opencode/claude-sonnet-4-6 "Issue #${{ github.event.issue.number }} was previously flagged as non-compliant and has been edited.
|
||||||
|
Issue author association: ${{ github.event.issue.author_association }}
|
||||||
|
|
||||||
Lookup this issue with gh issue view ${{ github.event.issue.number }}.
|
Lookup this issue with gh issue view ${{ github.event.issue.number }}.
|
||||||
|
|
||||||
|
If the issue author association is OWNER or MEMBER, remove the needs:compliance label if present, delete the previous compliance comment if present, and do not post a new comment.
|
||||||
|
|
||||||
Re-check whether the issue now follows our contributing guidelines and issue templates.
|
Re-check whether the issue now follows our contributing guidelines and issue templates.
|
||||||
|
|
||||||
This project has three issue templates that every issue MUST use one of:
|
This project has three issue templates that every issue MUST use one of:
|
||||||
|
|||||||
@@ -16,13 +16,32 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Check exempt issue author
|
||||||
|
id: author
|
||||||
|
run: |
|
||||||
|
LOGIN="${{ github.event.issue.user.login }}"
|
||||||
|
ASSOCIATION="${{ github.event.issue.author_association }}"
|
||||||
|
|
||||||
|
if [ "$LOGIN" = "opencode-agent[bot]" ] ||
|
||||||
|
[ "$ASSOCIATION" = "OWNER" ] ||
|
||||||
|
[ "$ASSOCIATION" = "MEMBER" ] ||
|
||||||
|
grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then
|
||||||
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)"
|
||||||
|
else
|
||||||
|
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
|
if: steps.author.outputs.skip != 'true'
|
||||||
uses: ./.github/actions/setup-bun
|
uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
- name: Install opencode
|
- name: Install opencode
|
||||||
|
if: steps.author.outputs.skip != 'true'
|
||||||
run: curl -fsSL https://opencode.ai/install | bash
|
run: curl -fsSL https://opencode.ai/install | bash
|
||||||
|
|
||||||
- name: Triage issue
|
- name: Triage issue
|
||||||
|
if: steps.author.outputs.skip != 'true'
|
||||||
env:
|
env:
|
||||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.17.11",
|
"version": "1.17.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/abstract": "0.5.0",
|
"@dnd-kit/abstract": "0.5.0",
|
||||||
"@dnd-kit/dom": "0.5.0",
|
"@dnd-kit/dom": "0.5.0",
|
||||||
@@ -83,6 +83,7 @@
|
|||||||
"@types/luxon": "catalog:",
|
"@types/luxon": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
|
"tw-animate-css": "1.4.0",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||||
@@ -91,7 +92,7 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@opencode-ai/cli",
|
"name": "@opencode-ai/cli",
|
||||||
"version": "1.17.11",
|
"version": "1.17.12",
|
||||||
"bin": {
|
"bin": {
|
||||||
"lildax": "./bin/lildax.cjs",
|
"lildax": "./bin/lildax.cjs",
|
||||||
},
|
},
|
||||||
@@ -139,7 +140,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.17.11",
|
"version": "1.17.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
@@ -175,7 +176,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.17.11",
|
"version": "1.17.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
@@ -202,7 +203,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.17.11",
|
"version": "1.17.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "3.0.82",
|
"@ai-sdk/anthropic": "3.0.82",
|
||||||
"@ai-sdk/openai": "3.0.48",
|
"@ai-sdk/openai": "3.0.48",
|
||||||
@@ -224,7 +225,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.17.11",
|
"version": "1.17.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
@@ -248,7 +249,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/support": {
|
"packages/console/support": {
|
||||||
"name": "@opencode-ai/console-support",
|
"name": "@opencode-ai/console-support",
|
||||||
"version": "1.17.11",
|
"version": "1.17.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@opencode-ai/console-core": "workspace:*",
|
"@opencode-ai/console-core": "workspace:*",
|
||||||
@@ -268,7 +269,7 @@
|
|||||||
},
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@opencode-ai/core",
|
"name": "@opencode-ai/core",
|
||||||
"version": "1.17.11",
|
"version": "1.17.12",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -316,6 +317,7 @@
|
|||||||
"ai-gateway-provider": "3.1.2",
|
"ai-gateway-provider": "3.1.2",
|
||||||
"bun-pty": "0.4.8",
|
"bun-pty": "0.4.8",
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
|
"diff": "catalog:",
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
@@ -332,7 +334,7 @@
|
|||||||
"npm-package-arg": "13.0.2",
|
"npm-package-arg": "13.0.2",
|
||||||
"semver": "^7.6.3",
|
"semver": "^7.6.3",
|
||||||
"turndown": "7.2.0",
|
"turndown": "7.2.0",
|
||||||
"venice-ai-sdk-provider": "2.0.2",
|
"venice-ai-sdk-provider": "2.1.1",
|
||||||
"which": "6.0.1",
|
"which": "6.0.1",
|
||||||
"xdg-basedir": "5.1.0",
|
"xdg-basedir": "5.1.0",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
@@ -361,7 +363,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.17.11",
|
"version": "1.17.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zip.js/zip.js": "2.7.62",
|
"@zip.js/zip.js": "2.7.62",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -415,7 +417,7 @@
|
|||||||
},
|
},
|
||||||
"packages/effect-drizzle-sqlite": {
|
"packages/effect-drizzle-sqlite": {
|
||||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||||
"version": "1.17.11",
|
"version": "1.17.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -429,7 +431,7 @@
|
|||||||
},
|
},
|
||||||
"packages/effect-sqlite-node": {
|
"packages/effect-sqlite-node": {
|
||||||
"name": "@opencode-ai/effect-sqlite-node",
|
"name": "@opencode-ai/effect-sqlite-node",
|
||||||
"version": "1.17.11",
|
"version": "1.17.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -441,7 +443,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.17.11",
|
"version": "1.17.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/standard-validator": "catalog:",
|
"@hono/standard-validator": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
@@ -473,7 +475,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.17.11",
|
"version": "1.17.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -489,7 +491,7 @@
|
|||||||
},
|
},
|
||||||
"packages/http-recorder": {
|
"packages/http-recorder": {
|
||||||
"name": "@opencode-ai/http-recorder",
|
"name": "@opencode-ai/http-recorder",
|
||||||
"version": "1.17.11",
|
"version": "1.17.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform-node": "4.0.0-beta.83",
|
"@effect/platform-node": "4.0.0-beta.83",
|
||||||
"@effect/platform-node-shared": "4.0.0-beta.83",
|
"@effect/platform-node-shared": "4.0.0-beta.83",
|
||||||
@@ -520,7 +522,7 @@
|
|||||||
},
|
},
|
||||||
"packages/llm": {
|
"packages/llm": {
|
||||||
"name": "@opencode-ai/llm",
|
"name": "@opencode-ai/llm",
|
||||||
"version": "1.17.11",
|
"version": "1.17.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/schema": "workspace:*",
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@smithy/eventstream-codec": "4.2.14",
|
"@smithy/eventstream-codec": "4.2.14",
|
||||||
@@ -539,7 +541,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.17.11",
|
"version": "1.17.12",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -635,7 +637,7 @@
|
|||||||
"tree-sitter-powershell": "0.25.10",
|
"tree-sitter-powershell": "0.25.10",
|
||||||
"turndown": "7.2.0",
|
"turndown": "7.2.0",
|
||||||
"ulid": "catalog:",
|
"ulid": "catalog:",
|
||||||
"venice-ai-sdk-provider": "2.0.2",
|
"venice-ai-sdk-provider": "2.1.1",
|
||||||
"vscode-jsonrpc": "8.2.1",
|
"vscode-jsonrpc": "8.2.1",
|
||||||
"web-tree-sitter": "0.25.10",
|
"web-tree-sitter": "0.25.10",
|
||||||
"ws": "8.21.0",
|
"ws": "8.21.0",
|
||||||
@@ -669,7 +671,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.17.11",
|
"version": "1.17.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/provider": "3.0.8",
|
"@ai-sdk/provider": "3.0.8",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -745,7 +747,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.17.11",
|
"version": "1.17.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -760,7 +762,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@opencode-ai/server",
|
"name": "@opencode-ai/server",
|
||||||
"version": "1.17.11",
|
"version": "1.17.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/protocol": "workspace:*",
|
"@opencode-ai/protocol": "workspace:*",
|
||||||
@@ -775,7 +777,7 @@
|
|||||||
},
|
},
|
||||||
"packages/session-ui": {
|
"packages/session-ui": {
|
||||||
"name": "@opencode-ai/session-ui",
|
"name": "@opencode-ai/session-ui",
|
||||||
"version": "1.17.11",
|
"version": "1.17.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
@@ -819,7 +821,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.17.11",
|
"version": "1.17.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -832,7 +834,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/app": {
|
"packages/stats/app": {
|
||||||
"name": "@opencode-ai/stats-app",
|
"name": "@opencode-ai/stats-app",
|
||||||
"version": "1.17.11",
|
"version": "1.17.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
"@opencode-ai/stats-core": "workspace:*",
|
"@opencode-ai/stats-core": "workspace:*",
|
||||||
@@ -865,7 +867,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/core": {
|
"packages/stats/core": {
|
||||||
"name": "@opencode-ai/stats-core",
|
"name": "@opencode-ai/stats-core",
|
||||||
"version": "1.17.11",
|
"version": "1.17.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-athena": "3.933.0",
|
"@aws-sdk/client-athena": "3.933.0",
|
||||||
"@planetscale/database": "1.19.0",
|
"@planetscale/database": "1.19.0",
|
||||||
@@ -884,7 +886,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/server": {
|
"packages/stats/server": {
|
||||||
"name": "@opencode-ai/stats-server",
|
"name": "@opencode-ai/stats-server",
|
||||||
"version": "1.17.11",
|
"version": "1.17.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-firehose": "3.933.0",
|
"@aws-sdk/client-firehose": "3.933.0",
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
@@ -925,7 +927,7 @@
|
|||||||
},
|
},
|
||||||
"packages/tui": {
|
"packages/tui": {
|
||||||
"name": "@opencode-ai/tui",
|
"name": "@opencode-ai/tui",
|
||||||
"version": "1.17.11",
|
"version": "1.17.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
@@ -952,7 +954,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.17.11",
|
"version": "1.17.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
@@ -990,6 +992,7 @@
|
|||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
|
"tw-animate-css": "1.4.0",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||||
@@ -1002,7 +1005,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.17.11",
|
"version": "1.17.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
@@ -1048,6 +1051,7 @@
|
|||||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
|
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
||||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||||
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
|
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
|
||||||
@@ -5315,6 +5319,8 @@
|
|||||||
|
|
||||||
"turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="],
|
"turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="],
|
||||||
|
|
||||||
|
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||||
|
|
||||||
"tw-to-css": ["tw-to-css@0.0.12", "", { "dependencies": { "postcss": "8.4.31", "postcss-css-variables": "0.18.0", "tailwindcss": "3.3.2" } }, "sha512-rQAsQvOtV1lBkyCw+iypMygNHrShYAItES5r8fMsrhhaj5qrV2LkZyXc8ccEH+u5bFjHjQ9iuxe90I7Kykf6pw=="],
|
"tw-to-css": ["tw-to-css@0.0.12", "", { "dependencies": { "postcss": "8.4.31", "postcss-css-variables": "0.18.0", "tailwindcss": "3.3.2" } }, "sha512-rQAsQvOtV1lBkyCw+iypMygNHrShYAItES5r8fMsrhhaj5qrV2LkZyXc8ccEH+u5bFjHjQ9iuxe90I7Kykf6pw=="],
|
||||||
|
|
||||||
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||||
@@ -5423,7 +5429,7 @@
|
|||||||
|
|
||||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||||
|
|
||||||
"venice-ai-sdk-provider": ["venice-ai-sdk-provider@2.0.2", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.47", "@ai-sdk/provider": "^3.0.10", "@ai-sdk/provider-utils": "^4.0.27" }, "peerDependencies": { "ai": "^6.0.90" } }, "sha512-aoa05nI3BTK5aGbjBflq+Gfln2AHAkwNbWuGGvCzUIsOfp5Y3iPD4O4PUGDAEiWVJWbjpPn0KfDa0H/HebwsaA=="],
|
"venice-ai-sdk-provider": ["venice-ai-sdk-provider@2.1.1", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.51", "@ai-sdk/provider": "^3.0.10", "@ai-sdk/provider-utils": "^4.0.30" }, "peerDependencies": { "ai": "^6.0.90" } }, "sha512-w3OHkuzzKZ3r2TOxER6myBYzZJNoDqol+DUHu3NnfBN/GETnUVxecZJab0CHQQ8GZc0jjzpFymepjcLDPS4SQg=="],
|
||||||
|
|
||||||
"verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="],
|
"verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="],
|
||||||
|
|
||||||
@@ -6443,11 +6449,11 @@
|
|||||||
|
|
||||||
"unzipper/fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="],
|
"unzipper/fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="],
|
||||||
|
|
||||||
"venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Enm5UlL0zUCrW3792opk5h7hRWxZOZzDe6eQYVFqX9LUOGGCe1h8MZWAGim765nwzgnjlpeYOsuzZmLtRsTPlg=="],
|
"venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.32" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SoPSkrL5cbNQnAljRsJ7pOzJ2FmWgnhC0lfFOda873ycCdFJL1A+h3Ib7mX2spcv3XnNaO13y/45/0RyqNWlIQ=="],
|
||||||
|
|
||||||
"venice-ai-sdk-provider/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
"venice-ai-sdk-provider/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||||
|
|
||||||
"venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
"venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.32", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Kwj499fTcN9bP/AfGoPU7JWIXeP6VZqKI6omsH062c9E2G4gdjeJczkz4z/tYSkzYjLE2AI3DtZbMfs6D7vn2Q=="],
|
||||||
|
|
||||||
"verror/core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
|
"verror/core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
|
||||||
|
|
||||||
@@ -7067,6 +7073,10 @@
|
|||||||
|
|
||||||
"unzipper/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
"unzipper/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||||
|
|
||||||
|
"venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider": ["@ai-sdk/provider@3.0.12", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-sj9DWTJ2Ze0WR9qsiOPqoqzNx3OxL6iMxHImbhvoe9qOspekbzxNDMiJ4TIGfYHYh9w4OmBjz3prvqhzTi96+Q=="],
|
||||||
|
|
||||||
|
"venice-ai-sdk-provider/@ai-sdk/provider-utils/@ai-sdk/provider": ["@ai-sdk/provider@3.0.12", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-sj9DWTJ2Ze0WR9qsiOPqoqzNx3OxL6iMxHImbhvoe9qOspekbzxNDMiJ4TIGfYHYh9w4OmBjz3prvqhzTi96+Q=="],
|
||||||
|
|
||||||
"venice-ai-sdk-provider/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"venice-ai-sdk-provider/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
"vitest/@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"vitest/@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-OiWvZ57vuyHwiIKNtW1n1KX+MLmOXVG3x4fLKvUoGQw=",
|
"x86_64-linux": "sha256-Ulflihjr8JDRVyNxSchoqaey6z/12256zs1FOiw+4vo=",
|
||||||
"aarch64-linux": "sha256-RnPLxVEg/UsL5IeIFWmXMSLUOG6rVrajYxhyDYj1vTA=",
|
"aarch64-linux": "sha256-wP6p30a9f7s3tua5qbIq/10OHdbJb8xUni5KxE3F+/k=",
|
||||||
"aarch64-darwin": "sha256-KPIgcBA0pTFBPrCTSZgIbvEorbtWcMgXvyX9bFAypVs=",
|
"aarch64-darwin": "sha256-7+4skFiJ+tLSjNG6WR53bT/IektA5B7gEcWLYpkK4z8=",
|
||||||
"x86_64-darwin": "sha256-6jVU7/uVId0VD24MVQ8s8Ill5b6PsKdlBgHg+oceKRg="
|
"x86_64-darwin": "sha256-NL945zD4J6HomH5SF0XIO8ZFCbIh2MTsJFtTyuxm2Q0="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -153,6 +153,7 @@
|
|||||||
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
|
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
|
||||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||||
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch"
|
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
|
||||||
|
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export async function installStressSessionTabs(page: Page, input?: { draftID?: s
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.global.dat:tabs",
|
"opencode.window.browser.dat:tabs",
|
||||||
JSON.stringify([
|
JSON.stringify([
|
||||||
...sessionIDs.map((sessionId) => ({
|
...sessionIDs.map((sessionId) => ({
|
||||||
type: "session",
|
type: "session",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ test("closing the active server's last tab opens the remaining server tab", asyn
|
|||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.global.dat:tabs",
|
"opencode.window.browser.dat:tabs",
|
||||||
JSON.stringify([
|
JSON.stringify([
|
||||||
{ type: "session", server: "http://127.0.0.1:4096", sessionId: sessionA },
|
{ type: "session", server: "http://127.0.0.1:4096", sessionId: sessionA },
|
||||||
{ type: "session", server: serverB, sessionId: sessionB },
|
{ type: "session", server: serverB, sessionId: sessionB },
|
||||||
@@ -52,7 +52,7 @@ test("legacy session routes preserve an existing tab's server", async ({ page })
|
|||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.global.dat:tabs",
|
"opencode.window.browser.dat:tabs",
|
||||||
JSON.stringify([{ type: "session", server: serverB, sessionId: sessionB }]),
|
JSON.stringify([{ type: "session", server: serverB, sessionId: sessionB }]),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => {
|
|||||||
await expect(control).toBeVisible()
|
await expect(control).toBeVisible()
|
||||||
|
|
||||||
await control.locator('[data-action="prompt-model-variant"]').click()
|
await control.locator('[data-action="prompt-model-variant"]').click()
|
||||||
const high = page.getByRole("option", { name: "high" })
|
const high = page.getByRole("menuitemradio", { name: "high" })
|
||||||
await expect(high).toBeVisible()
|
await expect(high).toBeVisible()
|
||||||
await page.mouse.move(0, 0)
|
await page.mouse.move(0, 0)
|
||||||
await expect(control).toBeVisible()
|
await expect(control).toBeVisible()
|
||||||
|
|||||||
@@ -38,6 +38,29 @@ test("shows a pending question dock", async ({ page }) => {
|
|||||||
await expect(question.getByRole("radio", { name: /Extended/ })).toBeVisible()
|
await expect(question.getByRole("radio", { name: /Extended/ })).toBeVisible()
|
||||||
await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0)
|
await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0)
|
||||||
|
|
||||||
|
const rejectRequests: string[] = []
|
||||||
|
page.on("request", (request) => {
|
||||||
|
if (request.method() !== "POST") return
|
||||||
|
if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url())
|
||||||
|
})
|
||||||
|
|
||||||
|
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
|
||||||
|
await expect(question).toBeVisible()
|
||||||
|
await expect(question.getByText("Which implementation should be used?")).toBeVisible()
|
||||||
|
await expect(question.getByText("Select one answer")).toBeHidden()
|
||||||
|
await expect(question.getByRole("radio", { name: /Minimal/ })).toBeHidden()
|
||||||
|
await expect(question.getByRole("radio", { name: /Extended/ })).toBeHidden()
|
||||||
|
await expect(question.getByRole("button", { name: "Dismiss" })).toBeVisible()
|
||||||
|
await expect(question.getByRole("button", { name: "Submit" })).toBeVisible()
|
||||||
|
await expect(page.locator('[data-component="question-minimized-dock"]')).toHaveCount(0)
|
||||||
|
expect(rejectRequests).toEqual([])
|
||||||
|
|
||||||
|
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
|
||||||
|
await expect(question).toBeVisible()
|
||||||
|
await expect(question.getByText("Which implementation should be used?")).toBeVisible()
|
||||||
|
await expect(question.getByRole("radio", { name: /Minimal/ })).toBeVisible()
|
||||||
|
expect(rejectRequests).toEqual([])
|
||||||
|
|
||||||
await question.getByRole("radio", { name: /Minimal/ }).click()
|
await question.getByRole("radio", { name: /Minimal/ }).click()
|
||||||
const reply = page.waitForRequest(
|
const reply = page.waitForRequest(
|
||||||
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",
|
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ async function configurePage(page: Page) {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.global.dat:tabs",
|
"opencode.window.browser.dat:tabs",
|
||||||
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
|
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ test.describe("smoke: session timeline", () => {
|
|||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ dirBase64, sourceID, targetID }) => {
|
({ dirBase64, sourceID, targetID }) => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.global.dat:tabs",
|
"opencode.window.browser.dat:tabs",
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
[sourceID, targetID].map((sessionId) => ({
|
[sourceID, targetID].map((sessionId) => ({
|
||||||
type: "session",
|
type: "session",
|
||||||
@@ -253,7 +253,7 @@ test.describe("smoke: session timeline", () => {
|
|||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ dirBase64, sourceID, targetID }) => {
|
({ dirBase64, sourceID, targetID }) => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.global.dat:tabs",
|
"opencode.window.browser.dat:tabs",
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
[sourceID, targetID].map((sessionId) => ({
|
[sourceID, targetID].map((sessionId) => ({
|
||||||
type: "session",
|
type: "session",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.17.11",
|
"version": "1.17.12",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
"@types/luxon": "catalog:",
|
"@types/luxon": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
|
"tw-animate-css": "1.4.0",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||||
|
|||||||
+44
-108
@@ -28,11 +28,11 @@ import {
|
|||||||
Show,
|
Show,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
import { CommandProvider } from "@/context/command"
|
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
|
||||||
import { CommentsProvider } from "@/context/comments"
|
import { CommentsProvider } from "@/context/comments"
|
||||||
import { FileProvider } from "@/context/file"
|
import { FileProvider } from "@/context/file"
|
||||||
import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk"
|
import { ServerSDKProvider } from "@/context/server-sdk"
|
||||||
import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
|
import { ServerSyncProvider } from "@/context/server-sync"
|
||||||
import { GlobalProvider, useGlobal } from "@/context/global"
|
import { GlobalProvider, useGlobal } from "@/context/global"
|
||||||
import { HighlightsProvider } from "@/context/highlights"
|
import { HighlightsProvider } from "@/context/highlights"
|
||||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||||
@@ -40,10 +40,10 @@ import { LayoutProvider } from "@/context/layout"
|
|||||||
import { ModelsProvider } from "@/context/models"
|
import { ModelsProvider } from "@/context/models"
|
||||||
import { NotificationProvider } from "@/context/notification"
|
import { NotificationProvider } from "@/context/notification"
|
||||||
import { PermissionProvider } from "@/context/permission"
|
import { PermissionProvider } from "@/context/permission"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
import { PromptProvider } from "@/context/prompt"
|
import { PromptProvider } from "@/context/prompt"
|
||||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||||
import { SettingsProvider, useSettings } from "@/context/settings"
|
import { SettingsProvider, useSettings } from "@/context/settings"
|
||||||
import { TerminalProvider } from "@/context/terminal"
|
|
||||||
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
||||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||||
import { WslServersProvider } from "@/wsl/context"
|
import { WslServersProvider } from "@/wsl/context"
|
||||||
@@ -52,16 +52,9 @@ import LegacyLayout from "@/pages/layout"
|
|||||||
import NewLayout from "@/pages/layout-new"
|
import NewLayout from "@/pages/layout-new"
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
import { useCheckServerHealth } from "./utils/server-health"
|
import { useCheckServerHealth } from "./utils/server-health"
|
||||||
import {
|
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||||
legacySessionHref,
|
|
||||||
legacySessionServer,
|
|
||||||
requireServerKey,
|
|
||||||
selectSessionLineage,
|
|
||||||
sessionHref,
|
|
||||||
} from "./utils/session-route"
|
|
||||||
import { isSessionNotFoundError } from "./utils/server-errors"
|
|
||||||
|
|
||||||
import Session from "@/pages/session"
|
import { SessionPage, TargetSessionRoute as TargetSessionRouteContent } from "@/pages/session"
|
||||||
import { NewHome, LegacyHome } from "@/pages/home"
|
import { NewHome, LegacyHome } from "@/pages/home"
|
||||||
|
|
||||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||||
@@ -95,11 +88,7 @@ const SessionRoute = () => {
|
|||||||
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
|
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return <SessionPage />
|
||||||
<SessionProviders>
|
|
||||||
<Session />
|
|
||||||
</SessionProviders>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const TargetSessionRoute = () => {
|
const TargetSessionRoute = () => {
|
||||||
@@ -114,76 +103,13 @@ const TargetSessionRoute = () => {
|
|||||||
<Show when={requireServerKey(params.serverKey)} keyed>
|
<Show when={requireServerKey(params.serverKey)} keyed>
|
||||||
<ServerSDKProvider server={conn}>
|
<ServerSDKProvider server={conn}>
|
||||||
<ServerSyncProvider server={conn}>
|
<ServerSyncProvider server={conn}>
|
||||||
<ResolvedTargetSessionRoute />
|
<TargetSessionRouteContent />
|
||||||
</ServerSyncProvider>
|
</ServerSyncProvider>
|
||||||
</ServerSDKProvider>
|
</ServerSDKProvider>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResolvedTargetSessionRoute() {
|
|
||||||
const params = useParams<{ serverKey: string; id: string }>()
|
|
||||||
const settings = useSettings()
|
|
||||||
const tabs = useTabs()
|
|
||||||
const sync = useServerSync()
|
|
||||||
const serverKey = createMemo(() => requireServerKey(params.serverKey))
|
|
||||||
const cached = createMemo(() => sync().session.lineage.peek(params.id))
|
|
||||||
const [resolved] = createResource(
|
|
||||||
() => {
|
|
||||||
if (cached()) return
|
|
||||||
return { id: params.id, server: serverKey(), sync: sync() }
|
|
||||||
},
|
|
||||||
({ id, server, sync }) =>
|
|
||||||
sync.session.lineage.resolve(id).catch((error) => {
|
|
||||||
if (isSessionNotFoundError(error, id)) tabs.removeSessionTab({ server, sessionId: id })
|
|
||||||
throw error
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const current = createMemo(() => selectSessionLineage(params.id, cached(), resolved()))
|
|
||||||
const directory = createMemo(() => current()?.session.directory)
|
|
||||||
const targetDirectory = () => directory()!
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const session = current()
|
|
||||||
if (!session) return
|
|
||||||
tabs.addSessionTab({
|
|
||||||
server: serverKey(),
|
|
||||||
sessionId: session.root.id,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
|
|
||||||
<Show when={!!current() || resolved.state !== "errored"} fallback={<ErrorPage error={resolved.error} />}>
|
|
||||||
<Show when={directory()}>
|
|
||||||
<Show
|
|
||||||
when={settings.general.newLayoutDesigns()}
|
|
||||||
fallback={<Navigate href={legacySessionHref(directory()!, params.id)} />}
|
|
||||||
>
|
|
||||||
<SDKProvider directory={targetDirectory}>
|
|
||||||
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
|
|
||||||
<TargetSessionPage />
|
|
||||||
</DirectoryDataProvider>
|
|
||||||
</SDKProvider>
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
</TargetServerScopedProviders>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TargetSessionPage() {
|
|
||||||
const sdk = useSDK()
|
|
||||||
const serverSDK = useServerSDK()
|
|
||||||
return (
|
|
||||||
<Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
|
|
||||||
<SessionProviders>
|
|
||||||
<Session />
|
|
||||||
</SessionProviders>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
||||||
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
|
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
|
||||||
// Notification/Models + the visual Layout) for that server.
|
// Notification/Models + the visual Layout) for that server.
|
||||||
@@ -231,7 +157,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
|||||||
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
||||||
<ServerSDKProvider server={conn}>
|
<ServerSDKProvider server={conn}>
|
||||||
<ServerSyncProvider server={conn}>
|
<ServerSyncProvider server={conn}>
|
||||||
<TargetServerScopedProviders directory={directory}>
|
<DraftServerScopedProviders directory={directory}>
|
||||||
<SDKProvider directory={directory}>
|
<SDKProvider directory={directory}>
|
||||||
<DirectoryDataProvider directory={directory} server={serverKey}>
|
<DirectoryDataProvider directory={directory} server={serverKey}>
|
||||||
<DraftProviders>
|
<DraftProviders>
|
||||||
@@ -239,7 +165,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
|||||||
</DraftProviders>
|
</DraftProviders>
|
||||||
</DirectoryDataProvider>
|
</DirectoryDataProvider>
|
||||||
</SDKProvider>
|
</SDKProvider>
|
||||||
</TargetServerScopedProviders>
|
</DraftServerScopedProviders>
|
||||||
</ServerSyncProvider>
|
</ServerSyncProvider>
|
||||||
</ServerSDKProvider>
|
</ServerSDKProvider>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -300,12 +226,36 @@ function SharedProviders(props: ParentProps) {
|
|||||||
<>
|
<>
|
||||||
<BodyDesignClass />
|
<BodyDesignClass />
|
||||||
<CommandProvider>
|
<CommandProvider>
|
||||||
|
<DesktopCommands />
|
||||||
<HighlightsProvider>{props.children}</HighlightsProvider>
|
<HighlightsProvider>{props.children}</HighlightsProvider>
|
||||||
</CommandProvider>
|
</CommandProvider>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DesktopCommands() {
|
||||||
|
const command = useCommand()
|
||||||
|
const language = useLanguage()
|
||||||
|
const platform = usePlatform()
|
||||||
|
|
||||||
|
command.register("desktop", () => {
|
||||||
|
const commands: CommandOption[] = []
|
||||||
|
if (platform.platform === "desktop" && platform.exportDebugLogs) {
|
||||||
|
commands.push({
|
||||||
|
id: "logs.export",
|
||||||
|
title: "Export logs",
|
||||||
|
category: language.t("command.category.settings"),
|
||||||
|
onSelect: () => {
|
||||||
|
void platform.exportDebugLogs?.()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return commands
|
||||||
|
})
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
// Server-scoped providers shared by the legacy shell and the top-level new shell.
|
// Server-scoped providers shared by the legacy shell and the top-level new shell.
|
||||||
type ServerScopedShellProps = ParentProps<{
|
type ServerScopedShellProps = ParentProps<{
|
||||||
directory?: () => string | undefined
|
directory?: () => string | undefined
|
||||||
@@ -316,9 +266,7 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
|
|||||||
return (
|
return (
|
||||||
<PermissionProvider directory={props.directory}>
|
<PermissionProvider directory={props.directory}>
|
||||||
<LayoutProvider>
|
<LayoutProvider>
|
||||||
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
|
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
|
||||||
</NotificationProvider>
|
|
||||||
</LayoutProvider>
|
</LayoutProvider>
|
||||||
</PermissionProvider>
|
</PermissionProvider>
|
||||||
)
|
)
|
||||||
@@ -342,28 +290,14 @@ function NewAppLayout(props: ParentProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TargetServerScopedProviders(props: ServerScopedShellProps) {
|
function DraftServerScopedProviders(props: ParentProps<{ directory?: () => string | undefined }>) {
|
||||||
return (
|
return (
|
||||||
<PermissionProvider directory={props.directory}>
|
<PermissionProvider directory={props.directory}>
|
||||||
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
|
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
|
||||||
</NotificationProvider>
|
|
||||||
</PermissionProvider>
|
</PermissionProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SessionProviders(props: ParentProps) {
|
|
||||||
return (
|
|
||||||
<TerminalProvider>
|
|
||||||
<FileProvider>
|
|
||||||
<PromptProvider>
|
|
||||||
<CommentsProvider>{props.children}</CommentsProvider>
|
|
||||||
</PromptProvider>
|
|
||||||
</FileProvider>
|
|
||||||
</TerminalProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The draft page only renders the prompt composer, so it drops TerminalProvider.
|
// The draft page only renders the prompt composer, so it drops TerminalProvider.
|
||||||
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
|
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
|
||||||
function DraftProviders(props: ParentProps) {
|
function DraftProviders(props: ParentProps) {
|
||||||
@@ -560,11 +494,13 @@ export function AppInterface(props: {
|
|||||||
component={props.router ?? Router}
|
component={props.router ?? Router}
|
||||||
root={(routerProps) => (
|
root={(routerProps) => (
|
||||||
<TabsProvider>
|
<TabsProvider>
|
||||||
<ServerShell>
|
<NotificationProvider>
|
||||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
<ServerShell>
|
||||||
<NewAppLayout>{routerProps.children}</NewAppLayout>
|
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||||
</Show>
|
<NewAppLayout>{routerProps.children}</NewAppLayout>
|
||||||
</ServerShell>
|
</Show>
|
||||||
|
</ServerShell>
|
||||||
|
</NotificationProvider>
|
||||||
</TabsProvider>
|
</TabsProvider>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { batch, createEffect, onCleanup, onMount } from "solid-js"
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
type Mem = Performance & {
|
type Mem = Performance & {
|
||||||
@@ -51,31 +52,62 @@ const bad = (n: number | undefined, limit: number, low = false) => {
|
|||||||
|
|
||||||
const session = (path: string) => path.includes("/session")
|
const session = (path: string) => path.includes("/session")
|
||||||
|
|
||||||
function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string; value: string; wide?: boolean }) {
|
function Cell(props: {
|
||||||
return (
|
bad?: boolean
|
||||||
<Tooltip value={props.tip} placement="top">
|
dim?: boolean
|
||||||
|
inline?: boolean
|
||||||
|
label: string
|
||||||
|
tip: string
|
||||||
|
value: string
|
||||||
|
wide?: boolean
|
||||||
|
}) {
|
||||||
|
const content = () => (
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"flex min-w-0 items-center": true,
|
||||||
|
"min-h-[20px] w-fit flex-row justify-start gap-1.5 px-1.5 py-0.5 text-left": !!props.inline,
|
||||||
|
"justify-center text-center": !props.inline,
|
||||||
|
"min-h-[42px] w-full flex-col rounded-[8px] px-0.5 py-1": !props.inline,
|
||||||
|
"col-span-2": !!props.wide && !props.inline,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] px-0.5 py-1 text-center": true,
|
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
|
||||||
"col-span-2": !!props.wide,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70">{props.label}</div>
|
{props.label}
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"text-[13px] leading-none font-bold tabular-nums sm:text-[14px]": true,
|
|
||||||
"text-text-on-critical-base": !!props.bad,
|
|
||||||
"opacity-70": !!props.dim,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{props.value}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"uppercase leading-none font-bold tabular-nums": true,
|
||||||
|
"text-[11px]": !!props.inline,
|
||||||
|
"text-[13px] sm:text-[14px]": !props.inline,
|
||||||
|
"text-text-on-critical-base": !!props.bad,
|
||||||
|
"opacity-70": !!props.dim,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (props.inline) {
|
||||||
|
return (
|
||||||
|
<TooltipV2 value={props.tip} placement="top">
|
||||||
|
{content()}
|
||||||
|
</TooltipV2>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip value={props.tip} placement="top">
|
||||||
|
{content()}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DebugBar() {
|
export function DebugBar(props: { inline?: boolean } = {}) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const routing = useIsRouting()
|
const routing = useIsRouting()
|
||||||
@@ -101,7 +133,7 @@ export function DebugBar() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const na = () => language.t("debugBar.na")
|
const na = () => language.t("debugBar.na").toUpperCase()
|
||||||
const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined)
|
const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined)
|
||||||
const heapv = () => {
|
const heapv = () => {
|
||||||
const value = heap()
|
const value = heap()
|
||||||
@@ -363,15 +395,30 @@ export function DebugBar() {
|
|||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
aria-label={language.t("debugBar.ariaLabel")}
|
aria-label={language.t("debugBar.ariaLabel")}
|
||||||
class="pointer-events-auto fixed bottom-3 right-3 z-50 hidden w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 text-text-strong shadow-[var(--shadow-lg-border-base)] md:block sm:bottom-4 sm:right-4 sm:w-[324px]"
|
classList={{
|
||||||
|
"pointer-events-auto hidden overflow-hidden text-text-strong md:block": true,
|
||||||
|
"mt-[-6px] w-full shrink-0 px-3 py-1": !!props.inline,
|
||||||
|
"fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]":
|
||||||
|
!props.inline,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div class="grid grid-cols-5 gap-px font-mono">
|
<div
|
||||||
|
classList={{
|
||||||
|
"font-mono": true,
|
||||||
|
"gap-[9px]": !!props.inline,
|
||||||
|
"gap-px": !props.inline,
|
||||||
|
"flex w-full flex-nowrap items-center justify-start": !!props.inline,
|
||||||
|
"grid-cols-5": !props.inline,
|
||||||
|
grid: !props.inline,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Cell
|
<Cell
|
||||||
label={language.t("debugBar.nav.label")}
|
label={language.t("debugBar.nav.label")}
|
||||||
tip={language.t("debugBar.nav.tip")}
|
tip={language.t("debugBar.nav.tip")}
|
||||||
value={navv()}
|
value={navv()}
|
||||||
bad={bad(state.nav.dur, 400)}
|
bad={bad(state.nav.dur, 400)}
|
||||||
dim={state.nav.dur === undefined && !state.nav.pending}
|
dim={state.nav.dur === undefined && !state.nav.pending}
|
||||||
|
inline={props.inline}
|
||||||
/>
|
/>
|
||||||
<Cell
|
<Cell
|
||||||
label={language.t("debugBar.fps.label")}
|
label={language.t("debugBar.fps.label")}
|
||||||
@@ -379,6 +426,7 @@ export function DebugBar() {
|
|||||||
value={state.fps === undefined ? na() : `${Math.round(state.fps)}`}
|
value={state.fps === undefined ? na() : `${Math.round(state.fps)}`}
|
||||||
bad={bad(state.fps, 50, true)}
|
bad={bad(state.fps, 50, true)}
|
||||||
dim={state.fps === undefined}
|
dim={state.fps === undefined}
|
||||||
|
inline={props.inline}
|
||||||
/>
|
/>
|
||||||
<Cell
|
<Cell
|
||||||
label={language.t("debugBar.frame.label")}
|
label={language.t("debugBar.frame.label")}
|
||||||
@@ -386,6 +434,7 @@ export function DebugBar() {
|
|||||||
value={time(state.gap) ?? na()}
|
value={time(state.gap) ?? na()}
|
||||||
bad={bad(state.gap, 50)}
|
bad={bad(state.gap, 50)}
|
||||||
dim={state.gap === undefined}
|
dim={state.gap === undefined}
|
||||||
|
inline={props.inline}
|
||||||
/>
|
/>
|
||||||
<Cell
|
<Cell
|
||||||
label={language.t("debugBar.jank.label")}
|
label={language.t("debugBar.jank.label")}
|
||||||
@@ -393,6 +442,7 @@ export function DebugBar() {
|
|||||||
value={state.jank === undefined ? na() : `${state.jank}`}
|
value={state.jank === undefined ? na() : `${state.jank}`}
|
||||||
bad={bad(state.jank, 8)}
|
bad={bad(state.jank, 8)}
|
||||||
dim={state.jank === undefined}
|
dim={state.jank === undefined}
|
||||||
|
inline={props.inline}
|
||||||
/>
|
/>
|
||||||
<Cell
|
<Cell
|
||||||
label={language.t("debugBar.long.label")}
|
label={language.t("debugBar.long.label")}
|
||||||
@@ -400,6 +450,7 @@ export function DebugBar() {
|
|||||||
value={longv()}
|
value={longv()}
|
||||||
bad={bad(state.long.block, 200)}
|
bad={bad(state.long.block, 200)}
|
||||||
dim={state.long.count === undefined}
|
dim={state.long.count === undefined}
|
||||||
|
inline={props.inline}
|
||||||
/>
|
/>
|
||||||
<Cell
|
<Cell
|
||||||
label={language.t("debugBar.delay.label")}
|
label={language.t("debugBar.delay.label")}
|
||||||
@@ -407,6 +458,7 @@ export function DebugBar() {
|
|||||||
value={time(state.delay) ?? na()}
|
value={time(state.delay) ?? na()}
|
||||||
bad={bad(state.delay, 100)}
|
bad={bad(state.delay, 100)}
|
||||||
dim={state.delay === undefined}
|
dim={state.delay === undefined}
|
||||||
|
inline={props.inline}
|
||||||
/>
|
/>
|
||||||
<Cell
|
<Cell
|
||||||
label={language.t("debugBar.inp.label")}
|
label={language.t("debugBar.inp.label")}
|
||||||
@@ -414,6 +466,7 @@ export function DebugBar() {
|
|||||||
value={time(state.inp) ?? na()}
|
value={time(state.inp) ?? na()}
|
||||||
bad={bad(state.inp, 200)}
|
bad={bad(state.inp, 200)}
|
||||||
dim={state.inp === undefined}
|
dim={state.inp === undefined}
|
||||||
|
inline={props.inline}
|
||||||
/>
|
/>
|
||||||
<Cell
|
<Cell
|
||||||
label={language.t("debugBar.cls.label")}
|
label={language.t("debugBar.cls.label")}
|
||||||
@@ -421,6 +474,7 @@ export function DebugBar() {
|
|||||||
value={state.cls === undefined ? na() : state.cls.toFixed(2)}
|
value={state.cls === undefined ? na() : state.cls.toFixed(2)}
|
||||||
bad={bad(state.cls, 0.1)}
|
bad={bad(state.cls, 0.1)}
|
||||||
dim={state.cls === undefined}
|
dim={state.cls === undefined}
|
||||||
|
inline={props.inline}
|
||||||
/>
|
/>
|
||||||
<Cell
|
<Cell
|
||||||
label={language.t("debugBar.mem.label")}
|
label={language.t("debugBar.mem.label")}
|
||||||
@@ -435,6 +489,7 @@ export function DebugBar() {
|
|||||||
value={heapv()}
|
value={heapv()}
|
||||||
bad={bad(heap(), 0.8)}
|
bad={bad(heap(), 0.8)}
|
||||||
dim={state.heap.used === undefined}
|
dim={state.heap.used === undefined}
|
||||||
|
inline={props.inline}
|
||||||
wide
|
wide
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
|||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
methodIndex: undefined as undefined | number,
|
methodIndex: undefined as undefined | number,
|
||||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
authorization: undefined as undefined | ProviderAuthAuthorization,
|
||||||
|
promptInputs: undefined as undefined | Record<string, string>,
|
||||||
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
||||||
error: undefined as string | undefined,
|
error: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
@@ -73,6 +74,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
|||||||
| { type: "method.select"; index: number }
|
| { type: "method.select"; index: number }
|
||||||
| { type: "method.reset" }
|
| { type: "method.reset" }
|
||||||
| { type: "auth.prompt" }
|
| { type: "auth.prompt" }
|
||||||
|
| { type: "auth.inputs"; inputs: Record<string, string> }
|
||||||
| { type: "auth.pending" }
|
| { type: "auth.pending" }
|
||||||
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
|
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
|
||||||
| { type: "auth.error"; error: string }
|
| { type: "auth.error"; error: string }
|
||||||
@@ -83,6 +85,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
|||||||
if (action.type === "method.select") {
|
if (action.type === "method.select") {
|
||||||
draft.methodIndex = action.index
|
draft.methodIndex = action.index
|
||||||
draft.authorization = undefined
|
draft.authorization = undefined
|
||||||
|
draft.promptInputs = undefined
|
||||||
draft.state = undefined
|
draft.state = undefined
|
||||||
draft.error = undefined
|
draft.error = undefined
|
||||||
return
|
return
|
||||||
@@ -90,6 +93,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
|||||||
if (action.type === "method.reset") {
|
if (action.type === "method.reset") {
|
||||||
draft.methodIndex = undefined
|
draft.methodIndex = undefined
|
||||||
draft.authorization = undefined
|
draft.authorization = undefined
|
||||||
|
draft.promptInputs = undefined
|
||||||
draft.state = undefined
|
draft.state = undefined
|
||||||
draft.error = undefined
|
draft.error = undefined
|
||||||
return
|
return
|
||||||
@@ -99,6 +103,12 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
|||||||
draft.error = undefined
|
draft.error = undefined
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (action.type === "auth.inputs") {
|
||||||
|
draft.promptInputs = action.inputs
|
||||||
|
draft.state = undefined
|
||||||
|
draft.error = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
if (action.type === "auth.pending") {
|
if (action.type === "auth.pending") {
|
||||||
draft.state = "pending"
|
draft.state = "pending"
|
||||||
draft.error = undefined
|
draft.error = undefined
|
||||||
@@ -151,6 +161,15 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
|||||||
const method = methods()[index]
|
const method = methods()[index]
|
||||||
dispatch({ type: "method.select", index })
|
dispatch({ type: "method.select", index })
|
||||||
|
|
||||||
|
if (method.type === "api" && method.prompts?.length) {
|
||||||
|
if (!inputs) {
|
||||||
|
dispatch({ type: "auth.prompt" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dispatch({ type: "auth.inputs", inputs })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (method.type === "oauth") {
|
if (method.type === "oauth") {
|
||||||
if (method.prompts?.length && !inputs) {
|
if (method.prompts?.length && !inputs) {
|
||||||
dispatch({ type: "auth.prompt" })
|
dispatch({ type: "auth.prompt" })
|
||||||
@@ -190,7 +209,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function OAuthPromptsView() {
|
function AuthPromptsView() {
|
||||||
const [formStore, setFormStore] = createStore({
|
const [formStore, setFormStore] = createStore({
|
||||||
value: {} as Record<string, string>,
|
value: {} as Record<string, string>,
|
||||||
index: 0,
|
index: 0,
|
||||||
@@ -198,8 +217,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
|||||||
|
|
||||||
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
|
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
|
||||||
const value = method()
|
const value = method()
|
||||||
if (value?.type !== "oauth") return []
|
return value?.prompts ?? []
|
||||||
return value.prompts ?? []
|
|
||||||
})
|
})
|
||||||
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
|
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
|
||||||
if (!prompt.when) return true
|
if (!prompt.when) return true
|
||||||
@@ -230,6 +248,10 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
|||||||
setFormStore("index", next)
|
setFormStore("index", next)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (method()?.type === "api") {
|
||||||
|
dispatch({ type: "auth.inputs", inputs: value })
|
||||||
|
return
|
||||||
|
}
|
||||||
await selectMethod(store.methodIndex, value)
|
await selectMethod(store.methodIndex, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,6 +436,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
|||||||
auth: {
|
auth: {
|
||||||
type: "api",
|
type: "api",
|
||||||
key: apiKey,
|
key: apiKey,
|
||||||
|
...(store.promptInputs ? { metadata: store.promptInputs } : {}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await complete()
|
await complete()
|
||||||
@@ -622,7 +645,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
|||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={store.state === "prompt"}>
|
<Match when={store.state === "prompt"}>
|
||||||
<OAuthPromptsView />
|
<AuthPromptsView />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={store.state === "error"}>
|
<Match when={store.state === "error"}>
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
|
|||||||
@@ -3,13 +3,26 @@ import { List } from "@opencode-ai/ui/list"
|
|||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import type { Component } from "solid-js"
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
|
import { Dialog as DialogV2, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2"
|
||||||
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
|
import { Switch as SwitchV2 } from "@opencode-ai/ui/v2/switch-v2"
|
||||||
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
|
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||||
|
import { For, Show, type Component } from "solid-js"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { popularProviders } from "@/hooks/use-providers"
|
import { popularProviders } from "@/hooks/use-providers"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
|
import { SettingsListV2 } from "./settings-v2/parts/list"
|
||||||
|
import { SettingsRowV2 } from "./settings-v2/parts/row"
|
||||||
|
import "./settings-v2/settings-v2.css"
|
||||||
|
|
||||||
|
type ModelItem = ReturnType<ReturnType<typeof useLocal>["model"]["list"]>[number]
|
||||||
|
|
||||||
export const DialogManageModels: Component = () => {
|
export const DialogManageModels: Component = () => {
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
@@ -102,3 +115,151 @@ export const DialogManageModels: Component = () => {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const DialogManageModelsV2: Component = () => {
|
||||||
|
const local = useLocal()
|
||||||
|
const language = useLanguage()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const directory = () => decode64(local.slug())
|
||||||
|
|
||||||
|
const handleConnectProvider = () => {
|
||||||
|
dialog.show(() => <DialogSelectProvider directory={directory} />)
|
||||||
|
}
|
||||||
|
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
||||||
|
const providerVisible = (providerID: string) =>
|
||||||
|
providerList(providerID).every((x) => local.model.visible({ modelID: x.id, providerID: x.provider.id }))
|
||||||
|
const setProviderVisibility = (providerID: string, checked: boolean) => {
|
||||||
|
providerList(providerID).forEach((x) => {
|
||||||
|
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, checked)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const setModelVisibility = (item: ModelItem, checked: boolean) => {
|
||||||
|
local.model.setVisibility({ modelID: item.id, providerID: item.provider.id }, checked)
|
||||||
|
}
|
||||||
|
const list = useFilteredList<ModelItem>({
|
||||||
|
items: () => local.model.list(),
|
||||||
|
key: (x) => `${x.provider.id}:${x.id}`,
|
||||||
|
filterKeys: ["provider.name", "name", "id"],
|
||||||
|
sortBy: (a, b) => a.name.localeCompare(b.name),
|
||||||
|
groupBy: (x) => x.provider.id,
|
||||||
|
sortGroupsBy: (a, b) => {
|
||||||
|
const aRank = popularProviders.indexOf(a.category)
|
||||||
|
const bRank = popularProviders.indexOf(b.category)
|
||||||
|
const aPopular = aRank >= 0
|
||||||
|
const bPopular = bRank >= 0
|
||||||
|
if (aPopular && !bPopular) return -1
|
||||||
|
if (!aPopular && bPopular) return 1
|
||||||
|
return aRank - bRank
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogV2 size="large" variant="settings" class="settings-v2-manage-models-dialog">
|
||||||
|
<DialogHeader hideClose={true} closeLabel={language.t("common.close")}>
|
||||||
|
<DialogTitleGroup
|
||||||
|
title={language.t("dialog.model.manage")}
|
||||||
|
description={language.t("dialog.model.manage.description")}
|
||||||
|
/>
|
||||||
|
<ButtonV2 variant="neutral" icon="plus" onClick={handleConnectProvider}>
|
||||||
|
{language.t("command.provider.connect")}
|
||||||
|
</ButtonV2>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogBody class="flex min-h-0 flex-1 flex-col">
|
||||||
|
<div class="px-4 pt-px pb-3">
|
||||||
|
<div class="relative">
|
||||||
|
<TextInputV2
|
||||||
|
type="search"
|
||||||
|
appearance="base"
|
||||||
|
class="!w-full self-stretch"
|
||||||
|
value={list.filter()}
|
||||||
|
onInput={(event) => list.onInput(event.currentTarget.value)}
|
||||||
|
placeholder={language.t("dialog.model.search.placeholder")}
|
||||||
|
spellcheck={false}
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
autofocus
|
||||||
|
aria-label={language.t("dialog.model.search.placeholder")}
|
||||||
|
/>
|
||||||
|
<Show when={list.filter()}>
|
||||||
|
<IconButtonV2
|
||||||
|
type="button"
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="small"
|
||||||
|
class="settings-v2-tab-search-clear"
|
||||||
|
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
|
||||||
|
onClick={() => list.clear()}
|
||||||
|
aria-label={language.t("common.clear")}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div data-slot="manage-models-scroll" class="relative min-h-0 flex-1">
|
||||||
|
<div class="settings-v2-panel settings-v2-models h-full px-4 pt-4 pb-4">
|
||||||
|
<Show
|
||||||
|
when={!list.grouped.loading}
|
||||||
|
fallback={
|
||||||
|
<div class="settings-v2-models-status">
|
||||||
|
{language.t("common.loading")}
|
||||||
|
{language.t("common.loading.ellipsis")}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={list.flat().length > 0}
|
||||||
|
fallback={
|
||||||
|
<div class="settings-v2-models-status">
|
||||||
|
<span>{language.t("dialog.model.empty")}</span>
|
||||||
|
<Show when={list.filter()}>
|
||||||
|
<span class="settings-v2-models-status-filter">"{list.filter()}"</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={list.grouped.latest}>
|
||||||
|
{(group) => (
|
||||||
|
<div class="settings-v2-section" data-component="settings-models-provider">
|
||||||
|
<div class="settings-v2-models-group-header justify-between">
|
||||||
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
|
<ProviderIcon id={group.category} width={16} height={16} class="ml-4 shrink-0" />
|
||||||
|
<h3 class="settings-v2-section-title">{group.items[0].provider.name}</h3>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<SwitchV2
|
||||||
|
class="mr-6"
|
||||||
|
checked={providerVisible(group.category)}
|
||||||
|
onChange={(checked) => setProviderVisibility(group.category, checked)}
|
||||||
|
hideLabel
|
||||||
|
>
|
||||||
|
{group.items[0].provider.name}
|
||||||
|
</SwitchV2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SettingsListV2>
|
||||||
|
<For each={group.items}>
|
||||||
|
{(item) => (
|
||||||
|
<SettingsRowV2 title={item.name} description="">
|
||||||
|
<div>
|
||||||
|
<SwitchV2
|
||||||
|
checked={local.model.visible({ modelID: item.id, providerID: item.provider.id })}
|
||||||
|
onChange={(checked) => setModelVisibility(item, checked)}
|
||||||
|
hideLabel
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</SwitchV2>
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</SettingsListV2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogBody>
|
||||||
|
</DialogV2>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import { Popover as Kobalte } from "@kobalte/core/popover"
|
import { Popover as Kobalte } from "@kobalte/core/popover"
|
||||||
import { Component, ComponentProps, createMemo, JSX, Show, ValidComponent } from "solid-js"
|
import { Component, ComponentProps, createMemo, For, JSX, Show, ValidComponent } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { popularProviders } from "@/hooks/use-providers"
|
import { popularProviders } from "@/hooks/use-providers"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||||
import { Tag } from "@opencode-ai/ui/tag"
|
import { Tag } from "@opencode-ai/ui/tag"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { Tag as TagV2 } from "@opencode-ai/ui/v2/badge-v2"
|
||||||
|
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||||
import { ModelTooltip } from "./model-tooltip"
|
import { ModelTooltip } from "./model-tooltip"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
@@ -18,6 +22,22 @@ const isFree = (provider: string, cost: { input: number } | undefined) =>
|
|||||||
provider === "opencode" && (!cost || cost.input === 0)
|
provider === "opencode" && (!cost || cost.input === 0)
|
||||||
|
|
||||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||||
|
type ModelItem = ReturnType<ModelState["list"]>[number]
|
||||||
|
|
||||||
|
const modelKey = (model: ModelItem) => `${model.provider.id}:${model.id}`
|
||||||
|
const manageKey = "action:manage"
|
||||||
|
|
||||||
|
const sortModelGroups = (a: { category: string; items: ModelItem[] }, b: { category: string; items: ModelItem[] }) => {
|
||||||
|
const aIndex = popularProviders.indexOf(a.category)
|
||||||
|
const bIndex = popularProviders.indexOf(b.category)
|
||||||
|
const aPopular = aIndex >= 0
|
||||||
|
const bPopular = bIndex >= 0
|
||||||
|
|
||||||
|
if (aPopular && !bPopular) return -1
|
||||||
|
if (!aPopular && bPopular) return 1
|
||||||
|
if (aPopular && bPopular) return aIndex - bIndex
|
||||||
|
return a.items[0].provider.name.localeCompare(b.items[0].provider.name)
|
||||||
|
}
|
||||||
|
|
||||||
const ModelList: Component<{
|
const ModelList: Component<{
|
||||||
provider?: string
|
provider?: string
|
||||||
@@ -199,6 +219,271 @@ export function ModelSelectorPopover(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ModelSelectorPopoverV2(props: {
|
||||||
|
provider?: string
|
||||||
|
model?: ModelState
|
||||||
|
children?: JSX.Element
|
||||||
|
triggerAs?: ValidComponent
|
||||||
|
triggerProps?: ModelSelectorTriggerProps
|
||||||
|
onClose?: () => void
|
||||||
|
}) {
|
||||||
|
const model = props.model ?? useLocal().model
|
||||||
|
const language = useLanguage()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
||||||
|
let searchRef: HTMLInputElement | undefined
|
||||||
|
let contentRef: HTMLDivElement | undefined
|
||||||
|
let restoreTrigger = true
|
||||||
|
|
||||||
|
const allModels = createMemo(() =>
|
||||||
|
model
|
||||||
|
.list()
|
||||||
|
.filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id }))
|
||||||
|
.filter((item) => (props.provider ? item.provider.id === props.provider : true)),
|
||||||
|
)
|
||||||
|
const models = createMemo(() => {
|
||||||
|
const search = store.search.trim().toLowerCase()
|
||||||
|
const filtered = search
|
||||||
|
? allModels().filter(
|
||||||
|
(item) =>
|
||||||
|
item.name.toLowerCase().includes(search) ||
|
||||||
|
item.id.toLowerCase().includes(search) ||
|
||||||
|
item.provider.name.toLowerCase().includes(search),
|
||||||
|
)
|
||||||
|
: allModels()
|
||||||
|
|
||||||
|
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
})
|
||||||
|
const groups = createMemo(() => {
|
||||||
|
const byProvider = new Map<string, ModelItem[]>()
|
||||||
|
for (const item of models()) {
|
||||||
|
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
|
||||||
|
}
|
||||||
|
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
|
||||||
|
})
|
||||||
|
const keys = () => [...models().map(modelKey), manageKey]
|
||||||
|
const current = () => {
|
||||||
|
const value = model.current()
|
||||||
|
return value ? `${value.provider.id}:${value.id}` : undefined
|
||||||
|
}
|
||||||
|
const initialActive = () => {
|
||||||
|
const selected = current()
|
||||||
|
const options = keys()
|
||||||
|
if (selected && options.includes(selected)) return selected
|
||||||
|
return options[0] ?? ""
|
||||||
|
}
|
||||||
|
const activeItem = () =>
|
||||||
|
store.active ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined
|
||||||
|
const afterClose = (callback: () => void) => {
|
||||||
|
const complete = () => {
|
||||||
|
if (contentRef?.isConnected) {
|
||||||
|
requestAnimationFrame(complete)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(callback))
|
||||||
|
}
|
||||||
|
requestAnimationFrame(complete)
|
||||||
|
}
|
||||||
|
const setOpen = (open: boolean) => {
|
||||||
|
if (open) {
|
||||||
|
restoreTrigger = true
|
||||||
|
setStore({ open: true, active: initialActive() })
|
||||||
|
setTimeout(() =>
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
searchRef?.focus()
|
||||||
|
activeItem()?.scrollIntoView({ block: "nearest" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStore({ open: false, search: "", active: "" })
|
||||||
|
}
|
||||||
|
const select = (item: ModelItem) => {
|
||||||
|
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
|
||||||
|
props.onClose?.()
|
||||||
|
}
|
||||||
|
const selectModel = (item: ModelItem) => {
|
||||||
|
restoreTrigger = false
|
||||||
|
setOpen(false)
|
||||||
|
afterClose(() => select(item))
|
||||||
|
}
|
||||||
|
const manage = () => {
|
||||||
|
restoreTrigger = false
|
||||||
|
setOpen(false)
|
||||||
|
afterClose(() => {
|
||||||
|
void import("./dialog-manage-models").then((x) => {
|
||||||
|
dialog.show(() => <x.DialogManageModelsV2 />)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const selectActive = () => {
|
||||||
|
const item = models().find((item) => modelKey(item) === store.active)
|
||||||
|
if (item) {
|
||||||
|
selectModel(item)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (store.active === manageKey) manage()
|
||||||
|
}
|
||||||
|
const moveActive = (delta: number) => {
|
||||||
|
const options = keys()
|
||||||
|
if (options.length === 0) return
|
||||||
|
const index = options.indexOf(store.active)
|
||||||
|
const start = index === -1 ? 0 : index
|
||||||
|
setStore("active", options[(start + delta + options.length) % options.length])
|
||||||
|
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
|
||||||
|
}
|
||||||
|
const setSearch = (value: string) => {
|
||||||
|
const search = value.trim().toLowerCase()
|
||||||
|
const first = [...allModels()]
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
.find(
|
||||||
|
(item) =>
|
||||||
|
!search ||
|
||||||
|
item.name.toLowerCase().includes(search) ||
|
||||||
|
item.id.toLowerCase().includes(search) ||
|
||||||
|
item.provider.name.toLowerCase().includes(search),
|
||||||
|
)
|
||||||
|
setStore({ search: value, active: first ? modelKey(first) : manageKey })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
|
||||||
|
<MenuV2.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
||||||
|
{props.children}
|
||||||
|
</MenuV2.Trigger>
|
||||||
|
<MenuV2.Portal>
|
||||||
|
<MenuV2.Content
|
||||||
|
ref={(el: HTMLDivElement) => (contentRef = el)}
|
||||||
|
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
||||||
|
onPointerDownOutside={() => (restoreTrigger = false)}
|
||||||
|
onFocusOutside={() => (restoreTrigger = false)}
|
||||||
|
onCloseAutoFocus={(event) => {
|
||||||
|
if (!restoreTrigger) event.preventDefault()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex flex-col p-0.5">
|
||||||
|
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
||||||
|
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||||
|
<input
|
||||||
|
ref={(el) => (searchRef = el)}
|
||||||
|
value={store.search}
|
||||||
|
placeholder={language.t("dialog.model.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"
|
||||||
|
spellcheck={false}
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
onInput={(event) => setSearch(event.currentTarget.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Tab") return
|
||||||
|
event.stopPropagation()
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault()
|
||||||
|
restoreTrigger = false
|
||||||
|
setOpen(false)
|
||||||
|
afterClose(() => props.onClose?.())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.altKey || event.metaKey) return
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault()
|
||||||
|
moveActive(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault()
|
||||||
|
moveActive(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === "Enter" && !event.isComposing) {
|
||||||
|
event.preventDefault()
|
||||||
|
selectActive()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Show when={store.search.trim()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
|
||||||
|
onPointerDown={(event) => event.preventDefault()}
|
||||||
|
onClick={() => setSearch("")}
|
||||||
|
aria-label={language.t("common.clear")}
|
||||||
|
>
|
||||||
|
<Icon name="close" size="small" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="h-px bg-v2-border-border-muted" />
|
||||||
|
<ScrollView data-slot="model-selector-scroll" class="max-h-[220px] min-h-0">
|
||||||
|
<div class="flex flex-col p-0.5 pt-0">
|
||||||
|
<Show
|
||||||
|
when={models().length > 0}
|
||||||
|
fallback={
|
||||||
|
<div class="flex h-12 items-center px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint">
|
||||||
|
{language.t("dialog.model.empty")}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={groups()}>
|
||||||
|
{(group) => (
|
||||||
|
<MenuV2.Group>
|
||||||
|
<MenuV2.GroupLabel class="gap-2 px-3">
|
||||||
|
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
|
||||||
|
</MenuV2.GroupLabel>
|
||||||
|
<MenuV2.RadioGroup value={current()}>
|
||||||
|
<For each={group.items}>
|
||||||
|
{(item) => (
|
||||||
|
<MenuV2.RadioItem
|
||||||
|
value={modelKey(item)}
|
||||||
|
data-option-key={modelKey(item)}
|
||||||
|
data-selected-model={current() === modelKey(item) ? true : undefined}
|
||||||
|
class="scroll-my-6"
|
||||||
|
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setStore("active", modelKey(item))
|
||||||
|
setTimeout(() => searchRef?.focus())
|
||||||
|
}}
|
||||||
|
onSelect={() => selectModel(item)}
|
||||||
|
>
|
||||||
|
<span class="min-w-0 truncate">{item.name}</span>
|
||||||
|
<Show when={isFree(item.provider.id, item.cost)}>
|
||||||
|
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
|
||||||
|
</Show>
|
||||||
|
<Show when={item.latest}>
|
||||||
|
<TagV2 class="shrink-0">{language.t("model.tag.latest")}</TagV2>
|
||||||
|
</Show>
|
||||||
|
</MenuV2.RadioItem>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</MenuV2.RadioGroup>
|
||||||
|
</MenuV2.Group>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</ScrollView>
|
||||||
|
<div class="h-px bg-v2-border-border-muted" />
|
||||||
|
<div class="flex flex-col p-0.5">
|
||||||
|
<MenuV2.Item
|
||||||
|
data-option-key={manageKey}
|
||||||
|
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === manageKey }}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setStore("active", manageKey)
|
||||||
|
setTimeout(() => searchRef?.focus())
|
||||||
|
}}
|
||||||
|
onSelect={manage}
|
||||||
|
>
|
||||||
|
<Icon name="outline-sliders" size="small" />
|
||||||
|
<span class="min-w-0 flex-1 truncate leading-5">{language.t("dialog.model.manage")}</span>
|
||||||
|
</MenuV2.Item>
|
||||||
|
</div>
|
||||||
|
</MenuV2.Content>
|
||||||
|
</MenuV2.Portal>
|
||||||
|
</MenuV2>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => {
|
export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|||||||
@@ -35,10 +35,14 @@ import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
|
|||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
|
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||||
|
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||||
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { Select } from "@opencode-ai/ui/select"
|
import { Select } from "@opencode-ai/ui/select"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
@@ -66,6 +70,7 @@ import { promptPlaceholder } from "./prompt-input/placeholder"
|
|||||||
import { createPromptInputTransientState } from "./prompt-input/transient-state"
|
import { createPromptInputTransientState } from "./prompt-input/transient-state"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||||
|
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
export type PromptInputState = ReturnType<typeof usePrompt>
|
export type PromptInputState = ReturnType<typeof usePrompt>
|
||||||
|
|
||||||
@@ -212,6 +217,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
let fileInputRef: HTMLInputElement | undefined
|
let fileInputRef: HTMLInputElement | undefined
|
||||||
let scrollRef!: HTMLDivElement
|
let scrollRef!: HTMLDivElement
|
||||||
let slashPopoverRef!: HTMLDivElement
|
let slashPopoverRef!: HTMLDivElement
|
||||||
|
let restoreEndOnFocus = true
|
||||||
|
|
||||||
const mirror = { input: false }
|
const mirror = { input: false }
|
||||||
const inset = 56
|
const inset = 56
|
||||||
@@ -591,6 +597,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleFocus = () => {
|
||||||
|
if (!restoreEndOnFocus) return
|
||||||
|
restoreEndOnFocus = false
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (document.activeElement !== editorRef) return
|
||||||
|
setCursorPosition(editorRef, prompt.cursor() ?? promptLength(prompt.current()))
|
||||||
|
queueScroll()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const renderEditorWithCursor = (parts: Prompt) => {
|
const renderEditorWithCursor = (parts: Prompt) => {
|
||||||
const cursor = currentCursor()
|
const cursor = currentCursor()
|
||||||
renderEditor(parts)
|
renderEditor(parts)
|
||||||
@@ -627,24 +643,89 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const referenceDescription = (reference: ReferenceInfo) =>
|
||||||
|
reference.source.type === "git" ? reference.source.repository : reference.source.path
|
||||||
|
|
||||||
|
const referenceList = createMemo(() =>
|
||||||
|
sync()
|
||||||
|
.data.reference.filter((reference) => !reference.hidden)
|
||||||
|
.map(
|
||||||
|
(reference): AtOption => ({
|
||||||
|
type: "reference",
|
||||||
|
name: reference.name,
|
||||||
|
path: reference.path,
|
||||||
|
display: reference.name,
|
||||||
|
description: reference.description ?? referenceDescription(reference),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const agentList = createMemo(() =>
|
const agentList = createMemo(() =>
|
||||||
props.controls.agents.available
|
props.controls.agents.available
|
||||||
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
||||||
.map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
|
.map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const mcpResourceList = createMemo(() =>
|
||||||
|
Object.values(sync().data.mcp_resource).map(
|
||||||
|
(resource): AtOption => ({
|
||||||
|
type: "resource",
|
||||||
|
name: resource.name,
|
||||||
|
uri: resource.uri,
|
||||||
|
client: resource.client,
|
||||||
|
display: resource.name,
|
||||||
|
description: resource.description,
|
||||||
|
mime: resource.mimeType,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const handleAtSelect = (option: AtOption | undefined) => {
|
const handleAtSelect = (option: AtOption | undefined) => {
|
||||||
if (!option) return
|
if (!option) return
|
||||||
if (option.type === "agent") {
|
if (option.type === "agent") {
|
||||||
addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 })
|
addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 })
|
||||||
} else {
|
return
|
||||||
addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 })
|
|
||||||
}
|
}
|
||||||
|
if (option.type === "reference") {
|
||||||
|
addPart({
|
||||||
|
type: "file",
|
||||||
|
path: option.path,
|
||||||
|
content: "@" + option.name,
|
||||||
|
start: 0,
|
||||||
|
end: 0,
|
||||||
|
mime: "application/x-directory",
|
||||||
|
filename: option.name,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (option.type === "resource") {
|
||||||
|
addPart({
|
||||||
|
type: "file",
|
||||||
|
path: option.uri,
|
||||||
|
content: "@" + option.name,
|
||||||
|
start: 0,
|
||||||
|
end: 0,
|
||||||
|
mime: option.mime ?? "text/plain",
|
||||||
|
filename: option.name,
|
||||||
|
url: option.uri,
|
||||||
|
source: {
|
||||||
|
type: "resource",
|
||||||
|
text: { value: "@" + option.name, start: 0, end: 0 },
|
||||||
|
clientName: option.client,
|
||||||
|
uri: option.uri,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const atKey = (x: AtOption | undefined) => {
|
const atKey = (x: AtOption | undefined) => {
|
||||||
if (!x) return ""
|
if (!x) return ""
|
||||||
return x.type === "agent" ? `agent:${x.name}` : `file:${x.path}`
|
if (x.type === "agent") return `agent:${x.name}`
|
||||||
|
if (x.type === "reference") return `reference:${x.name}`
|
||||||
|
if (x.type === "resource") return `resource:${x.client}:${x.uri}`
|
||||||
|
return `file:${x.path}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -655,30 +736,36 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
onKeyDown: atOnKeyDown,
|
onKeyDown: atOnKeyDown,
|
||||||
} = useFilteredList<AtOption>({
|
} = useFilteredList<AtOption>({
|
||||||
items: async (query) => {
|
items: async (query) => {
|
||||||
|
const references = referenceList()
|
||||||
const agents = agentList()
|
const agents = agentList()
|
||||||
|
const mcpResources = mcpResourceList()
|
||||||
const open = recent()
|
const open = recent()
|
||||||
const seen = new Set(open)
|
const seen = new Set(open)
|
||||||
const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
|
const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
|
||||||
if (!query.trim()) return [...agents, ...pinned]
|
if (!query.trim()) return [...references, ...agents, ...mcpResources, ...pinned]
|
||||||
const paths = await files.searchFilesAndDirectories(query)
|
const paths = await files.searchFilesAndDirectories(query)
|
||||||
const fileOptions: AtOption[] = paths
|
const fileOptions: AtOption[] = paths
|
||||||
.filter((path) => !seen.has(path))
|
.filter((path) => !seen.has(path))
|
||||||
.map((path) => ({ type: "file", path, display: path }))
|
.map((path) => ({ type: "file", path, display: path }))
|
||||||
return [...agents, ...pinned, ...fileOptions]
|
return [...references, ...agents, ...mcpResources, ...pinned, ...fileOptions]
|
||||||
},
|
},
|
||||||
key: atKey,
|
key: atKey,
|
||||||
filterKeys: ["display"],
|
filterKeys: ["display"],
|
||||||
skipFilter: (item) => item.type === "file" && !item.recent,
|
skipFilter: (item) => item.type === "file" && !item.recent,
|
||||||
groupBy: (item) => {
|
groupBy: (item) => {
|
||||||
|
if (item.type === "reference") return "reference"
|
||||||
if (item.type === "agent") return "agent"
|
if (item.type === "agent") return "agent"
|
||||||
|
if (item.type === "resource") return "resource"
|
||||||
if (item.recent) return "recent"
|
if (item.recent) return "recent"
|
||||||
return "file"
|
return "file"
|
||||||
},
|
},
|
||||||
sortGroupsBy: (a, b) => {
|
sortGroupsBy: (a, b) => {
|
||||||
const rank = (category: string) => {
|
const rank = (category: string) => {
|
||||||
if (category === "agent") return 0
|
if (category === "reference") return 0
|
||||||
if (category === "recent") return 1
|
if (category === "agent") return 1
|
||||||
return 2
|
if (category === "resource") return 2
|
||||||
|
if (category === "recent") return 3
|
||||||
|
return 4
|
||||||
}
|
}
|
||||||
return rank(a.category) - rank(b.category)
|
return rank(a.category) - rank(b.category)
|
||||||
},
|
},
|
||||||
@@ -744,7 +831,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const pill = document.createElement("span")
|
const pill = document.createElement("span")
|
||||||
pill.textContent = part.content
|
pill.textContent = part.content
|
||||||
pill.setAttribute("data-type", part.type)
|
pill.setAttribute("data-type", part.type)
|
||||||
if (part.type === "file") pill.setAttribute("data-path", part.path)
|
if (part.type === "file") {
|
||||||
|
pill.setAttribute("data-path", part.path)
|
||||||
|
if (part.mime) pill.setAttribute("data-mime", part.mime)
|
||||||
|
if (part.filename) pill.setAttribute("data-filename", part.filename)
|
||||||
|
if (part.url) pill.setAttribute("data-url", part.url)
|
||||||
|
if (part.source?.type === "resource") {
|
||||||
|
pill.setAttribute("data-source-type", part.source.type)
|
||||||
|
pill.setAttribute("data-source-client-name", part.source.clientName)
|
||||||
|
pill.setAttribute("data-source-uri", part.source.uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
if (part.type === "agent") pill.setAttribute("data-name", part.name)
|
if (part.type === "agent") pill.setAttribute("data-name", part.name)
|
||||||
pill.setAttribute("contenteditable", "false")
|
pill.setAttribute("contenteditable", "false")
|
||||||
pill.style.userSelect = "text"
|
pill.style.userSelect = "text"
|
||||||
@@ -789,8 +886,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-scroll active command into view when navigating with keyboard
|
const scrollSlashActiveIntoView = () => {
|
||||||
createEffect(() => {
|
|
||||||
const activeId = slashActive()
|
const activeId = slashActive()
|
||||||
if (!activeId || !slashPopoverRef) return
|
if (!activeId || !slashPopoverRef) return
|
||||||
|
|
||||||
@@ -798,7 +894,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const element = slashPopoverRef.querySelector(`[data-slash-id="${activeId}"]`)
|
const element = slashPopoverRef.querySelector(`[data-slash-id="${activeId}"]`)
|
||||||
element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||||
})
|
})
|
||||||
})
|
}
|
||||||
const selectPopoverActive = () => {
|
const selectPopoverActive = () => {
|
||||||
if (store.popover === "at") {
|
if (store.popover === "at") {
|
||||||
const items = atFlat()
|
const items = atFlat()
|
||||||
@@ -860,12 +956,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
const pushFile = (file: HTMLElement) => {
|
const pushFile = (file: HTMLElement) => {
|
||||||
const content = file.textContent ?? ""
|
const content = file.textContent ?? ""
|
||||||
|
const source =
|
||||||
|
file.dataset.sourceType === "resource" && file.dataset.sourceClientName && file.dataset.sourceUri
|
||||||
|
? {
|
||||||
|
type: "resource" as const,
|
||||||
|
text: {
|
||||||
|
value: content,
|
||||||
|
start: position,
|
||||||
|
end: position + content.length,
|
||||||
|
},
|
||||||
|
clientName: file.dataset.sourceClientName,
|
||||||
|
uri: file.dataset.sourceUri,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
parts.push({
|
parts.push({
|
||||||
type: "file",
|
type: "file",
|
||||||
path: file.dataset.path!,
|
path: file.dataset.path!,
|
||||||
content,
|
content,
|
||||||
start: position,
|
start: position,
|
||||||
end: position + content.length,
|
end: position + content.length,
|
||||||
|
...(file.dataset.mime ? { mime: file.dataset.mime } : {}),
|
||||||
|
...(file.dataset.filename ? { filename: file.dataset.filename } : {}),
|
||||||
|
...(file.dataset.url ? { url: file.dataset.url } : {}),
|
||||||
|
...(source ? { source } : {}),
|
||||||
})
|
})
|
||||||
position += content.length
|
position += content.length
|
||||||
}
|
}
|
||||||
@@ -1285,6 +1398,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
if (store.popover === "slash") {
|
if (store.popover === "slash") {
|
||||||
slashOnKeyDown(event)
|
slashOnKeyDown(event)
|
||||||
|
if (event.key === "ArrowUp" || event.key === "ArrowDown" || ctrlNav) {
|
||||||
|
scrollSlashActiveIntoView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
return
|
return
|
||||||
@@ -1343,9 +1459,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const agentsLoading = () => props.controls.agents.loading
|
const agentsLoading = () => props.controls.agents.loading
|
||||||
const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading())
|
const agentsShouldFadeIn = createMemo<boolean>((prev) => prev ?? agentsLoading())
|
||||||
const providersLoading = () => props.controls.model.loading
|
const providersLoading = () => props.controls.model.loading
|
||||||
const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
|
const providersShouldFadeIn = createMemo<boolean>((prev) => prev ?? providersLoading())
|
||||||
|
|
||||||
const [promptReady] = createResource(
|
const [promptReady] = createResource(
|
||||||
() => prompt.ready.promise,
|
() => prompt.ready.promise,
|
||||||
@@ -1359,12 +1475,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
const modelControlState = createMemo<ComposerModelControlState>(() => ({
|
const modelControlState = createMemo<ComposerModelControlState>(() => ({
|
||||||
loading: providersLoading(),
|
loading: providersLoading(),
|
||||||
|
shouldAnimate: providersShouldFadeIn(),
|
||||||
paid: props.controls.model.paid,
|
paid: props.controls.model.paid,
|
||||||
title: language.t("command.model.choose"),
|
title: language.t("command.model.choose"),
|
||||||
keybind: command.keybind("model.choose"),
|
keybind: command.keybindParts("model.choose"),
|
||||||
model: props.controls.model.selection,
|
model: props.controls.model.selection,
|
||||||
providerID: props.controls.model.selection.current()?.provider?.id,
|
providerID: props.controls.model.selection.current()?.provider?.id,
|
||||||
modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
|
modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
|
||||||
|
newLayoutDesigns: props.controls.newLayoutDesigns,
|
||||||
style: control(),
|
style: control(),
|
||||||
onClose: restoreFocus,
|
onClose: restoreFocus,
|
||||||
onUnpaidClick: () => {
|
onUnpaidClick: () => {
|
||||||
@@ -1375,10 +1493,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const newSession = () => props.variant === "new-session"
|
const newSession = () => props.variant === "new-session"
|
||||||
|
const bindEditorRef = (el: HTMLDivElement) => {
|
||||||
|
editorRef = el
|
||||||
|
restoreEndOnFocus = true
|
||||||
|
props.ref?.(el)
|
||||||
|
}
|
||||||
const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0)
|
const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0)
|
||||||
const agentControlState = createMemo<ComposerAgentControlState>(() => ({
|
const agentControlState = createMemo<ComposerAgentControlState>(() => ({
|
||||||
title: language.t("command.agent.cycle"),
|
title: language.t("command.agent.cycle"),
|
||||||
keybind: command.keybind("agent.cycle"),
|
keybind: command.keybindParts("agent.cycle"),
|
||||||
options: props.controls.agents.options,
|
options: props.controls.agents.options,
|
||||||
current: props.controls.agents.current,
|
current: props.controls.agents.current,
|
||||||
style: control(),
|
style: control(),
|
||||||
@@ -1403,6 +1526,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
setSlashActive={setSlashActive}
|
setSlashActive={setSlashActive}
|
||||||
onSlashSelect={handleSlashSelect}
|
onSlashSelect={handleSlashSelect}
|
||||||
commandKeybind={command.keybind}
|
commandKeybind={command.keybind}
|
||||||
|
commandKeybindParts={command.keybindParts}
|
||||||
|
newLayoutDesigns={props.controls.newLayoutDesigns}
|
||||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||||
/>
|
/>
|
||||||
<Switch>
|
<Switch>
|
||||||
@@ -1456,10 +1581,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
<div class="relative max-h-[180px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
|
<div class="relative max-h-[180px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
|
||||||
<div
|
<div
|
||||||
data-component="prompt-input"
|
data-component="prompt-input"
|
||||||
ref={(el) => {
|
ref={bindEditorRef}
|
||||||
editorRef = el
|
|
||||||
props.ref?.(el)
|
|
||||||
}}
|
|
||||||
role="textbox"
|
role="textbox"
|
||||||
aria-multiline="true"
|
aria-multiline="true"
|
||||||
aria-label={designPlaceholder()}
|
aria-label={designPlaceholder()}
|
||||||
@@ -1474,6 +1596,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
onCompositionStart={handleCompositionStart}
|
onCompositionStart={handleCompositionStart}
|
||||||
onCompositionEnd={handleCompositionEnd}
|
onCompositionEnd={handleCompositionEnd}
|
||||||
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
classList={{
|
classList={{
|
||||||
@@ -1494,12 +1617,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex h-11 items-center px-2">
|
<div class="flex h-11 items-center px-2">
|
||||||
<div class="flex min-w-0 flex-1 items-center gap-0">
|
<div class="flex min-w-0 flex-1 items-center gap-1">
|
||||||
{fileAttachmentInput()}
|
{fileAttachmentInput()}
|
||||||
<TooltipKeybind
|
<TooltipV2
|
||||||
placement="top"
|
placement="top"
|
||||||
title={language.t("prompt.action.attachFile")}
|
value={
|
||||||
keybind={command.keybind("file.attach")}
|
<>
|
||||||
|
{language.t("prompt.action.attachFile")}
|
||||||
|
<KeybindV2 keys={command.keybindParts("file.attach")} variant="neutral" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
data-action="prompt-attach"
|
data-action="prompt-attach"
|
||||||
@@ -1513,47 +1640,76 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||||
aria-label={language.t("prompt.action.attachFile")}
|
aria-label={language.t("prompt.action.attachFile")}
|
||||||
/>
|
/>
|
||||||
</TooltipKeybind>
|
</TooltipV2>
|
||||||
<Show when={showAgentControl()}>
|
<Show when={showAgentControl()}>
|
||||||
<ComposerAgentControl state={agentControlState()} />
|
<ComposerAgentControl state={agentControlState()} />
|
||||||
</Show>
|
</Show>
|
||||||
{props.toolbar}
|
{props.toolbar}
|
||||||
<ComposerModelControl state={modelControlState()} />
|
<ComposerModelControl state={modelControlState()} />
|
||||||
<Show when={store.mode !== "shell" && showVariantControl()}>
|
<Show when={!providersLoading() && store.mode !== "shell" && showVariantControl()}>
|
||||||
<div
|
<div
|
||||||
data-component="prompt-variant-control"
|
data-component="prompt-variant-control"
|
||||||
|
class="[&_[data-action=prompt-model-variant]]:![font-weight:440]"
|
||||||
classList={{
|
classList={{
|
||||||
|
"animate-in fade-in": providersShouldFadeIn(),
|
||||||
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
|
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
|
||||||
!props.controls.model.selection.variant.current() && !store.variantOpen,
|
!props.controls.model.selection.variant.current() && !store.variantOpen,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TooltipKeybind
|
<TooltipV2
|
||||||
placement="top"
|
placement="top"
|
||||||
gutter={4}
|
gutter={4}
|
||||||
title={language.t("command.model.variant.cycle")}
|
value={
|
||||||
keybind={command.keybind("model.variant.cycle")}
|
<>
|
||||||
|
{language.t("command.model.variant.cycle")}
|
||||||
|
<KeybindV2 keys={command.keybindParts("model.variant.cycle")} variant="neutral" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Select
|
<MenuV2
|
||||||
size="normal"
|
gutter={6}
|
||||||
options={variants()}
|
modal={false}
|
||||||
current={props.controls.model.selection.variant.current() ?? "default"}
|
placement="top-start"
|
||||||
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
|
||||||
onOpenChange={(open) => setStore("variantOpen", open)}
|
onOpenChange={(open) => setStore("variantOpen", open)}
|
||||||
onSelect={(value) => {
|
>
|
||||||
props.controls.model.selection.variant.set(value === "default" ? undefined : value)
|
<MenuV2.Trigger
|
||||||
restoreFocus()
|
as={ButtonV2}
|
||||||
}}
|
data-action="prompt-model-variant"
|
||||||
class="capitalize max-w-[160px] justify-start text-v2-text-text-faint"
|
variant="ghost-muted"
|
||||||
valueClass="truncate text-[13px] font-[440] leading-5 text-v2-text-text-faint"
|
size="normal"
|
||||||
triggerStyle={control()}
|
class="max-w-[160px] justify-start capitalize"
|
||||||
triggerProps={{ "data-action": "prompt-model-variant" }}
|
style={control()}
|
||||||
variant="ghost"
|
>
|
||||||
/>
|
<span class="truncate">
|
||||||
</TooltipKeybind>
|
{props.controls.model.selection.variant.current() ?? language.t("common.default")}
|
||||||
|
</span>
|
||||||
|
<span class="-ml-0.5 -mr-1 flex shrink-0">
|
||||||
|
<Icon name="chevron-down" size="small" />
|
||||||
|
</span>
|
||||||
|
</MenuV2.Trigger>
|
||||||
|
<MenuV2.Portal>
|
||||||
|
<MenuV2.Content>
|
||||||
|
<MenuV2.RadioGroup
|
||||||
|
value={props.controls.model.selection.variant.current() ?? "default"}
|
||||||
|
onChange={(value) => {
|
||||||
|
props.controls.model.selection.variant.set(value === "default" ? undefined : value)
|
||||||
|
restoreFocus()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{variants().map((value) => (
|
||||||
|
<MenuV2.RadioItem value={value} class="capitalize">
|
||||||
|
{value === "default" ? language.t("common.default") : value}
|
||||||
|
</MenuV2.RadioItem>
|
||||||
|
))}
|
||||||
|
</MenuV2.RadioGroup>
|
||||||
|
</MenuV2.Content>
|
||||||
|
</MenuV2.Portal>
|
||||||
|
</MenuV2>
|
||||||
|
</TooltipV2>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
|
<TooltipV2 placement="top" inactive={!working() && blank()} value={tip()}>
|
||||||
<IconButton
|
<IconButton
|
||||||
data-action="prompt-submit"
|
data-action="prompt-submit"
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -1568,7 +1724,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}}
|
}}
|
||||||
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</TooltipV2>
|
||||||
</div>
|
</div>
|
||||||
</DockShellForm>
|
</DockShellForm>
|
||||||
</div>
|
</div>
|
||||||
@@ -1628,10 +1784,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
data-component="prompt-input"
|
data-component="prompt-input"
|
||||||
ref={(el) => {
|
ref={bindEditorRef}
|
||||||
editorRef = el
|
|
||||||
props.ref?.(el)
|
|
||||||
}}
|
|
||||||
role="textbox"
|
role="textbox"
|
||||||
aria-multiline="true"
|
aria-multiline="true"
|
||||||
aria-label={placeholder()}
|
aria-label={placeholder()}
|
||||||
@@ -1646,6 +1799,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
onCompositionStart={handleCompositionStart}
|
onCompositionStart={handleCompositionStart}
|
||||||
onCompositionEnd={handleCompositionEnd}
|
onCompositionEnd={handleCompositionEnd}
|
||||||
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
classList={{
|
classList={{
|
||||||
@@ -1765,7 +1919,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
<Show when={!agentsLoading()}>
|
<Show when={!agentsLoading()}>
|
||||||
<div
|
<div
|
||||||
data-component="prompt-agent-control"
|
data-component="prompt-agent-control"
|
||||||
style={agentsShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
classList={{ "animate-in fade-in duration-300": agentsShouldFadeIn() }}
|
||||||
>
|
>
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
placement="top"
|
placement="top"
|
||||||
@@ -1794,7 +1948,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
<Show when={store.mode !== "shell"}>
|
<Show when={store.mode !== "shell"}>
|
||||||
<div
|
<div
|
||||||
data-component="prompt-model-control"
|
data-component="prompt-model-control"
|
||||||
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={props.controls.model.paid}
|
when={props.controls.model.paid}
|
||||||
@@ -1873,7 +2027,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
<Show when={showVariantControl()}>
|
<Show when={showVariantControl()}>
|
||||||
<div
|
<div
|
||||||
data-component="prompt-variant-control"
|
data-component="prompt-variant-control"
|
||||||
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
|
||||||
>
|
>
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
placement="top"
|
placement="top"
|
||||||
@@ -1914,7 +2068,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
type ComposerAgentControlState = {
|
type ComposerAgentControlState = {
|
||||||
title: string
|
title: string
|
||||||
keybind: string
|
keybind: string[]
|
||||||
options: string[]
|
options: string[]
|
||||||
current: string
|
current: string
|
||||||
style: JSX.CSSProperties | undefined
|
style: JSX.CSSProperties | undefined
|
||||||
@@ -1923,12 +2077,14 @@ type ComposerAgentControlState = {
|
|||||||
|
|
||||||
type ComposerModelControlState = {
|
type ComposerModelControlState = {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
|
shouldAnimate: boolean
|
||||||
paid: boolean
|
paid: boolean
|
||||||
title: string
|
title: string
|
||||||
keybind: string
|
keybind: string[]
|
||||||
model: ReturnType<typeof useLocal>["model"]
|
model: ReturnType<typeof useLocal>["model"]
|
||||||
providerID?: string
|
providerID?: string
|
||||||
modelName: string
|
modelName: string
|
||||||
|
newLayoutDesigns: boolean
|
||||||
style: JSX.CSSProperties | undefined
|
style: JSX.CSSProperties | undefined
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onUnpaidClick: () => void
|
onUnpaidClick: () => void
|
||||||
@@ -1940,7 +2096,16 @@ function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
|
|||||||
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center text-v2-icon-icon-muted">
|
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center text-v2-icon-icon-muted">
|
||||||
<Icon name="sliders" size="small" />
|
<Icon name="sliders" size="small" />
|
||||||
</div>
|
</div>
|
||||||
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
|
<TooltipV2
|
||||||
|
placement="top"
|
||||||
|
gutter={4}
|
||||||
|
value={
|
||||||
|
<>
|
||||||
|
{props.state.title}
|
||||||
|
<KeybindV2 keys={props.state.keybind} variant="neutral" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Select
|
<Select
|
||||||
size="normal"
|
size="normal"
|
||||||
options={props.state.options}
|
options={props.state.options}
|
||||||
@@ -1952,7 +2117,7 @@ function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
|
|||||||
triggerProps={{ "data-action": "prompt-agent" }}
|
triggerProps={{ "data-action": "prompt-agent" }}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
/>
|
/>
|
||||||
</TooltipKeybind>
|
</TooltipV2>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1963,13 +2128,23 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
|||||||
<Show
|
<Show
|
||||||
when={props.state.paid}
|
when={props.state.paid}
|
||||||
fallback={
|
fallback={
|
||||||
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
|
<TooltipV2
|
||||||
|
placement="top"
|
||||||
|
gutter={4}
|
||||||
|
value={
|
||||||
|
<>
|
||||||
|
{props.state.title}
|
||||||
|
<KeybindV2 keys={props.state.keybind} variant="neutral" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
data-action="prompt-model"
|
data-action="prompt-model"
|
||||||
as="div"
|
as="div"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="normal"
|
size="normal"
|
||||||
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
|
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
|
||||||
|
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
|
||||||
style={props.state.style}
|
style={props.state.style}
|
||||||
onClick={props.state.onUnpaidClick}
|
onClick={props.state.onUnpaidClick}
|
||||||
>
|
>
|
||||||
@@ -1987,39 +2162,78 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
|||||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipKeybind>
|
</TooltipV2>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
|
<TooltipV2
|
||||||
<ModelSelectorPopover
|
placement="top"
|
||||||
model={props.state.model}
|
gutter={4}
|
||||||
triggerAs={Button}
|
value={
|
||||||
triggerProps={{
|
<>
|
||||||
variant: "ghost",
|
{props.state.title}
|
||||||
size: "normal",
|
<KeybindV2 keys={props.state.keybind} variant="neutral" />
|
||||||
style: props.state.style,
|
</>
|
||||||
class:
|
}
|
||||||
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
|
>
|
||||||
"data-action": "prompt-model",
|
<Show
|
||||||
}}
|
when={props.state.newLayoutDesigns}
|
||||||
onClose={props.state.onClose}
|
fallback={
|
||||||
|
<ModelSelectorPopover
|
||||||
|
model={props.state.model}
|
||||||
|
triggerAs={Button}
|
||||||
|
triggerProps={{
|
||||||
|
variant: "ghost",
|
||||||
|
size: "normal",
|
||||||
|
style: props.state.style,
|
||||||
|
class:
|
||||||
|
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
|
||||||
|
classList: { "animate-in fade-in": props.state.shouldAnimate },
|
||||||
|
"data-action": "prompt-model",
|
||||||
|
}}
|
||||||
|
onClose={props.state.onClose}
|
||||||
|
>
|
||||||
|
<ModelControlContent state={props.state} />
|
||||||
|
</ModelSelectorPopover>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Show when={props.state.providerID}>
|
<ModelSelectorPopoverV2
|
||||||
{(providerID) => (
|
model={props.state.model}
|
||||||
<ProviderIcon
|
triggerAs={ButtonV2}
|
||||||
id={providerID()}
|
triggerProps={{
|
||||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
variant: "ghost-muted",
|
||||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
size: "normal",
|
||||||
/>
|
style: props.state.style,
|
||||||
)}
|
class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
|
||||||
</Show>
|
classList: { "animate-in fade-in": props.state.shouldAnimate },
|
||||||
<span class="truncate">{props.state.modelName}</span>
|
"data-action": "prompt-model",
|
||||||
<span class="-ml-1 shrink-0 flex size-fit">
|
}}
|
||||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
onClose={props.state.onClose}
|
||||||
</span>
|
>
|
||||||
</ModelSelectorPopover>
|
<ModelControlContent state={props.state} v2 />
|
||||||
</TooltipKeybind>
|
</ModelSelectorPopoverV2>
|
||||||
|
</Show>
|
||||||
|
</TooltipV2>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ModelControlContent(props: { state: ComposerModelControlState; v2?: boolean }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Show when={props.state.providerID}>
|
||||||
|
{(providerID) => (
|
||||||
|
<ProviderIcon
|
||||||
|
id={providerID()}
|
||||||
|
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||||
|
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<span class="truncate">{props.state.modelName}</span>
|
||||||
|
<span class={props.v2 ? "-ml-0.5 -mr-1 flex shrink-0" : "-ml-1 shrink-0 flex size-fit"}>
|
||||||
|
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -100,6 +100,41 @@ describe("buildRequestParts", () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("preserves reference aliases as directory file parts", () => {
|
||||||
|
const result = buildRequestParts({
|
||||||
|
prompt: [
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
path: "/repo/../docs",
|
||||||
|
content: "@docs",
|
||||||
|
start: 0,
|
||||||
|
end: 5,
|
||||||
|
mime: "application/x-directory",
|
||||||
|
filename: "docs",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
context: [],
|
||||||
|
images: [],
|
||||||
|
text: "@docs",
|
||||||
|
messageID: "msg_reference",
|
||||||
|
sessionID: "ses_reference",
|
||||||
|
sessionDirectory: "/repo/app",
|
||||||
|
})
|
||||||
|
|
||||||
|
const filePart = result.requestParts.find((part) => part.type === "file")
|
||||||
|
expect(filePart).toBeDefined()
|
||||||
|
if (filePart?.type === "file") {
|
||||||
|
expect(filePart.mime).toBe("application/x-directory")
|
||||||
|
expect(filePart.filename).toBe("docs")
|
||||||
|
expect(filePart.url).toBe("file:///repo/../docs")
|
||||||
|
expect(filePart.source?.type).toBe("file")
|
||||||
|
if (filePart.source?.type === "file") {
|
||||||
|
expect(filePart.source.path).toBe("/repo/../docs")
|
||||||
|
expect(filePart.source.text.value).toBe("@docs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("deduplicates context files when prompt already includes same path", () => {
|
test("deduplicates context files when prompt already includes same path", () => {
|
||||||
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
|
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
|
||||||
|
|
||||||
|
|||||||
@@ -99,21 +99,31 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
|||||||
|
|
||||||
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
|
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
|
||||||
const path = absolute(input.sessionDirectory, attachment.path)
|
const path = absolute(input.sessionDirectory, attachment.path)
|
||||||
|
const source = attachment.source
|
||||||
|
? {
|
||||||
|
...attachment.source,
|
||||||
|
text: {
|
||||||
|
value: attachment.content,
|
||||||
|
start: attachment.start,
|
||||||
|
end: attachment.end,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: "file" as const,
|
||||||
|
text: {
|
||||||
|
value: attachment.content,
|
||||||
|
start: attachment.start,
|
||||||
|
end: attachment.end,
|
||||||
|
},
|
||||||
|
path,
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: Identifier.ascending("part"),
|
id: Identifier.ascending("part"),
|
||||||
type: "file",
|
type: "file",
|
||||||
mime: "text/plain",
|
mime: attachment.mime ?? "text/plain",
|
||||||
url: `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
|
url: attachment.url ?? `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
|
||||||
filename: getFilename(attachment.path),
|
filename: attachment.filename ?? getFilename(attachment.path),
|
||||||
source: {
|
source,
|
||||||
type: "file",
|
|
||||||
text: {
|
|
||||||
value: attachment.content,
|
|
||||||
start: attachment.start,
|
|
||||||
end: attachment.end,
|
|
||||||
},
|
|
||||||
path,
|
|
||||||
},
|
|
||||||
} satisfies PromptRequestPart
|
} satisfies PromptRequestPart
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
|||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
placement="top"
|
placement="top"
|
||||||
openDelay={2000}
|
openDelay={800}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
import { Component, For, Match, Show, Switch } from "solid-js"
|
import { Component, For, Match, Show, Switch } from "solid-js"
|
||||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||||
|
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||||
|
|
||||||
export type AtOption =
|
export type AtOption =
|
||||||
| { type: "agent"; name: string; display: string }
|
| { type: "agent"; name: string; display: string }
|
||||||
|
| {
|
||||||
|
type: "resource"
|
||||||
|
name: string
|
||||||
|
uri: string
|
||||||
|
client: string
|
||||||
|
display: string
|
||||||
|
description?: string
|
||||||
|
mime?: string
|
||||||
|
}
|
||||||
|
| { type: "reference"; name: string; path: string; display: string; description: string }
|
||||||
| { type: "file"; path: string; display: string; recent?: boolean }
|
| { type: "file"; path: string; display: string; recent?: boolean }
|
||||||
|
|
||||||
export interface SlashCommand {
|
export interface SlashCommand {
|
||||||
@@ -30,6 +42,8 @@ type PromptPopoverProps = {
|
|||||||
setSlashActive: (id: string) => void
|
setSlashActive: (id: string) => void
|
||||||
onSlashSelect: (item: SlashCommand) => void
|
onSlashSelect: (item: SlashCommand) => void
|
||||||
commandKeybind: (id: string) => string | undefined
|
commandKeybind: (id: string) => string | undefined
|
||||||
|
commandKeybindParts: (id: string) => string[]
|
||||||
|
newLayoutDesigns: boolean
|
||||||
t: (key: string) => string
|
t: (key: string) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,15 +55,29 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
|
|||||||
if (props.popover === "slash") props.setSlashPopoverRef(el)
|
if (props.popover === "slash") props.setSlashPopoverRef(el)
|
||||||
}}
|
}}
|
||||||
class="absolute inset-x-0 -top-2 -translate-y-full origin-bottom-left max-h-80 min-h-10
|
class="absolute inset-x-0 -top-2 -translate-y-full origin-bottom-left max-h-80 min-h-10
|
||||||
overflow-auto no-scrollbar flex flex-col p-2 rounded-[12px]
|
overflow-auto no-scrollbar flex flex-col p-2"
|
||||||
bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]"
|
classList={{
|
||||||
|
"z-[70] rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": props.newLayoutDesigns,
|
||||||
|
"rounded-[12px] bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]":
|
||||||
|
!props.newLayoutDesigns,
|
||||||
|
}}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={props.popover === "at"}>
|
<Match when={props.popover === "at"}>
|
||||||
<Show
|
<Show
|
||||||
when={props.atFlat.length > 0}
|
when={props.atFlat.length > 0}
|
||||||
fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>}
|
fallback={
|
||||||
|
<div
|
||||||
|
class="px-2 py-1"
|
||||||
|
classList={{
|
||||||
|
"text-v2-text-text-muted": props.newLayoutDesigns,
|
||||||
|
"text-text-weak": !props.newLayoutDesigns,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.t("prompt.popover.emptyResults")}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<For each={props.atFlat.slice(0, 10)}>
|
<For each={props.atFlat.slice(0, 10)}>
|
||||||
{(item) => {
|
{(item) => {
|
||||||
@@ -58,13 +86,117 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
|
|||||||
if (item.type === "agent") {
|
if (item.type === "agent") {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
|
class="w-full flex items-center gap-x-2 px-2 py-0.5"
|
||||||
classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
|
classList={{
|
||||||
|
"rounded-[4px]": props.newLayoutDesigns,
|
||||||
|
"rounded-md": !props.newLayoutDesigns,
|
||||||
|
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.atActive === key,
|
||||||
|
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.atActive === key,
|
||||||
|
}}
|
||||||
onClick={() => props.onAtSelect(item)}
|
onClick={() => props.onAtSelect(item)}
|
||||||
onMouseEnter={() => props.setAtActive(key)}
|
onPointerMove={() => props.setAtActive(key)}
|
||||||
>
|
>
|
||||||
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
|
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
|
||||||
<span class="text-14-regular text-text-strong whitespace-nowrap">@{item.name}</span>
|
<span
|
||||||
|
class="whitespace-nowrap"
|
||||||
|
classList={{
|
||||||
|
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
|
||||||
|
props.newLayoutDesigns,
|
||||||
|
"text-v2-text-text-base": props.newLayoutDesigns,
|
||||||
|
"text-14-regular": !props.newLayoutDesigns,
|
||||||
|
"text-text-strong": !props.newLayoutDesigns,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
@{item.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === "resource") {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center gap-x-2 px-2 py-0.5"
|
||||||
|
classList={{
|
||||||
|
"rounded-[4px]": props.newLayoutDesigns,
|
||||||
|
"rounded-md": !props.newLayoutDesigns,
|
||||||
|
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.atActive === key,
|
||||||
|
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.atActive === key,
|
||||||
|
}}
|
||||||
|
onClick={() => props.onAtSelect(item)}
|
||||||
|
onPointerMove={() => props.setAtActive(key)}
|
||||||
|
>
|
||||||
|
<FileIcon node={{ path: item.uri, type: "file" }} class="shrink-0 size-4" />
|
||||||
|
<div
|
||||||
|
class="flex items-center min-w-0"
|
||||||
|
classList={{
|
||||||
|
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
|
||||||
|
props.newLayoutDesigns,
|
||||||
|
"text-14-regular": !props.newLayoutDesigns,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-text-strong whitespace-nowrap"
|
||||||
|
classList={{ "text-v2-text-text-base": props.newLayoutDesigns }}
|
||||||
|
>
|
||||||
|
@{item.name}
|
||||||
|
</span>
|
||||||
|
<Show when={item.description}>
|
||||||
|
{(description) => (
|
||||||
|
<span
|
||||||
|
class="whitespace-nowrap truncate min-w-0 ml-2"
|
||||||
|
classList={{
|
||||||
|
"text-v2-text-text-muted": props.newLayoutDesigns,
|
||||||
|
"text-text-weak": !props.newLayoutDesigns,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{description()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === "reference") {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center gap-x-2 px-2 py-0.5"
|
||||||
|
classList={{
|
||||||
|
"rounded-[4px]": props.newLayoutDesigns,
|
||||||
|
"rounded-md": !props.newLayoutDesigns,
|
||||||
|
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.atActive === key,
|
||||||
|
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.atActive === key,
|
||||||
|
}}
|
||||||
|
onClick={() => props.onAtSelect(item)}
|
||||||
|
onPointerMove={() => props.setAtActive(key)}
|
||||||
|
>
|
||||||
|
<FileIcon node={{ path: item.path, type: "directory" }} class="shrink-0 size-4" />
|
||||||
|
<div
|
||||||
|
class="flex items-center min-w-0"
|
||||||
|
classList={{
|
||||||
|
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
|
||||||
|
props.newLayoutDesigns,
|
||||||
|
"text-14-regular": !props.newLayoutDesigns,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-text-strong whitespace-nowrap"
|
||||||
|
classList={{ "text-v2-text-text-base": props.newLayoutDesigns }}
|
||||||
|
>
|
||||||
|
@{item.name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="whitespace-nowrap truncate min-w-0 ml-2"
|
||||||
|
classList={{
|
||||||
|
"text-v2-text-text-muted": props.newLayoutDesigns,
|
||||||
|
"text-text-weak": !props.newLayoutDesigns,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -75,16 +207,44 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
|
class="w-full flex items-center gap-x-2 px-2 py-0.5"
|
||||||
classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
|
classList={{
|
||||||
|
"rounded-[4px]": props.newLayoutDesigns,
|
||||||
|
"rounded-md": !props.newLayoutDesigns,
|
||||||
|
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.atActive === key,
|
||||||
|
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.atActive === key,
|
||||||
|
}}
|
||||||
onClick={() => props.onAtSelect(item)}
|
onClick={() => props.onAtSelect(item)}
|
||||||
onMouseEnter={() => props.setAtActive(key)}
|
onPointerMove={() => props.setAtActive(key)}
|
||||||
>
|
>
|
||||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
|
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
|
||||||
<div class="flex items-center text-14-regular min-w-0">
|
<div
|
||||||
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{directory}</span>
|
class="flex items-center min-w-0"
|
||||||
|
classList={{
|
||||||
|
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
|
||||||
|
props.newLayoutDesigns,
|
||||||
|
"text-14-regular": !props.newLayoutDesigns,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="whitespace-nowrap truncate min-w-0"
|
||||||
|
classList={{
|
||||||
|
"text-v2-text-text-muted": props.newLayoutDesigns,
|
||||||
|
"text-text-weak": !props.newLayoutDesigns,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{directory}
|
||||||
|
</span>
|
||||||
<Show when={!isDirectory}>
|
<Show when={!isDirectory}>
|
||||||
<span class="text-text-strong whitespace-nowrap">{filename}</span>
|
<span
|
||||||
|
class="whitespace-nowrap"
|
||||||
|
classList={{
|
||||||
|
"text-v2-text-text-base": props.newLayoutDesigns,
|
||||||
|
"text-text-strong": !props.newLayoutDesigns,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filename}
|
||||||
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -96,41 +256,98 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
|
|||||||
<Match when={props.popover === "slash"}>
|
<Match when={props.popover === "slash"}>
|
||||||
<Show
|
<Show
|
||||||
when={props.slashFlat.length > 0}
|
when={props.slashFlat.length > 0}
|
||||||
fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyCommands")}</div>}
|
fallback={
|
||||||
|
<div
|
||||||
|
class="px-2 py-1"
|
||||||
|
classList={{
|
||||||
|
"text-v2-text-text-muted": props.newLayoutDesigns,
|
||||||
|
"text-text-weak": !props.newLayoutDesigns,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.t("prompt.popover.emptyCommands")}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<For each={props.slashFlat}>
|
<For each={props.slashFlat}>
|
||||||
{(cmd) => (
|
{(cmd) => {
|
||||||
<button
|
const keybind = () => props.commandKeybind(cmd.id)
|
||||||
data-slash-id={cmd.id}
|
const keybindParts = () => props.commandKeybindParts(cmd.id)
|
||||||
classList={{
|
return (
|
||||||
"w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
|
<button
|
||||||
"bg-surface-raised-base-hover": props.slashActive === cmd.id,
|
data-slash-id={cmd.id}
|
||||||
}}
|
classList={{
|
||||||
onClick={() => props.onSlashSelect(cmd)}
|
"w-full flex items-center justify-between gap-4 px-2 py-1": true,
|
||||||
onMouseEnter={() => props.setSlashActive(cmd.id)}
|
"rounded-[4px] scroll-my-2": props.newLayoutDesigns,
|
||||||
>
|
"rounded-md": !props.newLayoutDesigns,
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.slashActive === cmd.id,
|
||||||
<span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
|
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.slashActive === cmd.id,
|
||||||
<Show when={cmd.description}>
|
}}
|
||||||
<span class="text-14-regular text-text-weak truncate">{cmd.description}</span>
|
onClick={() => props.onSlashSelect(cmd)}
|
||||||
</Show>
|
onPointerMove={() => props.setSlashActive(cmd.id)}
|
||||||
</div>
|
>
|
||||||
<div class="flex items-center gap-2 shrink-0">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<Show when={cmd.type === "custom" && cmd.source !== "command"}>
|
<span
|
||||||
<span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
|
class="whitespace-nowrap"
|
||||||
{cmd.source === "skill"
|
classList={{
|
||||||
? props.t("prompt.slash.badge.skill")
|
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
|
||||||
: cmd.source === "mcp"
|
props.newLayoutDesigns,
|
||||||
? props.t("prompt.slash.badge.mcp")
|
"text-v2-text-text-base": props.newLayoutDesigns,
|
||||||
: props.t("prompt.slash.badge.custom")}
|
"text-14-regular": !props.newLayoutDesigns,
|
||||||
|
"text-text-strong": !props.newLayoutDesigns,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
/{cmd.trigger}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
<Show when={cmd.description}>
|
||||||
<Show when={props.commandKeybind(cmd.id)}>
|
<span
|
||||||
<span class="text-12-regular text-text-subtle">{props.commandKeybind(cmd.id)}</span>
|
class="truncate"
|
||||||
</Show>
|
classList={{
|
||||||
</div>
|
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
|
||||||
</button>
|
props.newLayoutDesigns,
|
||||||
)}
|
"text-v2-text-text-muted": props.newLayoutDesigns,
|
||||||
|
"text-14-regular": !props.newLayoutDesigns,
|
||||||
|
"text-text-weak": !props.newLayoutDesigns,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cmd.description}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<Show when={cmd.type === "custom" && cmd.source !== "command"}>
|
||||||
|
<Show
|
||||||
|
when={props.newLayoutDesigns}
|
||||||
|
fallback={
|
||||||
|
<span class="text-11-regular px-1.5 py-0.5 rounded bg-surface-base text-text-subtle">
|
||||||
|
{cmd.source === "skill"
|
||||||
|
? props.t("prompt.slash.badge.skill")
|
||||||
|
: cmd.source === "mcp"
|
||||||
|
? props.t("prompt.slash.badge.mcp")
|
||||||
|
: props.t("prompt.slash.badge.custom")}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Tag>
|
||||||
|
{cmd.source === "skill"
|
||||||
|
? props.t("prompt.slash.badge.skill")
|
||||||
|
: cmd.source === "mcp"
|
||||||
|
? props.t("prompt.slash.badge.mcp")
|
||||||
|
: props.t("prompt.slash.badge.custom")}
|
||||||
|
</Tag>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.newLayoutDesigns ? keybindParts().length > 0 : keybind()}>
|
||||||
|
<Show
|
||||||
|
when={props.newLayoutDesigns}
|
||||||
|
fallback={<span class="text-12-regular text-text-subtle">{keybind()}</span>}
|
||||||
|
>
|
||||||
|
<KeybindV2 keys={keybindParts()} variant="neutral" />
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}}
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
</Match>
|
</Match>
|
||||||
|
|||||||
@@ -92,11 +92,17 @@ export function PromptWorkspaceSelector(props: {
|
|||||||
</MenuV2.Content>
|
</MenuV2.Content>
|
||||||
</MenuV2.Portal>
|
</MenuV2.Portal>
|
||||||
</MenuV2>
|
</MenuV2>
|
||||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
<Show when={props.branch}>
|
||||||
<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]">
|
{(branch) => (
|
||||||
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
<>
|
||||||
<span class="min-w-0 truncate">{props.branch || "main"}</span>
|
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||||
</div>
|
<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">{branch()}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Match, Show, Switch, createMemo } from "solid-js"
|
import { Match, Show, Switch, createMemo, type ComponentProps, type JSX } from "solid-js"
|
||||||
import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
|
|
||||||
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
||||||
|
import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
|
|
||||||
import { useFile } from "@/context/file"
|
import { useFile } from "@/context/file"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
@@ -9,13 +11,23 @@ import { useSync } from "@/context/sync"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
|
import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
|
|
||||||
interface SessionContextUsageProps {
|
interface SessionContextUsageProps {
|
||||||
variant?: "button" | "indicator"
|
variant?: "button" | "indicator"
|
||||||
placement?: TooltipProps["placement"]
|
buttonAppearance?: "default" | "v2"
|
||||||
|
placement?: ComponentProps<typeof TooltipV2>["placement"]
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextTooltipRow(props: { name: JSX.Element; value: JSX.Element }) {
|
||||||
|
return (
|
||||||
|
<div class="flex min-w-0 items-center gap-4">
|
||||||
|
<span class="shrink-0 text-v2-text-text-muted">{props.name}</span>
|
||||||
|
<span class="ml-auto min-w-0 truncate text-right text-v2-text-text-base">{props.value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSessionContext(args: {
|
function openSessionContext(args: {
|
||||||
@@ -23,7 +35,7 @@ function openSessionContext(args: {
|
|||||||
layout: ReturnType<typeof useLayout>
|
layout: ReturnType<typeof useLayout>
|
||||||
tabs: ReturnType<ReturnType<typeof useLayout>["tabs"]>
|
tabs: ReturnType<ReturnType<typeof useLayout>["tabs"]>
|
||||||
}) {
|
}) {
|
||||||
if (!args.view.reviewPanel.opened()) args.view.reviewPanel.open()
|
args.view.reviewPanel.open(args.view.reviewPanel.opened() ? "other" : "context-button")
|
||||||
if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all")
|
if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all")
|
||||||
void args.tabs.open("context")
|
void args.tabs.open("context")
|
||||||
args.tabs.setActive("context")
|
args.tabs.setActive("context")
|
||||||
@@ -39,12 +51,14 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
const { params, tabs, view } = useSessionLayout()
|
const { params, tabs, view } = useSessionLayout()
|
||||||
|
|
||||||
const variant = createMemo(() => props.variant ?? "button")
|
const variant = createMemo(() => props.variant ?? "button")
|
||||||
|
const buttonAppearance = createMemo(() => props.buttonAppearance ?? "default")
|
||||||
const tabState = createSessionTabs({
|
const tabState = createSessionTabs({
|
||||||
tabs,
|
tabs,
|
||||||
pathFromTab: file.pathFromTab,
|
pathFromTab: file.pathFromTab,
|
||||||
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||||
})
|
})
|
||||||
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
|
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
|
||||||
|
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
||||||
|
|
||||||
const usd = createMemo(
|
const usd = createMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -54,21 +68,30 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
|
const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
|
||||||
const context = createMemo(() => metrics().context)
|
const tokens = createMemo(() => info()?.tokens)
|
||||||
const cost = createMemo(() => {
|
const cost = createMemo(() => {
|
||||||
return usd().format(metrics().totalCost)
|
return usd().format(info()?.cost ?? 0)
|
||||||
})
|
})
|
||||||
|
const contextVisible = createMemo(() => view().reviewPanel.opened() && tabState.activeTab() === "context")
|
||||||
|
const hasOtherTabs = createMemo(() =>
|
||||||
|
tabs()
|
||||||
|
.all()
|
||||||
|
.some((tab) => tab !== "context" && tab !== "review"),
|
||||||
|
)
|
||||||
|
|
||||||
const openContext = () => {
|
const openContext = () => {
|
||||||
if (!params.id) return
|
if (!params.id) return
|
||||||
|
|
||||||
if (tabState.activeTab() === "context") {
|
const sessionView = view()
|
||||||
|
if (contextVisible()) {
|
||||||
tabs().close("context")
|
tabs().close("context")
|
||||||
|
if (sessionView.reviewPanel.source() === "context-button" && !hasOtherTabs()) sessionView.reviewPanel.close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
openSessionContext({
|
openSessionContext({
|
||||||
view: view(),
|
view: sessionView,
|
||||||
layout,
|
layout,
|
||||||
tabs: tabs(),
|
tabs: tabs(),
|
||||||
})
|
})
|
||||||
@@ -76,38 +99,54 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
|
|
||||||
const circle = () => (
|
const circle = () => (
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
<ProgressCircle size={16} strokeWidth={2} percentage={context()?.usage ?? 0} />
|
<ProgressCircle
|
||||||
|
size={16}
|
||||||
|
strokeWidth={2}
|
||||||
|
percentage={context()?.usage ?? 0}
|
||||||
|
style={
|
||||||
|
variant() === "indicator"
|
||||||
|
? {
|
||||||
|
"--progress-circle-background": "var(--v2-background-bg-layer-04, var(--border-weak-base))",
|
||||||
|
"--progress-circle-background-overlay": "var(--v2-overlay-simple-overlay-pressed, transparent)",
|
||||||
|
"--progress-circle-progress": "var(--v2-icon-icon-base, var(--icon-base))",
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
const circleV2 = () => (
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<ProgressCircleV2 percentage={context()?.usage ?? 0} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
const tooltipValue = () => (
|
const tooltipValue = () => (
|
||||||
<div>
|
<div class="flex w-[120px] flex-col gap-2">
|
||||||
<Show when={context()}>
|
<ContextTooltipRow name={language.t("context.usage.cost")} value={cost()} />
|
||||||
{(ctx) => (
|
<ContextTooltipRow name={language.t("context.usage.usage")} value={`${context()?.usage ?? 0}%`} />
|
||||||
<>
|
<ContextTooltipRow
|
||||||
<div class="flex items-center gap-2">
|
name={language.t("context.usage.tokens")}
|
||||||
<span class="text-text-invert-strong">{ctx().total.toLocaleString(language.intl())}</span>
|
value={getSessionTokenTotal(tokens())?.toLocaleString(language.intl()) ?? "0"}
|
||||||
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
|
/>
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-text-invert-strong">{ctx().usage ?? 0}%</span>
|
|
||||||
<span class="text-text-invert-base">{language.t("context.usage.usage")}</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-text-invert-strong">{cost()}</span>
|
|
||||||
<span class="text-text-invert-base">{language.t("context.usage.cost")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={params.id}>
|
<Show when={params.id}>
|
||||||
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"}>
|
<TooltipV2 value={tooltipValue()} placement={props.placement ?? "top"} shift={-8}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={variant() === "indicator"}>{circle()}</Match>
|
<Match when={variant() === "indicator"}>{circle()}</Match>
|
||||||
|
<Match when={buttonAppearance() === "v2"}>
|
||||||
|
<IconButtonV2
|
||||||
|
type="button"
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="large"
|
||||||
|
icon={circleV2()}
|
||||||
|
onClick={openContext}
|
||||||
|
aria-label={language.t("context.usage.view")}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -120,7 +159,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</Tooltip>
|
</TooltipV2>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||||
import { getSessionContextMetrics } from "./session-context-metrics"
|
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
|
||||||
|
|
||||||
const assistant = (
|
const assistant = (
|
||||||
id: string,
|
id: string,
|
||||||
@@ -37,8 +37,8 @@ const user = (id: string) => {
|
|||||||
} as unknown as Message
|
} as unknown as Message
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("getSessionContextMetrics", () => {
|
describe("getSessionContext", () => {
|
||||||
test("computes totals and usage from latest assistant with tokens", () => {
|
test("computes usage from latest assistant with tokens", () => {
|
||||||
const messages = [
|
const messages = [
|
||||||
user("u1"),
|
user("u1"),
|
||||||
assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5),
|
assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5),
|
||||||
@@ -57,45 +57,52 @@ describe("getSessionContextMetrics", () => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const metrics = getSessionContextMetrics(messages, providers)
|
const ctx = getSessionContext(messages, providers)
|
||||||
|
|
||||||
expect(metrics.totalCost).toBe(1.75)
|
expect(ctx?.message.id).toBe("a2")
|
||||||
expect(metrics.context?.message.id).toBe("a2")
|
expect(ctx?.usage).toBe(50)
|
||||||
expect(metrics.context?.total).toBe(500)
|
expect(ctx?.providerLabel).toBe("OpenAI")
|
||||||
expect(metrics.context?.usage).toBe(50)
|
expect(ctx?.modelLabel).toBe("GPT-4.1")
|
||||||
expect(metrics.context?.providerLabel).toBe("OpenAI")
|
|
||||||
expect(metrics.context?.modelLabel).toBe("GPT-4.1")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves fallback labels and null usage when model metadata is missing", () => {
|
test("preserves fallback labels and null usage when model metadata is missing", () => {
|
||||||
const messages = [assistant("a1", { input: 40, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1, "p-1", "m-1")]
|
const messages = [assistant("a1", { input: 40, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1, "p-1", "m-1")]
|
||||||
const providers = [{ id: "p-1", models: {} }]
|
const providers = [{ id: "p-1", models: {} }]
|
||||||
|
|
||||||
const metrics = getSessionContextMetrics(messages, providers)
|
const ctx = getSessionContext(messages, providers)
|
||||||
|
|
||||||
expect(metrics.context?.providerLabel).toBe("p-1")
|
expect(ctx?.providerLabel).toBe("p-1")
|
||||||
expect(metrics.context?.modelLabel).toBe("m-1")
|
expect(ctx?.modelLabel).toBe("m-1")
|
||||||
expect(metrics.context?.limit).toBeUndefined()
|
expect(ctx?.limit).toBeUndefined()
|
||||||
expect(metrics.context?.usage).toBeNull()
|
expect(ctx?.usage).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("recomputes when message array is mutated in place", () => {
|
test("recomputes when message array is mutated in place", () => {
|
||||||
const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)]
|
const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)]
|
||||||
const providers = [{ id: "openai", models: {} }]
|
const providers = [{ id: "openai", models: {} }]
|
||||||
|
|
||||||
const one = getSessionContextMetrics(messages, providers)
|
const one = getSessionContext(messages, providers)
|
||||||
messages.push(assistant("a2", { input: 100, output: 20, reasoning: 0, read: 0, write: 0 }, 0.75))
|
messages.push(assistant("a2", { input: 100, output: 20, reasoning: 0, read: 0, write: 0 }, 0.75))
|
||||||
const two = getSessionContextMetrics(messages, providers)
|
const two = getSessionContext(messages, providers)
|
||||||
|
|
||||||
expect(one.context?.message.id).toBe("a1")
|
expect(one?.message.id).toBe("a1")
|
||||||
expect(two.context?.message.id).toBe("a2")
|
expect(two?.message.id).toBe("a2")
|
||||||
expect(two.totalCost).toBe(1)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("returns empty metrics when inputs are undefined", () => {
|
test("returns undefined when inputs are undefined", () => {
|
||||||
const metrics = getSessionContextMetrics(undefined, undefined)
|
const ctx = getSessionContext(undefined, undefined)
|
||||||
|
|
||||||
expect(metrics.totalCost).toBe(0)
|
expect(ctx).toBeUndefined()
|
||||||
expect(metrics.context).toBeUndefined()
|
})
|
||||||
|
|
||||||
|
test("computes stored session token totals", () => {
|
||||||
|
expect(
|
||||||
|
getSessionTokenTotal({
|
||||||
|
input: 10,
|
||||||
|
output: 20,
|
||||||
|
reasoning: 30,
|
||||||
|
cache: { read: 40, write: 50 },
|
||||||
|
}),
|
||||||
|
).toBe(150)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client"
|
import type { AssistantMessage, Message, Session } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
type Provider = {
|
type Provider = {
|
||||||
id: string
|
id: string
|
||||||
@@ -21,19 +21,9 @@ type Context = {
|
|||||||
modelLabel: string
|
modelLabel: string
|
||||||
limit: number | undefined
|
limit: number | undefined
|
||||||
input: number
|
input: number
|
||||||
output: number
|
|
||||||
reasoning: number
|
|
||||||
cacheRead: number
|
|
||||||
cacheWrite: number
|
|
||||||
total: number
|
|
||||||
usage: number | null
|
usage: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type Metrics = {
|
|
||||||
totalCost: number
|
|
||||||
context: Context | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokenTotal = (msg: AssistantMessage) => {
|
const tokenTotal = (msg: AssistantMessage) => {
|
||||||
return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
|
return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
|
||||||
}
|
}
|
||||||
@@ -47,10 +37,9 @@ const lastAssistantWithTokens = (messages: Message[]) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const build = (messages: Message[] = [], providers: Provider[] = []): Metrics => {
|
const build = (messages: Message[] = [], providers: Provider[] = []): Context | undefined => {
|
||||||
const totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0)
|
|
||||||
const message = lastAssistantWithTokens(messages)
|
const message = lastAssistantWithTokens(messages)
|
||||||
if (!message) return { totalCost, context: undefined }
|
if (!message) return undefined
|
||||||
|
|
||||||
const provider = providers.find((item) => item.id === message.providerID)
|
const provider = providers.find((item) => item.id === message.providerID)
|
||||||
const model = provider?.models[message.modelID]
|
const model = provider?.models[message.modelID]
|
||||||
@@ -58,25 +47,22 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Metrics =>
|
|||||||
const total = tokenTotal(message)
|
const total = tokenTotal(message)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalCost,
|
message,
|
||||||
context: {
|
provider,
|
||||||
message,
|
model,
|
||||||
provider,
|
providerLabel: provider?.name ?? message.providerID,
|
||||||
model,
|
modelLabel: model?.name ?? message.modelID,
|
||||||
providerLabel: provider?.name ?? message.providerID,
|
limit,
|
||||||
modelLabel: model?.name ?? message.modelID,
|
input: message.tokens.input,
|
||||||
limit,
|
usage: limit ? Math.round((total / limit) * 100) : null,
|
||||||
input: message.tokens.input,
|
|
||||||
output: message.tokens.output,
|
|
||||||
reasoning: message.tokens.reasoning,
|
|
||||||
cacheRead: message.tokens.cache.read,
|
|
||||||
cacheWrite: message.tokens.cache.write,
|
|
||||||
total,
|
|
||||||
usage: limit ? Math.round((total / limit) * 100) : null,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSessionContextMetrics(messages: Message[] = [], providers: Provider[] = []) {
|
export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) {
|
||||||
return build(messages, providers)
|
return build(messages, providers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSessionTokenTotal(tokens: Session["tokens"] | undefined) {
|
||||||
|
if (!tokens) return undefined
|
||||||
|
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { getSessionContextMetrics } from "./session-context-metrics"
|
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
|
||||||
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
||||||
import { createSessionContextFormatter } from "./session-context-format"
|
import { createSessionContextFormatter } from "./session-context-format"
|
||||||
|
|
||||||
@@ -134,12 +134,12 @@ export function SessionContextTab() {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
|
const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
|
||||||
const ctx = createMemo(() => metrics().context)
|
const tokens = createMemo(() => info()?.tokens)
|
||||||
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
|
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
|
||||||
|
|
||||||
const cost = createMemo(() => {
|
const cost = createMemo(() => {
|
||||||
return usd().format(metrics().totalCost)
|
return usd().format(info()?.cost ?? 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
const counts = createMemo(() => {
|
const counts = createMemo(() => {
|
||||||
@@ -204,14 +204,14 @@ export function SessionContextTab() {
|
|||||||
{ label: "context.stats.provider", value: providerLabel },
|
{ label: "context.stats.provider", value: providerLabel },
|
||||||
{ label: "context.stats.model", value: modelLabel },
|
{ label: "context.stats.model", value: modelLabel },
|
||||||
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
|
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
|
||||||
{ label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) },
|
{ label: "context.stats.totalTokens", value: () => formatter().number(getSessionTokenTotal(tokens())) },
|
||||||
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
|
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
|
||||||
{ label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
|
{ label: "context.stats.inputTokens", value: () => formatter().number(tokens()?.input) },
|
||||||
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.output) },
|
{ label: "context.stats.outputTokens", value: () => formatter().number(tokens()?.output) },
|
||||||
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.reasoning) },
|
{ label: "context.stats.reasoningTokens", value: () => formatter().number(tokens()?.reasoning) },
|
||||||
{
|
{
|
||||||
label: "context.stats.cacheTokens",
|
label: "context.stats.cacheTokens",
|
||||||
value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`,
|
value: () => `${formatter().number(tokens()?.cache.read)} / ${formatter().number(tokens()?.cache.write)}`,
|
||||||
},
|
},
|
||||||
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
|
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
|
||||||
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
|
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
|
||||||
|
|||||||
@@ -541,6 +541,7 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
|
|||||||
</Show>
|
</Show>
|
||||||
<Show when={props.state.reviewVisible}>
|
<Show when={props.state.reviewVisible}>
|
||||||
<TooltipV2
|
<TooltipV2
|
||||||
|
class="shrink-0"
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
value={
|
value={
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-titlebar-tab-slot]:not(:first-child)::before {
|
[data-titlebar-tab-slot]:not(:first-child):not([data-active="true"])::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
@@ -32,6 +32,10 @@
|
|||||||
background: var(--v2-background-bg-layer-02);
|
background: var(--v2-background-bg-layer-02);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-titlebar-tab-slot][data-active="true"] + [data-titlebar-tab-slot]::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
[data-titlebar-tab] [data-slot="tab-close"]::before {
|
[data-titlebar-tab] [data-slot="tab-close"]::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -5,19 +5,24 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
|||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection, serverName } from "@/context/server"
|
||||||
import { projectForSession } from "@/pages/layout/helpers"
|
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
||||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import type { Session } from "@opencode-ai/sdk/v2"
|
import type { Session } from "@opencode-ai/sdk/v2"
|
||||||
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
|
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
|
||||||
|
import { TabPreviewPopover } from "./titlebar-tab-popover"
|
||||||
import "./titlebar-tab-nav.css"
|
import "./titlebar-tab-nav.css"
|
||||||
|
|
||||||
|
// MouseEvent.button uses 1 for the middle/wheel button.
|
||||||
|
const MIDDLE_MOUSE_BUTTON = 1
|
||||||
|
|
||||||
export function TabNavItem(props: {
|
export function TabNavItem(props: {
|
||||||
ref?: Ref<HTMLDivElement>
|
ref?: Ref<HTMLDivElement>
|
||||||
href: string
|
href: string
|
||||||
server: ServerConnection.Key
|
server: ServerConnection.Key
|
||||||
session: () => Session | undefined
|
session: () => Session | undefined
|
||||||
|
fallbackTitle?: string
|
||||||
onTitleChange?: (title: string) => void
|
onTitleChange?: (title: string) => void
|
||||||
onTitleChangeFailed?: (title: string) => void
|
onTitleChangeFailed?: (title: string) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -53,6 +58,33 @@ export function TabNavItem(props: {
|
|||||||
if (!session) return
|
if (!session) return
|
||||||
return projectForSession(session, serverCtx()?.projects.list() ?? [])
|
return projectForSession(session, serverCtx()?.projects.list() ?? [])
|
||||||
})
|
})
|
||||||
|
const title = createMemo(() => props.session()?.title ?? props.fallbackTitle)
|
||||||
|
|
||||||
|
const projectName = createMemo(() => {
|
||||||
|
const session = props.session()
|
||||||
|
if (!session) return
|
||||||
|
return displayName(project() ?? { worktree: session.directory })
|
||||||
|
})
|
||||||
|
const previewPath = createMemo(() => {
|
||||||
|
const session = props.session()
|
||||||
|
if (!session) return
|
||||||
|
const home = serverCtx()?.sync.data.path.home
|
||||||
|
return home ? session.directory.replace(home, "~") : session.directory
|
||||||
|
})
|
||||||
|
const branch = createMemo(() => {
|
||||||
|
const session = props.session()
|
||||||
|
if (!session) return
|
||||||
|
return serverCtx()?.sync.child(session.directory, { bootstrap: false })[0].vcs?.branch
|
||||||
|
})
|
||||||
|
// Only label the server when multiple servers are connected.
|
||||||
|
const serverLabel = createMemo(() => {
|
||||||
|
if (global.servers.list().length <= 1) return
|
||||||
|
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
|
||||||
|
return conn ? serverName(conn) : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const [popoverOpen, setPopoverOpen] = createSignal(false)
|
||||||
|
const previewBlocked = () => !!props.dragging || editing() || !!props.pressed || !props.session()
|
||||||
|
|
||||||
const measureTitleOverflow = () => {
|
const measureTitleOverflow = () => {
|
||||||
if (!titleEl || editing()) {
|
if (!titleEl || editing()) {
|
||||||
@@ -71,7 +103,7 @@ export function TabNavItem(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
props.session()?.title
|
title()
|
||||||
props.forceTruncate
|
props.forceTruncate
|
||||||
editing()
|
editing()
|
||||||
scheduleTitleOverflow()
|
scheduleTitleOverflow()
|
||||||
@@ -130,9 +162,9 @@ export function TabNavItem(props: {
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (editing()) return
|
if (editing()) return
|
||||||
if (!titleEl) return
|
if (!titleEl) return
|
||||||
const title = props.session()?.title
|
const value = title()
|
||||||
if (title === undefined) return
|
if (value === undefined) return
|
||||||
titleEl.textContent = title
|
titleEl.textContent = value
|
||||||
})
|
})
|
||||||
|
|
||||||
const openRename = (event: MouseEvent) => {
|
const openRename = (event: MouseEvent) => {
|
||||||
@@ -168,7 +200,7 @@ export function TabNavItem(props: {
|
|||||||
onCleanup(cleanup)
|
onCleanup(cleanup)
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
const tab = (
|
||||||
<div
|
<div
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
tabRoot = el
|
tabRoot = el
|
||||||
@@ -184,30 +216,35 @@ export function TabNavItem(props: {
|
|||||||
data-dragging={props.dragging}
|
data-dragging={props.dragging}
|
||||||
data-pressed={props.pressed}
|
data-pressed={props.pressed}
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
if (event.button !== 1) return
|
if (event.button !== MIDDLE_MOUSE_BUTTON) return
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onAuxClick={(event) => {
|
||||||
|
if (event.button !== MIDDLE_MOUSE_BUTTON) return
|
||||||
closeTab(event)
|
closeTab(event)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={props.session()}>
|
<Show when={title() !== undefined}>
|
||||||
{(session) => {
|
<a
|
||||||
return (
|
data-slot="tab-link"
|
||||||
<a
|
data-titlebar-tab-link
|
||||||
data-slot="tab-link"
|
href={props.href}
|
||||||
data-titlebar-tab-link
|
draggable={false}
|
||||||
href={props.href}
|
onDragStart={(event) => {
|
||||||
draggable={false}
|
event.preventDefault()
|
||||||
onDragStart={(event) => {
|
event.stopPropagation()
|
||||||
event.preventDefault()
|
}}
|
||||||
event.stopPropagation()
|
onClick={(event) => {
|
||||||
}}
|
event.preventDefault()
|
||||||
onClick={(event) => {
|
if (editing()) return
|
||||||
event.preventDefault()
|
if (props.suppressNavigation?.()) return
|
||||||
if (editing()) return
|
props.onNavigate()
|
||||||
if (props.suppressNavigation?.()) return
|
}}
|
||||||
props.onNavigate()
|
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||||
}}
|
>
|
||||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
<Show when={props.session()}>
|
||||||
>
|
{(session) => (
|
||||||
<span data-slot="project-avatar-slot">
|
<span data-slot="project-avatar-slot">
|
||||||
<SessionTabAvatar
|
<SessionTabAvatar
|
||||||
project={project()}
|
project={project()}
|
||||||
@@ -216,45 +253,45 @@ export function TabNavItem(props: {
|
|||||||
activeServer={props.activeServer}
|
activeServer={props.activeServer}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span
|
)}
|
||||||
ref={(el) => {
|
</Show>
|
||||||
titleEl = el
|
<span
|
||||||
titleEl.textContent = session().title
|
ref={(el) => {
|
||||||
}}
|
titleEl = el
|
||||||
data-slot="tab-title"
|
titleEl.textContent = title() ?? ""
|
||||||
data-titlebar-tab-title
|
}}
|
||||||
class="min-w-0 flex-1 outline-none leading-4"
|
data-slot="tab-title"
|
||||||
classList={{
|
data-titlebar-tab-title
|
||||||
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
class="min-w-0 flex-1 outline-none leading-4"
|
||||||
"select-text": editing(),
|
classList={{
|
||||||
}}
|
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
||||||
contenteditable={editing() ? true : undefined}
|
"select-text": editing(),
|
||||||
onDblClick={openRename}
|
}}
|
||||||
onKeyDown={(event) => {
|
contenteditable={editing() ? true : undefined}
|
||||||
event.stopPropagation()
|
onDblClick={openRename}
|
||||||
if (event.key === "Enter") {
|
onKeyDown={(event) => {
|
||||||
event.preventDefault()
|
event.stopPropagation()
|
||||||
void closeRename(true)
|
if (event.key === "Enter") {
|
||||||
return
|
event.preventDefault()
|
||||||
}
|
void closeRename(true)
|
||||||
if (event.key !== "Escape") return
|
return
|
||||||
event.preventDefault()
|
}
|
||||||
titleEl.textContent = session().title
|
if (event.key !== "Escape") return
|
||||||
void closeRename(false)
|
event.preventDefault()
|
||||||
}}
|
titleEl.textContent = props.session()?.title ?? ""
|
||||||
onBlur={() => void closeRename(true)}
|
void closeRename(false)
|
||||||
onPointerDown={(event) => {
|
}}
|
||||||
if (!editing()) return
|
onBlur={() => void closeRename(true)}
|
||||||
event.stopPropagation()
|
onPointerDown={(event) => {
|
||||||
}}
|
if (!editing()) return
|
||||||
onClick={(event) => {
|
event.stopPropagation()
|
||||||
if (!editing()) return
|
}}
|
||||||
event.preventDefault()
|
onClick={(event) => {
|
||||||
}}
|
if (!editing()) return
|
||||||
/>
|
event.preventDefault()
|
||||||
</a>
|
}}
|
||||||
)
|
/>
|
||||||
}}
|
</a>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
||||||
@@ -272,6 +309,24 @@ export function TabNavItem(props: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabPreviewPopover
|
||||||
|
trigger={tab}
|
||||||
|
open={popoverOpen() && !previewBlocked()}
|
||||||
|
onOpenChange={(value) => {
|
||||||
|
if (value && previewBlocked()) return
|
||||||
|
setPopoverOpen(value)
|
||||||
|
}}
|
||||||
|
data={{
|
||||||
|
projectName: projectName(),
|
||||||
|
title: props.session()?.title,
|
||||||
|
path: previewPath(),
|
||||||
|
branch: branch(),
|
||||||
|
serverName: serverLabel(),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DraftTabItem(props: {
|
export function DraftTabItem(props: {
|
||||||
@@ -302,7 +357,12 @@ export function DraftTabItem(props: {
|
|||||||
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||||
classList={{ invisible: props.hidden }}
|
classList={{ invisible: props.hidden }}
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
if (event.button !== 1) return
|
if (event.button !== MIDDLE_MOUSE_BUTTON) return
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onAuxClick={(event) => {
|
||||||
|
if (event.button !== MIDDLE_MOUSE_BUTTON) return
|
||||||
closeTab(event)
|
closeTab(event)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
[data-component="session-tab-popover-trigger"] {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="session-tab-popover"] {
|
||||||
|
z-index: 50;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 256px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
|
||||||
|
background: var(--v2-background-bg-base);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: var(--v2-elevation-floating);
|
||||||
|
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
transform-origin: var(--kb-hovercard-content-transform-origin);
|
||||||
|
/* Entrance: fade in while scaling up from 0.96 to 1.0. */
|
||||||
|
animation: sessionTabPopoverIn 150ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
|
||||||
|
&:focus-visible,
|
||||||
|
&:focus-within {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Exit: subtle fade + scale down, using the same ease-out curve as entry. */
|
||||||
|
&[data-closed] {
|
||||||
|
animation: sessionTabPopoverOut 100ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="header"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="project"] {
|
||||||
|
font-weight: 440;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.04px;
|
||||||
|
color: var(--v2-text-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="title"] {
|
||||||
|
font-weight: 530;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: -0.04px;
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="row"] {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="icon"] {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: var(--v2-icon-icon-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="detail"] {
|
||||||
|
flex: 1 0 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 440;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.04px;
|
||||||
|
color: var(--v2-text-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="server"] {
|
||||||
|
font-weight: 440;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.04px;
|
||||||
|
color: var(--v2-text-text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sessionTabPopoverIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sessionTabPopoverOut {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
[data-component="session-tab-popover"],
|
||||||
|
[data-component="session-tab-popover"][data-closed] {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { HoverCard as Kobalte } from "@kobalte/core/hover-card"
|
||||||
|
import { Show, type JSXElement } from "solid-js"
|
||||||
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import "./titlebar-tab-popover.css"
|
||||||
|
|
||||||
|
// Initial hover delay before the preview appears, per design.
|
||||||
|
const OPEN_DELAY = 200
|
||||||
|
// Mouse-out delay: begin closing immediately (a brief exit animation plays).
|
||||||
|
const CLOSE_DELAY = 0
|
||||||
|
// After a preview closes, hovering a neighbouring tab within this window skips
|
||||||
|
// the open delay — mirrors the tooltip's skipDelayDuration so moving across
|
||||||
|
// tabs doesn't re-wait the full delay each time.
|
||||||
|
const SKIP_WINDOW = 300
|
||||||
|
let lastClosedAt = 0
|
||||||
|
|
||||||
|
export interface TabPreviewData {
|
||||||
|
projectName?: string
|
||||||
|
title?: string
|
||||||
|
path?: string
|
||||||
|
branch?: string
|
||||||
|
serverName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabPreviewPopover(props: {
|
||||||
|
trigger: JSXElement
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
data: TabPreviewData
|
||||||
|
}) {
|
||||||
|
let triggerEl: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
// Kobalte reads openDelay lazily when the pointer enters the trigger, so this
|
||||||
|
// resolves the skip window per-hover.
|
||||||
|
const resolveOpenDelay = () => (Date.now() - lastClosedAt < SKIP_WINDOW ? 0 : OPEN_DELAY)
|
||||||
|
const handleOpenChange = (open: boolean) => {
|
||||||
|
if (!open) lastClosedAt = Date.now()
|
||||||
|
props.onOpenChange(open)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Kobalte
|
||||||
|
open={props.open}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
openDelay={resolveOpenDelay()}
|
||||||
|
closeDelay={CLOSE_DELAY}
|
||||||
|
// The preview is non-interactive (pointer-events: none), so there is no
|
||||||
|
// safe area to traverse — leaving the tab hides it immediately.
|
||||||
|
ignoreSafeArea
|
||||||
|
placement="bottom-start"
|
||||||
|
gutter={6}
|
||||||
|
>
|
||||||
|
<Kobalte.Trigger ref={triggerEl} as="div" data-component="session-tab-popover-trigger" tabIndex={-1}>
|
||||||
|
{props.trigger}
|
||||||
|
</Kobalte.Trigger>
|
||||||
|
<Kobalte.Portal>
|
||||||
|
<Kobalte.Content
|
||||||
|
ref={(el) => {
|
||||||
|
// Portalled content lives outside the themed subtree, so mirror the
|
||||||
|
// active theme like the v2 tooltip does.
|
||||||
|
const theme = triggerEl?.closest("[data-theme]")?.getAttribute("data-theme")
|
||||||
|
if (theme) el.setAttribute("data-theme", theme)
|
||||||
|
}}
|
||||||
|
data-component="session-tab-popover"
|
||||||
|
>
|
||||||
|
<div data-slot="header">
|
||||||
|
<Show when={props.data.projectName}>
|
||||||
|
<span data-slot="project">{props.data.projectName}</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.data.title}>
|
||||||
|
<span data-slot="title">{props.data.title}</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={props.data.path}>
|
||||||
|
<div data-slot="row">
|
||||||
|
<span data-slot="icon">
|
||||||
|
<IconV2 name="folder" />
|
||||||
|
</span>
|
||||||
|
<span data-slot="detail">{props.data.path}</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={props.data.branch}>
|
||||||
|
<div data-slot="row">
|
||||||
|
<span data-slot="icon">
|
||||||
|
<IconV2 name="branch" />
|
||||||
|
</span>
|
||||||
|
<span data-slot="detail">{props.data.branch}</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={props.data.serverName}>
|
||||||
|
<div data-slot="server">{props.data.serverName}</div>
|
||||||
|
</Show>
|
||||||
|
</Kobalte.Content>
|
||||||
|
</Kobalte.Portal>
|
||||||
|
</Kobalte>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ function SessionTabSlot(props: {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
|
const language = useLanguage()
|
||||||
const sortable = useSortable({
|
const sortable = useSortable({
|
||||||
get id() {
|
get id() {
|
||||||
return props.id
|
return props.id
|
||||||
@@ -50,6 +51,7 @@ function SessionTabSlot(props: {
|
|||||||
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
|
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
|
||||||
)
|
)
|
||||||
const session = createMemo(() => cachedSession() ?? loadedSession())
|
const session = createMemo(() => cachedSession() ?? loadedSession())
|
||||||
|
const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session())
|
||||||
let prefetched = false
|
let prefetched = false
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -85,8 +87,9 @@ function SessionTabSlot(props: {
|
|||||||
ref={sortable.ref}
|
ref={sortable.ref}
|
||||||
data-titlebar-tab-slot
|
data-titlebar-tab-slot
|
||||||
data-tab-key={props.id}
|
data-tab-key={props.id}
|
||||||
|
data-active={props.active()}
|
||||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||||
classList={{ hidden: !session() }}
|
classList={{ hidden: !session() && !missingSession() }}
|
||||||
>
|
>
|
||||||
<TabNavItem
|
<TabNavItem
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
@@ -95,6 +98,7 @@ function SessionTabSlot(props: {
|
|||||||
href={tabHref(props.tab)}
|
href={tabHref(props.tab)}
|
||||||
server={props.tab.server}
|
server={props.tab.server}
|
||||||
session={session}
|
session={session}
|
||||||
|
fallbackTitle={missingSession() ? language.t("session.tab.unknown") : undefined}
|
||||||
onTitleChange={(title) => {
|
onTitleChange={(title) => {
|
||||||
const value = session()
|
const value = session()
|
||||||
const ctx = props.serverCtx()
|
const ctx = props.serverCtx()
|
||||||
@@ -140,6 +144,7 @@ function DraftTabSlot(props: {
|
|||||||
ref={sortable.ref}
|
ref={sortable.ref}
|
||||||
data-titlebar-tab-slot
|
data-titlebar-tab-slot
|
||||||
data-tab-key={props.id}
|
data-tab-key={props.id}
|
||||||
|
data-active={props.active()}
|
||||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||||
>
|
>
|
||||||
<DraftTabItem
|
<DraftTabItem
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
|
|
||||||
const path = () => `${location.pathname}${location.search}${location.hash}`
|
const path = () => `${location.pathname}${location.search}${location.hash}`
|
||||||
const creating = createMemo(() => {
|
const creating = createMemo(() => {
|
||||||
|
const route = layout.route()
|
||||||
|
if (route.type === "draft" || route.type === "dir-new-sesssion") return true
|
||||||
if (!params.dir) return false
|
if (!params.dir) return false
|
||||||
if (params.id) return false
|
if (params.id) return false
|
||||||
const parts = location.pathname.replace(/\/+$/, "").split("/")
|
const parts = location.pathname.replace(/\/+$/, "").split("/")
|
||||||
@@ -227,7 +229,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
"min-height": minHeight(),
|
"min-height": minHeight(),
|
||||||
"padding-left": mac() && !mobile() ? `${84 / zoom()}px` : 0,
|
// Keep native macOS traffic lights clear even when the desktop window is narrow.
|
||||||
|
"padding-left": mac() ? `${84 / zoom()}px` : 0,
|
||||||
width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
|
width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
|
||||||
"max-width": electronWindows()
|
"max-width": electronWindows()
|
||||||
? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))`
|
? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))`
|
||||||
@@ -466,7 +469,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
}}
|
}}
|
||||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||||
/>
|
/>
|
||||||
<Show when={!(creating() && params.dir)}>
|
<Show when={!creating()}>
|
||||||
<TooltipV2
|
<TooltipV2
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
value={
|
value={
|
||||||
@@ -569,7 +572,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
placement="bottom"
|
placement="bottom"
|
||||||
title={language.t("command.session.new")}
|
title={language.t("command.session.new")}
|
||||||
keybind={command.keybind("session.new")}
|
keybind={command.keybind("session.new")}
|
||||||
openDelay={2000}
|
openDelay={800}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -599,7 +602,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
>
|
>
|
||||||
<Show when={hasProjects() && nav()}>
|
<Show when={hasProjects() && nav()}>
|
||||||
<div class="flex items-center gap-0 transition-transform">
|
<div class="flex items-center gap-0 transition-transform">
|
||||||
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
|
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={800}>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
icon="chevron-left"
|
icon="chevron-left"
|
||||||
@@ -609,7 +612,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
aria-label={language.t("common.goBack")}
|
aria-label={language.t("common.goBack")}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
|
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={800}>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
icon="chevron-right"
|
icon="chevron-right"
|
||||||
@@ -710,13 +713,14 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ChannelIndicator() {
|
function ChannelIndicator() {
|
||||||
|
const channel = import.meta.env.VITE_OPENCODE_CHANNEL
|
||||||
|
if (channel !== "beta" && channel !== "dev") return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div
|
||||||
{["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && (
|
class={`text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono ${channel === "beta" ? "bg-[#0488D1]" : "bg-icon-interactive-base"}`}
|
||||||
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
|
>
|
||||||
{import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()}
|
{channel === "beta" ? "β" : channel.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ describe("bootstrapDirectory", () => {
|
|||||||
status: "loading",
|
status: "loading",
|
||||||
agent: [],
|
agent: [],
|
||||||
command: [],
|
command: [],
|
||||||
|
reference: [],
|
||||||
project: "",
|
project: "",
|
||||||
projectMeta: undefined,
|
projectMeta: undefined,
|
||||||
icon: undefined,
|
icon: undefined,
|
||||||
@@ -35,6 +36,7 @@ describe("bootstrapDirectory", () => {
|
|||||||
question: {},
|
question: {},
|
||||||
mcp_ready: true,
|
mcp_ready: true,
|
||||||
mcp: {},
|
mcp: {},
|
||||||
|
mcp_resource: {},
|
||||||
lsp_ready: true,
|
lsp_ready: true,
|
||||||
lsp: [],
|
lsp: [],
|
||||||
vcs: undefined,
|
vcs: undefined,
|
||||||
@@ -67,6 +69,7 @@ describe("bootstrapDirectory", () => {
|
|||||||
},
|
},
|
||||||
permission: { list: async () => ({ data: [] }) },
|
permission: { list: async () => ({ data: [] }) },
|
||||||
question: { list: async () => ({ data: [] }) },
|
question: { list: async () => ({ data: [] }) },
|
||||||
|
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
|
||||||
mcp: {
|
mcp: {
|
||||||
status: async () => {
|
status: async () => {
|
||||||
mcpReads.push("status")
|
mcpReads.push("status")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
Project,
|
Project,
|
||||||
ProviderAuthResponse,
|
ProviderAuthResponse,
|
||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
|
ReferenceInfo,
|
||||||
Session,
|
Session,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
@@ -18,7 +19,7 @@ import type { ServerSession } from "../server-session"
|
|||||||
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
|
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
||||||
import { loadMcpQuery } from "../server-sync"
|
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
|
||||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
@@ -195,6 +196,13 @@ export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk:
|
|||||||
queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)),
|
queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const loadReferencesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
||||||
|
queryOptions<ReferenceInfo[]>({
|
||||||
|
queryKey: [scope, directory, "references"] as const,
|
||||||
|
queryFn: () => retry(() => sdk.v2.reference.list().then((x) => x.data?.data ?? [])).catch(() => []),
|
||||||
|
placeholderData: [],
|
||||||
|
})
|
||||||
|
|
||||||
export async function bootstrapDirectory(input: {
|
export async function bootstrapDirectory(input: {
|
||||||
directory: string
|
directory: string
|
||||||
scope: ServerScope
|
scope: ServerScope
|
||||||
@@ -277,6 +285,7 @@ export async function bootstrapDirectory(input: {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))),
|
input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))),
|
||||||
|
() => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.sdk)),
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
input.sdk.permission.list().then((x) => {
|
input.sdk.permission.list().then((x) => {
|
||||||
@@ -339,6 +348,7 @@ export async function bootstrapDirectory(input: {
|
|||||||
),
|
),
|
||||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||||
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))),
|
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))),
|
||||||
|
input.mcp && (() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.sdk))),
|
||||||
() =>
|
() =>
|
||||||
input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => {
|
input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => {
|
||||||
const project = getFilename(input.directory)
|
const project = getFilename(input.directory)
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ const queryOptionsApi = {
|
|||||||
}),
|
}),
|
||||||
agents: (directory: string) => ({ queryKey: [directory, "agents"], queryFn: async () => [] }),
|
agents: (directory: string) => ({ queryKey: [directory, "agents"], queryFn: async () => [] }),
|
||||||
mcp: (directory: string) => ({ queryKey: [directory, "mcp"], queryFn: async () => ({}) }),
|
mcp: (directory: string) => ({ queryKey: [directory, "mcp"], queryFn: async () => ({}) }),
|
||||||
|
mcpResources: (directory: string) => ({ queryKey: [directory, "mcpResources"], queryFn: async () => ({}) }),
|
||||||
lsp: (directory: string) => ({ queryKey: [directory, "lsp"], queryFn: async () => [] }),
|
lsp: (directory: string) => ({ queryKey: [directory, "lsp"], queryFn: async () => [] }),
|
||||||
|
references: (directory: string) => ({ queryKey: [directory, "references"], queryFn: async () => [] }),
|
||||||
sessions: (directory: string) => ({ queryKey: [directory, "loadSessions"] as const }),
|
sessions: (directory: string) => ({ queryKey: [directory, "loadSessions"] as const }),
|
||||||
} as unknown as QueryOptionsApi
|
} as unknown as QueryOptionsApi
|
||||||
|
|
||||||
@@ -197,14 +199,18 @@ describe("createChildStoreManager", () => {
|
|||||||
try {
|
try {
|
||||||
if (!manager) throw new Error("manager required")
|
if (!manager) throw new Error("manager required")
|
||||||
const [store, setStore] = manager.child("/project", { bootstrap: false })
|
const [store, setStore] = manager.child("/project", { bootstrap: false })
|
||||||
expect(querySingles.length - offset).toBe(4)
|
expect(querySingles.length - offset).toBe(6)
|
||||||
const query = querySingles[offset + 1]
|
const query = querySingles[offset + 1]
|
||||||
|
const resourceQuery = querySingles[offset + 2]
|
||||||
if (!query) throw new Error("query required")
|
if (!query) throw new Error("query required")
|
||||||
|
if (!resourceQuery) throw new Error("resource query required")
|
||||||
expect(query().enabled).toBe(false)
|
expect(query().enabled).toBe(false)
|
||||||
|
expect(resourceQuery().enabled).toBe(false)
|
||||||
|
|
||||||
setStore("status", "complete")
|
setStore("status", "complete")
|
||||||
manager.child("/project", { bootstrap: false, mcp: true })
|
manager.child("/project", { bootstrap: false, mcp: true })
|
||||||
expect(query().enabled).toBe(true)
|
expect(query().enabled).toBe(true)
|
||||||
|
expect(resourceQuery().enabled).toBe(true)
|
||||||
expect(store.mcp).toEqual({ demo: { status: "disabled" } })
|
expect(store.mcp).toEqual({ demo: { status: "disabled" } })
|
||||||
expect(mcpLoads).toEqual(["/project"])
|
expect(mcpLoads).toEqual(["/project"])
|
||||||
|
|
||||||
|
|||||||
@@ -185,8 +185,10 @@ export function createChildStoreManager(input: {
|
|||||||
|
|
||||||
const pathQuery = useQuery(() => input.queryOptions.path(key))
|
const pathQuery = useQuery(() => input.queryOptions.path(key))
|
||||||
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
|
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
|
||||||
|
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
|
||||||
const lspQuery = useQuery(() => input.queryOptions.lsp(key))
|
const lspQuery = useQuery(() => input.queryOptions.lsp(key))
|
||||||
const providerQuery = useQuery(() => input.queryOptions.providers(key))
|
const providerQuery = useQuery(() => input.queryOptions.providers(key))
|
||||||
|
const referenceQuery = useQuery(() => input.queryOptions.references(key))
|
||||||
|
|
||||||
const child = createStore<State>({
|
const child = createStore<State>({
|
||||||
project: "",
|
project: "",
|
||||||
@@ -210,6 +212,9 @@ export function createChildStoreManager(input: {
|
|||||||
status: "loading" as const,
|
status: "loading" as const,
|
||||||
agent: [],
|
agent: [],
|
||||||
command: [],
|
command: [],
|
||||||
|
get reference() {
|
||||||
|
return referenceQuery.isLoading ? [] : (referenceQuery.data ?? [])
|
||||||
|
},
|
||||||
session: [],
|
session: [],
|
||||||
sessionTotal: 0,
|
sessionTotal: 0,
|
||||||
session_status: {},
|
session_status: {},
|
||||||
@@ -227,6 +232,9 @@ export function createChildStoreManager(input: {
|
|||||||
get mcp() {
|
get mcp() {
|
||||||
return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {})
|
return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {})
|
||||||
},
|
},
|
||||||
|
get mcp_resource() {
|
||||||
|
return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {})
|
||||||
|
},
|
||||||
get lsp_ready() {
|
get lsp_ready() {
|
||||||
return !lspQuery.isLoading
|
return !lspQuery.isLoading
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ export function applyDirectoryEvent(input: {
|
|||||||
push: (directory: string) => void
|
push: (directory: string) => void
|
||||||
directory: string
|
directory: string
|
||||||
loadLsp: () => void
|
loadLsp: () => void
|
||||||
|
loadReferences?: () => void
|
||||||
vcsCache?: VcsCache
|
vcsCache?: VcsCache
|
||||||
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
|
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
|
||||||
retainedLimit?: number
|
retainedLimit?: number
|
||||||
@@ -404,5 +405,9 @@ export function applyDirectoryEvent(input: {
|
|||||||
input.loadLsp()
|
input.loadLsp()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case "reference.updated": {
|
||||||
|
input.loadReferences?.()
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import type {
|
|||||||
Command,
|
Command,
|
||||||
Config,
|
Config,
|
||||||
LspStatus,
|
LspStatus,
|
||||||
|
McpResource,
|
||||||
McpStatus,
|
McpStatus,
|
||||||
Message,
|
Message,
|
||||||
Part,
|
Part,
|
||||||
Path,
|
Path,
|
||||||
PermissionRequest,
|
PermissionRequest,
|
||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
|
ReferenceInfo,
|
||||||
Session,
|
Session,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
SnapshotFileDiff,
|
SnapshotFileDiff,
|
||||||
@@ -34,6 +36,7 @@ export type State = {
|
|||||||
status: "loading" | "partial" | "complete"
|
status: "loading" | "partial" | "complete"
|
||||||
agent: Agent[]
|
agent: Agent[]
|
||||||
command: Command[]
|
command: Command[]
|
||||||
|
reference: ReferenceInfo[]
|
||||||
project: string
|
project: string
|
||||||
projectMeta: ProjectMeta | undefined
|
projectMeta: ProjectMeta | undefined
|
||||||
icon: string | undefined
|
icon: string | undefined
|
||||||
@@ -63,6 +66,9 @@ export type State = {
|
|||||||
mcp: {
|
mcp: {
|
||||||
[name: string]: McpStatus
|
[name: string]: McpStatus
|
||||||
}
|
}
|
||||||
|
mcp_resource: {
|
||||||
|
[key: string]: McpResource
|
||||||
|
}
|
||||||
lsp_ready: boolean
|
lsp_ready: boolean
|
||||||
lsp: LspStatus[]
|
lsp: LspStatus[]
|
||||||
vcs: VcsInfo | undefined
|
vcs: VcsInfo | undefined
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const DEFAULT_SIDEBAR_WIDTH = 344
|
|||||||
const DEFAULT_FILE_TREE_WIDTH = 200
|
const DEFAULT_FILE_TREE_WIDTH = 200
|
||||||
const DEFAULT_SESSION_WIDTH = 600
|
const DEFAULT_SESSION_WIDTH = 600
|
||||||
const DEFAULT_TERMINAL_HEIGHT = 280
|
const DEFAULT_TERMINAL_HEIGHT = 280
|
||||||
|
const DEFAULT_REVIEW_PANEL_OPENED = false
|
||||||
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
|
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
|
||||||
|
|
||||||
export function getAvatarColors(key?: string) {
|
export function getAvatarColors(key?: string) {
|
||||||
@@ -77,6 +78,7 @@ export type LocalProject = Partial<Project> & { worktree: string; expanded: bool
|
|||||||
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
|
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
|
||||||
|
|
||||||
export type ReviewDiffStyle = "unified" | "split"
|
export type ReviewDiffStyle = "unified" | "split"
|
||||||
|
export type ReviewPanelSource = "context-button" | "other"
|
||||||
|
|
||||||
export type LayoutRoute =
|
export type LayoutRoute =
|
||||||
| { type: "home" }
|
| { type: "home" }
|
||||||
@@ -210,7 +212,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
if (!isRecord(review)) return review
|
if (!isRecord(review)) return review
|
||||||
if (typeof review.panelOpened === "boolean") return review
|
if (typeof review.panelOpened === "boolean") return review
|
||||||
|
|
||||||
const opened = isRecord(fileTree) && typeof fileTree.opened === "boolean" ? fileTree.opened : true
|
const opened =
|
||||||
|
isRecord(fileTree) && typeof fileTree.opened === "boolean" ? fileTree.opened : DEFAULT_REVIEW_PANEL_OPENED
|
||||||
return {
|
return {
|
||||||
...review,
|
...review,
|
||||||
panelOpened: opened,
|
panelOpened: opened,
|
||||||
@@ -279,7 +282,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
diffStyle: "split" as ReviewDiffStyle,
|
diffStyle: "split" as ReviewDiffStyle,
|
||||||
panelOpened: true,
|
panelOpened: DEFAULT_REVIEW_PANEL_OPENED,
|
||||||
},
|
},
|
||||||
fileTree: {
|
fileTree: {
|
||||||
opened: false,
|
opened: false,
|
||||||
@@ -302,6 +305,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
const [ephemeral, setEphemeral] = createStore({
|
||||||
|
reviewPanelSource: "other" as ReviewPanelSource,
|
||||||
|
})
|
||||||
|
|
||||||
const MAX_SESSION_KEYS = 50
|
const MAX_SESSION_KEYS = 50
|
||||||
const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000
|
const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000
|
||||||
@@ -662,7 +668,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
|
diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
|
||||||
setDiffStyle(diffStyle: ReviewDiffStyle) {
|
setDiffStyle(diffStyle: ReviewDiffStyle) {
|
||||||
if (!store.review) {
|
if (!store.review) {
|
||||||
setStore("review", { diffStyle, panelOpened: true })
|
setStore("review", { diffStyle, panelOpened: DEFAULT_REVIEW_PANEL_OPENED })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setStore("review", "diffStyle", diffStyle)
|
setStore("review", "diffStyle", diffStyle)
|
||||||
@@ -777,7 +783,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
const key = createSessionKeyReader(sessionKey, ensureKey)
|
const key = createSessionKeyReader(sessionKey, ensureKey)
|
||||||
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
|
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
|
||||||
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
|
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
|
||||||
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true)
|
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED)
|
||||||
|
const reviewPanelSource = createMemo(() => (reviewPanelOpened() ? ephemeral.reviewPanelSource : "other"))
|
||||||
|
|
||||||
function setTerminalOpened(next: boolean) {
|
function setTerminalOpened(next: boolean) {
|
||||||
const current = store.terminal
|
const current = store.terminal
|
||||||
@@ -791,16 +798,26 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
setStore("terminal", "opened", next)
|
setStore("terminal", "opened", next)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setReviewPanelOpened(next: boolean) {
|
function setReviewPanelOpened(next: boolean, source: ReviewPanelSource) {
|
||||||
|
const nextSource = next ? source : "other"
|
||||||
const current = store.review
|
const current = store.review
|
||||||
if (!current) {
|
if (!current) {
|
||||||
setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next })
|
batch(() => {
|
||||||
|
setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next })
|
||||||
|
setEphemeral("reviewPanelSource", nextSource)
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const value = current.panelOpened ?? true
|
const value = current.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED
|
||||||
if (value === next) return
|
if (value === next) {
|
||||||
setStore("review", "panelOpened", next)
|
if (ephemeral.reviewPanelSource !== nextSource) setEphemeral("reviewPanelSource", nextSource)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
batch(() => {
|
||||||
|
setStore("review", "panelOpened", next)
|
||||||
|
setEphemeral("reviewPanelSource", nextSource)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -836,14 +853,15 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
},
|
},
|
||||||
reviewPanel: {
|
reviewPanel: {
|
||||||
opened: reviewPanelOpened,
|
opened: reviewPanelOpened,
|
||||||
open() {
|
source: reviewPanelSource,
|
||||||
setReviewPanelOpened(true)
|
open(source: ReviewPanelSource = "other") {
|
||||||
|
setReviewPanelOpened(true, source)
|
||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
setReviewPanelOpened(false)
|
setReviewPanelOpened(false, "other")
|
||||||
},
|
},
|
||||||
toggle() {
|
toggle() {
|
||||||
setReviewPanelOpened(!reviewPanelOpened())
|
setReviewPanelOpened(!reviewPanelOpened(), "other")
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { batch, createEffect, createMemo } from "solid-js"
|
import { batch, createEffect, createMemo, startTransition } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useModels } from "@/context/models"
|
import { useModels } from "@/context/models"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
@@ -80,6 +80,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
current?: string
|
current?: string
|
||||||
draft?: State
|
draft?: State
|
||||||
|
promoting?: State
|
||||||
last?: {
|
last?: {
|
||||||
type: "agent" | "model" | "variant"
|
type: "agent" | "model" | "variant"
|
||||||
agent?: string
|
agent?: string
|
||||||
@@ -123,7 +124,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
|
|
||||||
const scope = createMemo<State | undefined>(() => {
|
const scope = createMemo<State | undefined>(() => {
|
||||||
const session = id()
|
const session = id()
|
||||||
if (!session) return store.draft
|
if (!session) return store.draft ?? store.promoting
|
||||||
return saved.session[session] ?? handoff.get(handoffKey(serverSDK().scope, sdk().directory, session))
|
return saved.session[session] ?? handoff.get(handoffKey(serverSDK().scope, sdk().directory, session))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -136,11 +137,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
if (!next) return
|
if (!next) return
|
||||||
if (saved.session[session] !== undefined) {
|
if (saved.session[session] !== undefined) {
|
||||||
handoff.delete(key)
|
handoff.delete(key)
|
||||||
|
setStore("promoting", undefined)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaved("session", session, clone(next))
|
setSaved("session", session, clone(next))
|
||||||
handoff.delete(key)
|
handoff.delete(key)
|
||||||
|
setStore("promoting", undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
const configuredModel = () => {
|
const configuredModel = () => {
|
||||||
@@ -294,19 +297,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
model.set({ providerID: entry.provider.id, modelID: entry.id })
|
model.set({ providerID: entry.provider.id, modelID: entry.id })
|
||||||
},
|
},
|
||||||
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
|
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
|
||||||
batch(() => {
|
startTransition(() =>
|
||||||
setStore("last", {
|
batch(() => {
|
||||||
type: "model",
|
setStore("last", {
|
||||||
agent: agent.current()?.name,
|
type: "model",
|
||||||
model: item ?? null,
|
agent: agent.current()?.name,
|
||||||
variant: selected(),
|
model: item ?? null,
|
||||||
})
|
variant: selected(),
|
||||||
write({ model: item })
|
})
|
||||||
if (!item) return
|
write({ model: item })
|
||||||
models.setVisibility(item, true)
|
if (!item) return
|
||||||
if (!options?.recent) return
|
models.setVisibility(item, true)
|
||||||
models.recent.push(item)
|
if (!options?.recent) return
|
||||||
})
|
models.recent.push(item)
|
||||||
|
}),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
visible(item: ModelKey) {
|
visible(item: ModelKey) {
|
||||||
return models.visible(item)
|
return models.visible(item)
|
||||||
@@ -335,19 +340,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
return Object.keys(item.variants)
|
return Object.keys(item.variants)
|
||||||
},
|
},
|
||||||
set(value: string | undefined) {
|
set(value: string | undefined) {
|
||||||
batch(() => {
|
startTransition(() =>
|
||||||
const model = current()
|
batch(() => {
|
||||||
setStore("last", {
|
const model = current()
|
||||||
type: "variant",
|
setStore("last", {
|
||||||
agent: agent.current()?.name,
|
type: "variant",
|
||||||
model: model ? { providerID: model.provider.id, modelID: model.id } : null,
|
agent: agent.current()?.name,
|
||||||
variant: value ?? null,
|
model: model ? { providerID: model.provider.id, modelID: model.id } : null,
|
||||||
})
|
variant: value ?? null,
|
||||||
write({ variant: value ?? null })
|
})
|
||||||
if (model) {
|
write({ variant: value ?? null })
|
||||||
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value ?? undefined)
|
if (model) {
|
||||||
}
|
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value ?? undefined)
|
||||||
})
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
cycle() {
|
cycle() {
|
||||||
const items = this.list()
|
const items = this.list()
|
||||||
@@ -369,19 +376,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
agent,
|
agent,
|
||||||
session: {
|
session: {
|
||||||
reset() {
|
reset() {
|
||||||
setStore("draft", undefined)
|
setStore({ draft: undefined, promoting: undefined })
|
||||||
},
|
},
|
||||||
promote(dir: string, session: string) {
|
promote(dir: string, session: string) {
|
||||||
const next = clone(snapshot())
|
const next = clone(snapshot())
|
||||||
if (!next) return
|
if (!next) return
|
||||||
|
const key = handoffKey(serverSDK().scope, dir, session)
|
||||||
|
handoff.set(key, next)
|
||||||
|
|
||||||
if (dir === sdk().directory) {
|
if (dir === sdk().directory) {
|
||||||
setSaved("session", session, next)
|
setSaved("session", session, next)
|
||||||
setStore("draft", undefined)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handoff.set(handoffKey(serverSDK().scope, dir, session), next)
|
setStore("promoting", next)
|
||||||
setStore("draft", undefined)
|
setStore("draft", undefined)
|
||||||
},
|
},
|
||||||
restore(msg: { sessionID: string; agent: string; model: ModelKey }) {
|
restore(msg: { sessionID: string; agent: string; model: ModelKey }) {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
|
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams, useSearchParams } from "@solidjs/router"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { useServerSDK } from "./server-sdk"
|
import type { ServerSDK } from "./server-sdk"
|
||||||
import { useServerSync } from "./server-sync"
|
import type { ServerSync } from "./server-sync"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
@@ -12,6 +12,11 @@ import { decode64 } from "@/utils/base64"
|
|||||||
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { playSoundById } from "@/utils/sound"
|
import { playSoundById } from "@/utils/sound"
|
||||||
|
import { useGlobal } from "./global"
|
||||||
|
import { ServerConnection, useServer } from "./server"
|
||||||
|
import { type DraftTab, useTabs } from "./tabs"
|
||||||
|
import { requireServerKey } from "@/utils/session-route"
|
||||||
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
type NotificationBase = {
|
type NotificationBase = {
|
||||||
directory?: string
|
directory?: string
|
||||||
@@ -107,267 +112,360 @@ function buildNotificationIndex(list: Notification[]) {
|
|||||||
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
|
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
|
||||||
name: "Notification",
|
name: "Notification",
|
||||||
gate: false,
|
gate: false,
|
||||||
init: (props: { directory?: Accessor<string | undefined>; sessionID?: Accessor<string | undefined> }) => {
|
init: () => {
|
||||||
const params = useParams()
|
const params = useParams<{ serverKey?: string; dir?: string; id?: string }>()
|
||||||
const serverSDK = useServerSDK()
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
const serverSync = useServerSync()
|
const global = useGlobal()
|
||||||
|
const server = useServer()
|
||||||
|
const tabs = useTabs()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const owner = getOwner()
|
||||||
|
const states = new Map<ServerScope, { dispose: () => void; state: NotificationState }>()
|
||||||
|
|
||||||
const empty: Notification[] = []
|
const activeServer = createMemo(() => {
|
||||||
|
if (params.serverKey) return requireServerKey(params.serverKey)
|
||||||
const currentDirectory = createMemo(() => {
|
if (search.draftId) {
|
||||||
return props.directory?.() ?? decode64(params.dir)
|
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
|
||||||
|
if (draft) return draft.server
|
||||||
|
}
|
||||||
|
return server.key
|
||||||
})
|
})
|
||||||
|
const activeDirectory = createMemo(() => decode64(params.dir))
|
||||||
|
const activeSession = createMemo(() => params.id)
|
||||||
|
|
||||||
const currentSession = createMemo(() => props.sessionID?.() ?? params.id)
|
const ensure = (key: ServerConnection.Key) => {
|
||||||
|
const conn = global.servers.list().find((item) => ServerConnection.key(item) === key)
|
||||||
const [store, setStore, _, ready] = persisted(
|
if (!conn) throw new Error(`Notification server not found: ${key}`)
|
||||||
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
|
const ctx = global.ensureServerCtx(conn)
|
||||||
createStore({
|
const existing = states.get(ctx.sdk.scope)
|
||||||
list: [] as Notification[],
|
if (existing) return existing.state
|
||||||
}),
|
const root = createRoot(
|
||||||
)
|
(dispose) => ({
|
||||||
const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
|
dispose,
|
||||||
|
state: createServerNotificationState({
|
||||||
const meta = { pruned: false, disposed: false }
|
sdk: ctx.sdk,
|
||||||
|
sync: ctx.sync,
|
||||||
const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => {
|
active: () => server.scope(activeServer()) === ctx.sdk.scope,
|
||||||
setIndex(scope, "unseen", key, unseen)
|
directory: activeDirectory,
|
||||||
setIndex(scope, "unseenCount", key, unseen.length)
|
sessionID: activeSession,
|
||||||
setIndex(
|
platform,
|
||||||
scope,
|
settings,
|
||||||
"unseenHasError",
|
language,
|
||||||
key,
|
}),
|
||||||
unseen.some((notification) => notification.type === "error"),
|
}),
|
||||||
|
owner ?? undefined,
|
||||||
)
|
)
|
||||||
}
|
states.set(ctx.sdk.scope, root)
|
||||||
|
return root.state
|
||||||
const appendToIndex = (notification: Notification) => {
|
|
||||||
if (notification.session) {
|
|
||||||
setIndex("session", "all", notification.session, (all = []) => [...all, notification])
|
|
||||||
if (!notification.viewed) {
|
|
||||||
setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification])
|
|
||||||
setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1)
|
|
||||||
if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notification.directory) {
|
|
||||||
setIndex("project", "all", notification.directory, (all = []) => [...all, notification])
|
|
||||||
if (!notification.viewed) {
|
|
||||||
setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification])
|
|
||||||
setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1)
|
|
||||||
if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeFromIndex = (notification: Notification) => {
|
|
||||||
if (notification.session) {
|
|
||||||
setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification))
|
|
||||||
if (!notification.viewed) {
|
|
||||||
const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification)
|
|
||||||
updateUnseen("session", notification.session, unseen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notification.directory) {
|
|
||||||
setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification))
|
|
||||||
if (!notification.viewed) {
|
|
||||||
const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification)
|
|
||||||
updateUnseen("project", notification.directory, unseen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!ready()) return
|
global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn)))
|
||||||
if (meta.pruned) return
|
})
|
||||||
meta.pruned = true
|
|
||||||
const list = pruneNotifications(store.list)
|
createEffect(() => {
|
||||||
batch(() => {
|
const scopes = new Set(global.servers.list().map((conn) => server.scope(ServerConnection.key(conn))))
|
||||||
setStore("list", list)
|
states.forEach((value, scope) => {
|
||||||
setIndex(reconcile(buildNotificationIndex(list), { merge: false }))
|
if (scopes.has(scope)) return
|
||||||
|
value.dispose()
|
||||||
|
states.delete(scope)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const append = (notification: Notification) => {
|
onCleanup(() => states.forEach((value) => value.dispose()))
|
||||||
const list = pruneNotifications([...store.list, notification])
|
|
||||||
const keep = new Set(list)
|
|
||||||
const removed = store.list.filter((n) => !keep.has(n))
|
|
||||||
|
|
||||||
batch(() => {
|
const selected = () => ensure(activeServer())
|
||||||
if (keep.has(notification)) appendToIndex(notification)
|
|
||||||
removed.forEach((n) => removeFromIndex(n))
|
|
||||||
setStore("list", list)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const lookup = async (directory: string, sessionID?: string) => {
|
|
||||||
if (!sessionID) return undefined
|
|
||||||
const sync = serverSync().ensureDirSyncContext(directory)
|
|
||||||
const session = sync.session.get(sessionID)
|
|
||||||
if (session) return session
|
|
||||||
return sync.session
|
|
||||||
.sync(sessionID)
|
|
||||||
.then(() => sync.session.get(sessionID))
|
|
||||||
.catch(() => undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
const viewedInCurrentSession = (directory: string, sessionID?: string) => {
|
|
||||||
const activeDirectory = currentDirectory()
|
|
||||||
const activeSession = currentSession()
|
|
||||||
if (!activeDirectory) return false
|
|
||||||
if (!activeSession) return false
|
|
||||||
if (!sessionID) return false
|
|
||||||
if (directory !== activeDirectory) return false
|
|
||||||
return sessionID === activeSession
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
|
|
||||||
const sessionID = event.properties.sessionID
|
|
||||||
void lookup(directory, sessionID).then((session) => {
|
|
||||||
if (meta.disposed) return
|
|
||||||
if (!session) return
|
|
||||||
if (session.parentID) return
|
|
||||||
|
|
||||||
if (settings.sounds.agentEnabled()) {
|
|
||||||
void playSoundById(settings.sounds.agent())
|
|
||||||
}
|
|
||||||
|
|
||||||
append({
|
|
||||||
directory,
|
|
||||||
time,
|
|
||||||
viewed: viewedInCurrentSession(directory, sessionID),
|
|
||||||
type: "turn-complete",
|
|
||||||
session: sessionID,
|
|
||||||
})
|
|
||||||
|
|
||||||
const href = `/${base64Encode(directory)}/session/${sessionID}`
|
|
||||||
if (settings.notifications.agent()) {
|
|
||||||
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSessionError = (
|
|
||||||
directory: string,
|
|
||||||
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
|
|
||||||
time: number,
|
|
||||||
) => {
|
|
||||||
const sessionID = event.properties.sessionID
|
|
||||||
void lookup(directory, sessionID).then((session) => {
|
|
||||||
if (meta.disposed) return
|
|
||||||
if (session?.parentID) return
|
|
||||||
|
|
||||||
if (settings.sounds.errorsEnabled()) {
|
|
||||||
void playSoundById(settings.sounds.errors())
|
|
||||||
}
|
|
||||||
|
|
||||||
const error = "error" in event.properties ? event.properties.error : undefined
|
|
||||||
append({
|
|
||||||
directory,
|
|
||||||
time,
|
|
||||||
viewed: viewedInCurrentSession(directory, sessionID),
|
|
||||||
type: "error",
|
|
||||||
session: sessionID ?? "global",
|
|
||||||
error,
|
|
||||||
})
|
|
||||||
const description =
|
|
||||||
session?.title ??
|
|
||||||
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
|
|
||||||
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
|
|
||||||
if (settings.notifications.errors()) {
|
|
||||||
void platform.notify(language.t("notification.session.error.title"), description, href)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const unsub = serverSDK().event.listen((e) => {
|
|
||||||
const event = e.details
|
|
||||||
if (event.type !== "session.idle" && event.type !== "session.error") return
|
|
||||||
|
|
||||||
const directory = e.name
|
|
||||||
const time = Date.now()
|
|
||||||
if (event.type === "session.idle") {
|
|
||||||
handleSessionIdle(directory, event, time)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
handleSessionError(directory, event, time)
|
|
||||||
})
|
|
||||||
onCleanup(() => {
|
|
||||||
meta.disposed = true
|
|
||||||
unsub()
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready: () => selected().ready(),
|
||||||
|
ensureServerState: ensure,
|
||||||
session: {
|
session: {
|
||||||
all(session: string) {
|
all: (session: string) => selected().session.all(session),
|
||||||
return index.session.all[session] ?? empty
|
unseen: (session: string) => selected().session.unseen(session),
|
||||||
},
|
unseenCount: (session: string) => selected().session.unseenCount(session),
|
||||||
unseen(session: string) {
|
unseenHasError: (session: string) => selected().session.unseenHasError(session),
|
||||||
return index.session.unseen[session] ?? empty
|
markViewed: (session: string) => selected().session.markViewed(session),
|
||||||
},
|
|
||||||
unseenCount(session: string) {
|
|
||||||
return index.session.unseenCount[session] ?? 0
|
|
||||||
},
|
|
||||||
unseenHasError(session: string) {
|
|
||||||
return index.session.unseenHasError[session] ?? false
|
|
||||||
},
|
|
||||||
markViewed(session: string) {
|
|
||||||
const unseen = index.session.unseen[session] ?? empty
|
|
||||||
if (!unseen.length) return
|
|
||||||
|
|
||||||
const projects = [
|
|
||||||
...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))),
|
|
||||||
]
|
|
||||||
batch(() => {
|
|
||||||
setStore("list", (n) => n.session === session && !n.viewed, "viewed", true)
|
|
||||||
updateUnseen("session", session, [])
|
|
||||||
projects.forEach((directory) => {
|
|
||||||
const next = (index.project.unseen[directory] ?? empty).filter(
|
|
||||||
(notification) => notification.session !== session,
|
|
||||||
)
|
|
||||||
updateUnseen("project", directory, next)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
project: {
|
project: {
|
||||||
all(directory: string) {
|
all: (directory: string) => selected().project.all(directory),
|
||||||
return index.project.all[directory] ?? empty
|
unseen: (directory: string) => selected().project.unseen(directory),
|
||||||
},
|
unseenCount: (directory: string) => selected().project.unseenCount(directory),
|
||||||
unseen(directory: string) {
|
unseenHasError: (directory: string) => selected().project.unseenHasError(directory),
|
||||||
return index.project.unseen[directory] ?? empty
|
markViewed: (directory: string) => selected().project.markViewed(directory),
|
||||||
},
|
|
||||||
unseenCount(directory: string) {
|
|
||||||
return index.project.unseenCount[directory] ?? 0
|
|
||||||
},
|
|
||||||
unseenHasError(directory: string) {
|
|
||||||
return index.project.unseenHasError[directory] ?? false
|
|
||||||
},
|
|
||||||
markViewed(directory: string) {
|
|
||||||
const unseen = index.project.unseen[directory] ?? empty
|
|
||||||
if (!unseen.length) return
|
|
||||||
|
|
||||||
const sessions = [
|
|
||||||
...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))),
|
|
||||||
]
|
|
||||||
batch(() => {
|
|
||||||
setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true)
|
|
||||||
updateUnseen("project", directory, [])
|
|
||||||
sessions.forEach((session) => {
|
|
||||||
const next = (index.session.unseen[session] ?? empty).filter(
|
|
||||||
(notification) => notification.directory !== directory,
|
|
||||||
)
|
|
||||||
updateUnseen("session", session, next)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
type NotificationState = ReturnType<typeof createServerNotificationState>
|
||||||
|
|
||||||
|
function createServerNotificationState(input: {
|
||||||
|
sdk: ServerSDK
|
||||||
|
sync: ServerSync
|
||||||
|
active: Accessor<boolean>
|
||||||
|
directory: Accessor<string | undefined>
|
||||||
|
sessionID: Accessor<string | undefined>
|
||||||
|
platform: ReturnType<typeof usePlatform>
|
||||||
|
settings: ReturnType<typeof useSettings>
|
||||||
|
language: ReturnType<typeof useLanguage>
|
||||||
|
}) {
|
||||||
|
const serverSDK = () => input.sdk
|
||||||
|
const serverSync = () => input.sync
|
||||||
|
const platform = input.platform
|
||||||
|
const settings = input.settings
|
||||||
|
const language = input.language
|
||||||
|
|
||||||
|
const empty: Notification[] = []
|
||||||
|
|
||||||
|
const currentDirectory = input.directory
|
||||||
|
const currentSession = input.sessionID
|
||||||
|
|
||||||
|
const [store, setStore, _, ready] = persisted(
|
||||||
|
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
|
||||||
|
createStore({
|
||||||
|
list: [] as Notification[],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
|
||||||
|
|
||||||
|
const meta = { pruned: false, disposed: false }
|
||||||
|
|
||||||
|
const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => {
|
||||||
|
setIndex(scope, "unseen", key, unseen)
|
||||||
|
setIndex(scope, "unseenCount", key, unseen.length)
|
||||||
|
setIndex(
|
||||||
|
scope,
|
||||||
|
"unseenHasError",
|
||||||
|
key,
|
||||||
|
unseen.some((notification) => notification.type === "error"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const appendToIndex = (notification: Notification) => {
|
||||||
|
if (notification.session) {
|
||||||
|
setIndex("session", "all", notification.session, (all = []) => [...all, notification])
|
||||||
|
if (!notification.viewed) {
|
||||||
|
setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification])
|
||||||
|
setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1)
|
||||||
|
if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification.directory) {
|
||||||
|
setIndex("project", "all", notification.directory, (all = []) => [...all, notification])
|
||||||
|
if (!notification.viewed) {
|
||||||
|
setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification])
|
||||||
|
setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1)
|
||||||
|
if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeFromIndex = (notification: Notification) => {
|
||||||
|
if (notification.session) {
|
||||||
|
setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification))
|
||||||
|
if (!notification.viewed) {
|
||||||
|
const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification)
|
||||||
|
updateUnseen("session", notification.session, unseen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification.directory) {
|
||||||
|
setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification))
|
||||||
|
if (!notification.viewed) {
|
||||||
|
const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification)
|
||||||
|
updateUnseen("project", notification.directory, unseen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready()) return
|
||||||
|
if (meta.pruned) return
|
||||||
|
meta.pruned = true
|
||||||
|
const list = pruneNotifications(store.list)
|
||||||
|
batch(() => {
|
||||||
|
setStore("list", list)
|
||||||
|
setIndex(reconcile(buildNotificationIndex(list), { merge: false }))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const append = (notification: Notification) => {
|
||||||
|
const list = pruneNotifications([...store.list, notification])
|
||||||
|
const keep = new Set(list)
|
||||||
|
const removed = store.list.filter((n) => !keep.has(n))
|
||||||
|
|
||||||
|
batch(() => {
|
||||||
|
if (keep.has(notification)) appendToIndex(notification)
|
||||||
|
removed.forEach((n) => removeFromIndex(n))
|
||||||
|
setStore("list", list)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const lookup = async (directory: string, sessionID?: string) => {
|
||||||
|
if (!sessionID) return undefined
|
||||||
|
const sync = serverSync().ensureDirSyncContext(directory)
|
||||||
|
const session = sync.session.get(sessionID)
|
||||||
|
if (session) return session
|
||||||
|
return sync.session
|
||||||
|
.sync(sessionID)
|
||||||
|
.then(() => sync.session.get(sessionID))
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewedInCurrentSession = (directory: string, sessionID?: string) => {
|
||||||
|
if (!input.active()) return false
|
||||||
|
const activeDirectory = currentDirectory()
|
||||||
|
const activeSession = currentSession()
|
||||||
|
if (!activeSession) return false
|
||||||
|
if (!sessionID) return false
|
||||||
|
if (activeDirectory && directory !== activeDirectory) return false
|
||||||
|
return sessionID === activeSession
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
|
||||||
|
const sessionID = event.properties.sessionID
|
||||||
|
void lookup(directory, sessionID).then((session) => {
|
||||||
|
if (meta.disposed) return
|
||||||
|
if (!session) return
|
||||||
|
if (session.parentID) return
|
||||||
|
|
||||||
|
if (settings.sounds.agentEnabled()) {
|
||||||
|
void playSoundById(settings.sounds.agent())
|
||||||
|
}
|
||||||
|
|
||||||
|
append({
|
||||||
|
directory,
|
||||||
|
time,
|
||||||
|
viewed: viewedInCurrentSession(directory, sessionID),
|
||||||
|
type: "turn-complete",
|
||||||
|
session: sessionID,
|
||||||
|
})
|
||||||
|
|
||||||
|
const href = `/${base64Encode(directory)}/session/${sessionID}`
|
||||||
|
if (settings.notifications.agent()) {
|
||||||
|
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSessionError = (
|
||||||
|
directory: string,
|
||||||
|
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
|
||||||
|
time: number,
|
||||||
|
) => {
|
||||||
|
const sessionID = event.properties.sessionID
|
||||||
|
void lookup(directory, sessionID).then((session) => {
|
||||||
|
if (meta.disposed) return
|
||||||
|
if (session?.parentID) return
|
||||||
|
|
||||||
|
if (settings.sounds.errorsEnabled()) {
|
||||||
|
void playSoundById(settings.sounds.errors())
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = "error" in event.properties ? event.properties.error : undefined
|
||||||
|
append({
|
||||||
|
directory,
|
||||||
|
time,
|
||||||
|
viewed: viewedInCurrentSession(directory, sessionID),
|
||||||
|
type: "error",
|
||||||
|
session: sessionID ?? "global",
|
||||||
|
error,
|
||||||
|
})
|
||||||
|
const description =
|
||||||
|
session?.title ??
|
||||||
|
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
|
||||||
|
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
|
||||||
|
if (settings.notifications.errors()) {
|
||||||
|
void platform.notify(language.t("notification.session.error.title"), description, href)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsub = serverSDK().event.listen((e) => {
|
||||||
|
const event = e.details
|
||||||
|
if (event.type !== "session.idle" && event.type !== "session.error") return
|
||||||
|
|
||||||
|
const directory = e.name
|
||||||
|
const time = Date.now()
|
||||||
|
if (event.type === "session.idle") {
|
||||||
|
handleSessionIdle(directory, event, time)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleSessionError(directory, event, time)
|
||||||
|
})
|
||||||
|
onCleanup(() => {
|
||||||
|
meta.disposed = true
|
||||||
|
unsub()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready,
|
||||||
|
session: {
|
||||||
|
all(session: string) {
|
||||||
|
return index.session.all[session] ?? empty
|
||||||
|
},
|
||||||
|
unseen(session: string) {
|
||||||
|
return index.session.unseen[session] ?? empty
|
||||||
|
},
|
||||||
|
unseenCount(session: string) {
|
||||||
|
return index.session.unseenCount[session] ?? 0
|
||||||
|
},
|
||||||
|
unseenHasError(session: string) {
|
||||||
|
return index.session.unseenHasError[session] ?? false
|
||||||
|
},
|
||||||
|
markViewed(session: string) {
|
||||||
|
const unseen = index.session.unseen[session] ?? empty
|
||||||
|
if (!unseen.length) return
|
||||||
|
|
||||||
|
const projects = [
|
||||||
|
...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))),
|
||||||
|
]
|
||||||
|
batch(() => {
|
||||||
|
setStore("list", (n) => n.session === session && !n.viewed, "viewed", true)
|
||||||
|
updateUnseen("session", session, [])
|
||||||
|
projects.forEach((directory) => {
|
||||||
|
const next = (index.project.unseen[directory] ?? empty).filter(
|
||||||
|
(notification) => notification.session !== session,
|
||||||
|
)
|
||||||
|
updateUnseen("project", directory, next)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
all(directory: string) {
|
||||||
|
return index.project.all[directory] ?? empty
|
||||||
|
},
|
||||||
|
unseen(directory: string) {
|
||||||
|
return index.project.unseen[directory] ?? empty
|
||||||
|
},
|
||||||
|
unseenCount(directory: string) {
|
||||||
|
return index.project.unseenCount[directory] ?? 0
|
||||||
|
},
|
||||||
|
unseenHasError(directory: string) {
|
||||||
|
return index.project.unseenHasError[directory] ?? false
|
||||||
|
},
|
||||||
|
markViewed(directory: string) {
|
||||||
|
const unseen = index.project.unseen[directory] ?? empty
|
||||||
|
if (!unseen.length) return
|
||||||
|
|
||||||
|
const sessions = [
|
||||||
|
...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))),
|
||||||
|
]
|
||||||
|
batch(() => {
|
||||||
|
setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true)
|
||||||
|
updateUnseen("project", directory, [])
|
||||||
|
sessions.forEach((session) => {
|
||||||
|
const next = (index.session.unseen[session] ?? empty).filter(
|
||||||
|
(notification) => notification.directory !== directory,
|
||||||
|
)
|
||||||
|
updateUnseen("session", session, next)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ type PlatformBase = {
|
|||||||
/** Storage mechanism, defaults to localStorage */
|
/** Storage mechanism, defaults to localStorage */
|
||||||
storage?: (name?: string) => SyncStorage | AsyncStorage
|
storage?: (name?: string) => SyncStorage | AsyncStorage
|
||||||
|
|
||||||
|
/** Stable platform window identity for window-scoped persistence */
|
||||||
|
windowID?: string
|
||||||
|
|
||||||
/** Application-global desktop updater */
|
/** Application-global desktop updater */
|
||||||
updater?: UpdaterPlatform
|
updater?: UpdaterPlatform
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useTabs, type Tab } from "./tabs"
|
|||||||
import { ServerConnection } from "./server"
|
import { ServerConnection } from "./server"
|
||||||
import { requireServerKey } from "@/utils/session-route"
|
import { requireServerKey } from "@/utils/session-route"
|
||||||
import { useSettings } from "./settings"
|
import { useSettings } from "./settings"
|
||||||
|
import type { FilePartSource } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
interface PartBase {
|
interface PartBase {
|
||||||
content: string
|
content: string
|
||||||
@@ -27,6 +28,10 @@ export interface FileAttachmentPart extends PartBase {
|
|||||||
type: "file"
|
type: "file"
|
||||||
path: string
|
path: string
|
||||||
selection?: FileSelection
|
selection?: FileSelection
|
||||||
|
mime?: string
|
||||||
|
filename?: string
|
||||||
|
url?: string
|
||||||
|
source?: FilePartSource
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentPart extends PartBase {
|
export interface AgentPart extends PartBase {
|
||||||
@@ -73,7 +78,13 @@ function isPartEqual(partA: ContentPart, partB: ContentPart) {
|
|||||||
case "text":
|
case "text":
|
||||||
return partB.type === "text" && partA.content === partB.content
|
return partB.type === "text" && partA.content === partB.content
|
||||||
case "file":
|
case "file":
|
||||||
return partB.type === "file" && partA.path === partB.path && isSelectionEqual(partA.selection, partB.selection)
|
return (
|
||||||
|
partB.type === "file" &&
|
||||||
|
partA.path === partB.path &&
|
||||||
|
partA.mime === partB.mime &&
|
||||||
|
partA.filename === partB.filename &&
|
||||||
|
isSelectionEqual(partA.selection, partB.selection)
|
||||||
|
)
|
||||||
case "agent":
|
case "agent":
|
||||||
return partB.type === "agent" && partA.name === partB.name
|
return partB.type === "agent" && partA.name === partB.name
|
||||||
case "image":
|
case "image":
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { coalesceServerEvents, resumeStreamAfterPageShow } from "./server-sdk"
|
import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
||||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
describe("resumeStreamAfterPageShow", () => {
|
describe("resumeStreamAfterPageShow", () => {
|
||||||
@@ -15,19 +15,23 @@ describe("resumeStreamAfterPageShow", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("coalesceServerEvents", () => {
|
describe("coalesceServerEvents", () => {
|
||||||
const delta = (value: string, field = "text") => ({
|
const delta = (value: string, field = "text", partID = "part") => ({
|
||||||
directory: "/repo",
|
directory: "/repo",
|
||||||
payload: {
|
payload: {
|
||||||
type: "message.part.delta",
|
type: "message.part.delta",
|
||||||
properties: { messageID: "msg", partID: "part", field, delta: value },
|
properties: { messageID: "msg", partID, field, delta: value },
|
||||||
} as Event,
|
} as Event,
|
||||||
})
|
})
|
||||||
|
|
||||||
test("merges adjacent deltas for the same field", () => {
|
test("merges adjacent deltas for the same field", () => {
|
||||||
const result = coalesceServerEvents([delta("hello "), delta("world")])
|
const first = delta("hello ")
|
||||||
|
const second = delta("world")
|
||||||
|
first.payload.id = "first"
|
||||||
|
second.payload.id = "second"
|
||||||
|
const result = coalesceServerEvents([first, second])
|
||||||
|
|
||||||
expect(result).toHaveLength(1)
|
expect(result).toHaveLength(1)
|
||||||
expect(result[0]?.payload).toMatchObject({ properties: { delta: "hello world" } })
|
expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves event boundaries and distinct fields", () => {
|
test("preserves event boundaries and distinct fields", () => {
|
||||||
@@ -45,9 +49,112 @@ describe("coalesceServerEvents", () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("drops stale deltas", () => {
|
test("preserves event ID order across interleaved deltas", () => {
|
||||||
const result = coalesceServerEvents([delta("stale")], new Set(["/repo:msg:part"]))
|
const first = delta("a")
|
||||||
|
const other = delta("b", "text", "other")
|
||||||
|
const last = delta("c")
|
||||||
|
first.payload.id = "1"
|
||||||
|
other.payload.id = "2"
|
||||||
|
last.payload.id = "3"
|
||||||
|
|
||||||
expect(result).toEqual([])
|
const result = coalesceServerEvents([first, other, last])
|
||||||
|
|
||||||
|
expect(result.map((event) => event.payload.id)).toEqual(["1", "2", "3"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("enqueueServerEvent", () => {
|
||||||
|
const partUpdated = (text: string) =>
|
||||||
|
({
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: {
|
||||||
|
sessionID: "session",
|
||||||
|
part: { id: "part", sessionID: "session", messageID: "message", type: "text", text },
|
||||||
|
},
|
||||||
|
}) as Event
|
||||||
|
|
||||||
|
test("preserves part updates across message remove and re-add barriers", () => {
|
||||||
|
const events: Array<{ directory: string; payload: Event }> = []
|
||||||
|
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
|
||||||
|
|
||||||
|
enqueue(partUpdated("old"))
|
||||||
|
enqueue({ type: "message.removed", properties: { sessionID: "session", messageID: "message" } } as Event)
|
||||||
|
enqueue({
|
||||||
|
type: "message.updated",
|
||||||
|
properties: {
|
||||||
|
sessionID: "session",
|
||||||
|
info: {
|
||||||
|
id: "message",
|
||||||
|
sessionID: "session",
|
||||||
|
role: "user",
|
||||||
|
time: { created: 1 },
|
||||||
|
agent: "build",
|
||||||
|
model: { providerID: "provider", modelID: "model" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as Event)
|
||||||
|
enqueue(partUpdated("new"))
|
||||||
|
|
||||||
|
expect(events.map((event) => event.payload.type)).toEqual([
|
||||||
|
"message.part.updated",
|
||||||
|
"message.removed",
|
||||||
|
"message.updated",
|
||||||
|
"message.part.updated",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves deltas after a replacement snapshot", () => {
|
||||||
|
const events: Array<{ directory: string; payload: Event }> = []
|
||||||
|
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
|
||||||
|
|
||||||
|
enqueue(partUpdated("a"))
|
||||||
|
enqueue(partUpdated("ab"))
|
||||||
|
enqueue({
|
||||||
|
type: "message.part.delta",
|
||||||
|
properties: { sessionID: "session", messageID: "message", partID: "part", field: "text", delta: "c" },
|
||||||
|
} as Event)
|
||||||
|
|
||||||
|
const result = coalesceServerEvents(events)
|
||||||
|
expect(result.map((event) => event.payload.type)).toEqual(["message.part.updated", "message.part.delta"])
|
||||||
|
expect(result[0]?.payload).toMatchObject({ properties: { part: { text: "ab" } } })
|
||||||
|
expect(result[1]?.payload).toMatchObject({ properties: { delta: "c" } })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves updates after session deletion", () => {
|
||||||
|
const events: Array<{ directory: string; payload: Event }> = []
|
||||||
|
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
|
||||||
|
|
||||||
|
enqueue(partUpdated("old"))
|
||||||
|
enqueue({
|
||||||
|
type: "session.deleted",
|
||||||
|
properties: { sessionID: "session", info: { id: "session" } },
|
||||||
|
} as Event)
|
||||||
|
enqueue(partUpdated("new"))
|
||||||
|
|
||||||
|
expect(events.map((event) => event.payload.type)).toEqual([
|
||||||
|
"message.part.updated",
|
||||||
|
"session.deleted",
|
||||||
|
"message.part.updated",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not coalesce edge-triggered session statuses", () => {
|
||||||
|
const events: Array<{ directory: string; payload: Event }> = []
|
||||||
|
const enqueue = (status: "retry" | "busy") =>
|
||||||
|
enqueueServerEvent(events, {
|
||||||
|
directory: "/repo",
|
||||||
|
payload: {
|
||||||
|
type: "session.status",
|
||||||
|
properties: {
|
||||||
|
sessionID: "session",
|
||||||
|
status: status === "retry" ? { type: "retry", attempt: 1, message: "retry", next: 1 } : { type: "busy" },
|
||||||
|
},
|
||||||
|
} as Event,
|
||||||
|
})
|
||||||
|
|
||||||
|
enqueue("retry")
|
||||||
|
enqueue("busy")
|
||||||
|
|
||||||
|
expect(events).toHaveLength(2)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,34 +17,56 @@ const isAbortError = (error: unknown) =>
|
|||||||
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
|
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
|
||||||
type QueuedServerEvent = { directory: string; payload: Event }
|
type QueuedServerEvent = { directory: string; payload: Event }
|
||||||
|
|
||||||
const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}`
|
const coalescedKey = (event: QueuedServerEvent) => {
|
||||||
|
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
|
||||||
|
if (event.payload.type === "message.part.updated") {
|
||||||
|
const part = event.payload.properties.part
|
||||||
|
return `message.part.updated:${event.directory}:${part.messageID}:${part.id}`
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
export function coalesceServerEvents(events: QueuedServerEvent[], stale?: Set<string>) {
|
export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServerEvent) {
|
||||||
|
const key = coalescedKey(event)
|
||||||
|
const previous = queue[queue.length - 1]
|
||||||
|
if (key && previous && coalescedKey(previous) === key) {
|
||||||
|
queue[queue.length - 1] = event
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
queue.push(event)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
||||||
const output: QueuedServerEvent[] = []
|
const output: QueuedServerEvent[] = []
|
||||||
const deltas = new Map<string, number>()
|
|
||||||
events.forEach((event) => {
|
events.forEach((event) => {
|
||||||
if (stale && event.payload.type === "message.part.delta") {
|
|
||||||
const props = event.payload.properties
|
|
||||||
if (stale.has(deltaKey(event.directory, props.messageID, props.partID))) return
|
|
||||||
}
|
|
||||||
if (event.payload.type !== "message.part.delta") {
|
if (event.payload.type !== "message.part.delta") {
|
||||||
deltas.clear()
|
|
||||||
output.push(event)
|
output.push(event)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const props = event.payload.properties
|
const props = event.payload.properties
|
||||||
const id = `${deltaKey(event.directory, props.messageID, props.partID)}:${props.field}`
|
const previous = output[output.length - 1]
|
||||||
const index = deltas.get(id)
|
if (
|
||||||
const existing = index === undefined ? undefined : output[index]
|
!previous ||
|
||||||
if (!existing || existing.payload.type !== "message.part.delta") {
|
previous.payload.type !== "message.part.delta" ||
|
||||||
deltas.set(id, output.length)
|
previous.directory !== event.directory ||
|
||||||
|
previous.payload.properties.messageID !== props.messageID ||
|
||||||
|
previous.payload.properties.partID !== props.partID ||
|
||||||
|
previous.payload.properties.field !== props.field
|
||||||
|
) {
|
||||||
output.push({
|
output.push({
|
||||||
directory: event.directory,
|
directory: event.directory,
|
||||||
payload: { ...event.payload, properties: { ...props } },
|
payload: { ...event.payload, properties: { ...props } },
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
existing.payload.properties.delta += props.delta
|
output[output.length - 1] = {
|
||||||
|
directory: event.directory,
|
||||||
|
payload: {
|
||||||
|
...event.payload,
|
||||||
|
properties: { ...props, delta: previous.payload.properties.delta + props.delta },
|
||||||
|
},
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
@@ -85,20 +107,9 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
|
|
||||||
let queue: Queued[] = []
|
let queue: Queued[] = []
|
||||||
let buffer: Queued[] = []
|
let buffer: Queued[] = []
|
||||||
const coalesced = new Map<string, number>()
|
|
||||||
const staleDeltas = new Set<string>()
|
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
let last = 0
|
let last = 0
|
||||||
|
|
||||||
const key = (directory: string, payload: Event) => {
|
|
||||||
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
|
|
||||||
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
|
|
||||||
if (payload.type === "message.part.updated") {
|
|
||||||
const part = payload.properties.part
|
|
||||||
return `message.part.updated:${directory}:${part.messageID}:${part.id}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const flush = () => {
|
const flush = () => {
|
||||||
if (timer) clearTimeout(timer)
|
if (timer) clearTimeout(timer)
|
||||||
timer = undefined
|
timer = undefined
|
||||||
@@ -106,15 +117,12 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
if (queue.length === 0) return
|
if (queue.length === 0) return
|
||||||
|
|
||||||
const events = queue
|
const events = queue
|
||||||
const skip = staleDeltas.size > 0 ? new Set(staleDeltas) : undefined
|
|
||||||
queue = buffer
|
queue = buffer
|
||||||
buffer = events
|
buffer = events
|
||||||
queue.length = 0
|
queue.length = 0
|
||||||
coalesced.clear()
|
|
||||||
staleDeltas.clear()
|
|
||||||
|
|
||||||
last = Date.now()
|
last = Date.now()
|
||||||
const output = coalesceServerEvents(events, skip)
|
const output = coalesceServerEvents(events)
|
||||||
batch(() => {
|
batch(() => {
|
||||||
output.forEach((event) => emitter.emit(event.directory, event.payload))
|
output.forEach((event) => emitter.emit(event.directory, event.payload))
|
||||||
})
|
})
|
||||||
@@ -184,29 +192,12 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
for await (const event of events.stream) {
|
for await (const event of events.stream) {
|
||||||
resetHeartbeat()
|
resetHeartbeat()
|
||||||
streamErrorLogged = false
|
streamErrorLogged = false
|
||||||
const directory = event.directory ?? "global"
|
if (event.payload.type !== "sync") {
|
||||||
if (event.payload.type === "sync") {
|
const directory = event.directory ?? "global"
|
||||||
continue
|
const payload = event.payload as Event
|
||||||
|
if (enqueueServerEvent(queue, { directory, payload })) schedule()
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = event.payload as Event
|
|
||||||
|
|
||||||
const k = key(directory, payload)
|
|
||||||
if (k) {
|
|
||||||
const i = coalesced.get(k)
|
|
||||||
if (i !== undefined) {
|
|
||||||
queue[i] = { directory, payload }
|
|
||||||
if (payload.type === "message.part.updated") {
|
|
||||||
const part = payload.properties.part
|
|
||||||
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
coalesced.set(k, queue.length)
|
|
||||||
}
|
|
||||||
queue.push({ directory, payload })
|
|
||||||
schedule()
|
|
||||||
|
|
||||||
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||||
yielded = Date.now()
|
yielded = Date.now()
|
||||||
await wait(0)
|
await wait(0)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -18,44 +18,67 @@ import { rootSession } from "@/utils/session-route"
|
|||||||
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
|
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
|
||||||
|
|
||||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||||
|
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
|
||||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||||
const initialMessagePageSize = 2
|
const initialMessagePageSize = 2
|
||||||
const historyMessagePageSize = 200
|
const historyMessagePageSize = 200
|
||||||
const sessionInfoLimit = 2_048
|
const sessionInfoLimit = 2_048
|
||||||
|
const emptyIDs: ReadonlySet<string> = new Set()
|
||||||
|
|
||||||
type OptimisticItem = {
|
type OptimisticItem = {
|
||||||
message: Message
|
message: Message
|
||||||
parts: Part[]
|
parts: Part[]
|
||||||
|
confirmedParts?: Part[]
|
||||||
|
confirmedMessage?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
|
type MessagePage = {
|
||||||
if (!parts) return want.length === 0
|
session: Message[]
|
||||||
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
|
part: { id: string; part: Part[] }[]
|
||||||
|
cursor?: string
|
||||||
|
complete: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeOptimisticPage(
|
// Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries.
|
||||||
page: { session: Message[]; part: { id: string; part: Part[] }[]; cursor?: string; complete: boolean },
|
type MessageLoadState = {
|
||||||
items: OptimisticItem[],
|
touchedMessages: Set<string>
|
||||||
) {
|
removedMessages: Set<string>
|
||||||
if (items.length === 0) return { ...page, confirmed: [] as string[] }
|
retainedMessages: Set<string>
|
||||||
|
touchedParts: Map<string, Set<string>>
|
||||||
|
deltaParts: Map<string, Set<string>>
|
||||||
|
carriedDeltaParts: Map<string, Set<string>>
|
||||||
|
removedParts: Map<string, Set<string>>
|
||||||
|
optimisticParts: Map<string, Set<string>>
|
||||||
|
orphanParents: Set<string>
|
||||||
|
clearedMessageParts: Set<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
|
||||||
|
if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] }
|
||||||
const session = [...page.session]
|
const session = [...page.session]
|
||||||
const part = new Map(page.part.map((item) => [item.id, item.part]))
|
const part = new Map(page.part.map((item) => [item.id, item.part]))
|
||||||
const confirmed: string[] = []
|
const observed: { messageID: string; parts: Part[] }[] = []
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const result = Binary.search(session, item.message.id, (message) => message.id)
|
const result = Binary.search(session, item.message.id, (message) => message.id)
|
||||||
if (!result.found) session.splice(result.index, 0, item.message)
|
if (!result.found) session.splice(result.index, 0, item.message)
|
||||||
const current = part.get(item.message.id)
|
const current = part.get(item.message.id)
|
||||||
if (result.found && hasParts(current, item.parts)) {
|
const confirmed = result.found
|
||||||
confirmed.push(item.message.id)
|
? item.parts.filter((part) => Binary.search(current ?? [], part.id, (value) => value.id).found)
|
||||||
continue
|
: []
|
||||||
}
|
if (result.found) observed.push({ messageID: item.message.id, parts: confirmed })
|
||||||
part.set(item.message.id, merge(current ?? [], item.parts))
|
part.set(
|
||||||
|
item.message.id,
|
||||||
|
merge(
|
||||||
|
result.found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []),
|
||||||
|
item.parts.filter((part) => !confirmed.includes(part)),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...page,
|
...page,
|
||||||
session,
|
session,
|
||||||
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, parts]) => ({ id, part: parts })),
|
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, parts]) => ({ id, part: parts })),
|
||||||
confirmed,
|
observed,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +98,38 @@ function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
|
|||||||
return [...items.values()].sort((x, y) => cmp(x.id, y.id))
|
return [...items.values()].sort((x, y) => cmp(x.id, y.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createServerSession(client: OpencodeClient) {
|
function reconcileFetched<T extends { id: string }>(
|
||||||
|
fetched: T[],
|
||||||
|
current: readonly T[],
|
||||||
|
options: {
|
||||||
|
touched?: ReadonlySet<string>
|
||||||
|
retained?: ReadonlySet<string>
|
||||||
|
preserveUnfetched?: boolean | ((item: T) => boolean)
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const result = new Map(fetched.map((item) => [item.id, item]))
|
||||||
|
const live = new Map(current.map((item) => [item.id, item]))
|
||||||
|
if (options.preserveUnfetched) {
|
||||||
|
for (const item of current) {
|
||||||
|
if (!result.has(item.id) && (options.preserveUnfetched === true || options.preserveUnfetched(item)))
|
||||||
|
result.set(item.id, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const id of options.retained ?? emptyIDs) {
|
||||||
|
if (result.has(id)) continue
|
||||||
|
const item = live.get(id)
|
||||||
|
if (item) result.set(id, item)
|
||||||
|
}
|
||||||
|
// Events observed while the request is pending are the freshest client state for those identities.
|
||||||
|
for (const id of options.touched ?? emptyIDs) {
|
||||||
|
const item = live.get(id)
|
||||||
|
if (item) result.set(id, item)
|
||||||
|
if (!item) result.delete(id)
|
||||||
|
}
|
||||||
|
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServerSession(client: OpencodeClient, options?: { retry?: typeof retry }) {
|
||||||
const [data, setData] = createStore({
|
const [data, setData] = createStore({
|
||||||
info: {} as Record<string, Session | undefined>,
|
info: {} as Record<string, Session | undefined>,
|
||||||
session_status: {} as Record<string, SessionStatus>,
|
session_status: {} as Record<string, SessionStatus>,
|
||||||
@@ -95,10 +149,32 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
const inflightDiff = new Map<string, Promise<void>>()
|
const inflightDiff = new Map<string, Promise<void>>()
|
||||||
const inflightTodo = new Map<string, Promise<void>>()
|
const inflightTodo = new Map<string, Promise<void>>()
|
||||||
const optimistic = new Map<string, Map<string, OptimisticItem>>()
|
const optimistic = new Map<string, Map<string, OptimisticItem>>()
|
||||||
|
const messageLoads = new Map<string, MessageLoadState>()
|
||||||
|
const pendingParts = new Map<string, Map<string, Set<string>>>()
|
||||||
|
const orphanParts = new Map<string, Set<string>>()
|
||||||
|
const removedMessages = new Map<string, Set<string>>()
|
||||||
|
const deltaBases = new Map<string, { base: string; sessionID: string }>()
|
||||||
|
const deleteMessageParts = (
|
||||||
|
cache: { part: Record<string, Part[] | undefined>; part_text_accum_delta: Record<string, string | undefined> },
|
||||||
|
messageID: string,
|
||||||
|
) => {
|
||||||
|
for (const part of cache.part[messageID] ?? []) {
|
||||||
|
delete cache.part_text_accum_delta[part.id]
|
||||||
|
deltaBases.delete(part.id)
|
||||||
|
}
|
||||||
|
delete cache.part[messageID]
|
||||||
|
}
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const infoSeen = new Set<string>()
|
const infoSeen = new Set<string>()
|
||||||
const pinned = new Map<string, number>()
|
const pinned = new Map<string, number>()
|
||||||
const generations = new Map<string, number>()
|
const generations = new Map<string, object>()
|
||||||
|
const generation = (sessionID: string) => {
|
||||||
|
const current = generations.get(sessionID)
|
||||||
|
if (current) return current
|
||||||
|
const created = {}
|
||||||
|
generations.set(sessionID, created)
|
||||||
|
return created
|
||||||
|
}
|
||||||
const [meta, setMeta] = createStore({
|
const [meta, setMeta] = createStore({
|
||||||
limit: {} as Record<string, number | undefined>,
|
limit: {} as Record<string, number | undefined>,
|
||||||
cursor: {} as Record<string, string | undefined>,
|
cursor: {} as Record<string, string | undefined>,
|
||||||
@@ -115,6 +191,11 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
const preserve = new Set([
|
const preserve = new Set([
|
||||||
...pinned.keys(),
|
...pinned.keys(),
|
||||||
...requests.keys(),
|
...requests.keys(),
|
||||||
|
...inflight.keys(),
|
||||||
|
...inflightDiff.keys(),
|
||||||
|
...inflightTodo.keys(),
|
||||||
|
...messageLoads.keys(),
|
||||||
|
...optimistic.keys(),
|
||||||
...Object.entries(data.permission)
|
...Object.entries(data.permission)
|
||||||
.filter(([, items]) => items.length > 0)
|
.filter(([, items]) => items.length > 0)
|
||||||
.map(([sessionID]) => sessionID),
|
.map(([sessionID]) => sessionID),
|
||||||
@@ -138,6 +219,7 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
if (!preserve.has(sessionID)) stale.push(sessionID)
|
if (!preserve.has(sessionID)) stale.push(sessionID)
|
||||||
}
|
}
|
||||||
stale.forEach((sessionID) => infoSeen.delete(sessionID))
|
stale.forEach((sessionID) => infoSeen.delete(sessionID))
|
||||||
|
stale.forEach((sessionID) => generations.delete(sessionID))
|
||||||
setData(
|
setData(
|
||||||
"info",
|
"info",
|
||||||
produce((draft) => stale.forEach((sessionID) => delete draft[sessionID])),
|
produce((draft) => stale.forEach((sessionID) => delete draft[sessionID])),
|
||||||
@@ -151,21 +233,27 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
if (cached && !options?.force) return Promise.resolve(cached)
|
if (cached && !options?.force) return Promise.resolve(cached)
|
||||||
const pending = requests.get(sessionID)
|
const pending = requests.get(sessionID)
|
||||||
if (pending) return pending
|
if (pending) return pending
|
||||||
const generation = generations.get(sessionID) ?? 0
|
const active = generation(sessionID)
|
||||||
const request = client.session.get({ sessionID }).then((result) => {
|
const request = client.session.get({ sessionID }).then((result) => {
|
||||||
if (!result.data) throw new Error(`Session not found: ${sessionID}`)
|
if (!result.data) throw new Error(`Session not found: ${sessionID}`)
|
||||||
if ((generations.get(sessionID) ?? 0) !== generation) return result.data
|
if (generations.get(sessionID) !== active) return result.data
|
||||||
return remember(result.data)
|
return remember(result.data)
|
||||||
})
|
})
|
||||||
requests.set(sessionID, request)
|
requests.set(sessionID, request)
|
||||||
void request.then(
|
const cleanup = () => {
|
||||||
() => {
|
if (requests.get(sessionID) === request) requests.delete(sessionID)
|
||||||
if (requests.get(sessionID) === request) requests.delete(sessionID)
|
if (
|
||||||
},
|
generations.get(sessionID) === active &&
|
||||||
() => {
|
!data.info[sessionID] &&
|
||||||
if (requests.get(sessionID) === request) requests.delete(sessionID)
|
!requests.has(sessionID) &&
|
||||||
},
|
!messageLoads.has(sessionID) &&
|
||||||
)
|
!inflight.has(sessionID) &&
|
||||||
|
!inflightDiff.has(sessionID) &&
|
||||||
|
!inflightTodo.has(sessionID)
|
||||||
|
)
|
||||||
|
generations.delete(sessionID)
|
||||||
|
}
|
||||||
|
void request.then(cleanup, cleanup)
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,15 +283,121 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
if (items.size === 0) optimistic.delete(sessionID)
|
if (items.size === 0) optimistic.delete(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearOptimisticPart = (sessionID: string, messageID: string, partID: string) => {
|
||||||
|
const items = optimistic.get(sessionID)
|
||||||
|
const item = items?.get(messageID)
|
||||||
|
if (!items || !item) return
|
||||||
|
const parts = item.parts.filter((part) => part.id !== partID)
|
||||||
|
const confirmedParts = item.confirmedParts?.filter((part) => part.id !== partID)
|
||||||
|
if (parts.length === 0) {
|
||||||
|
clearOptimistic(sessionID, messageID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items.set(messageID, { ...item, parts, confirmedParts, confirmedMessage: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmOptimisticPart = (sessionID: string, messageID: string, part: Part) => {
|
||||||
|
const items = optimistic.get(sessionID)
|
||||||
|
const item = items?.get(messageID)
|
||||||
|
if (!items || !item) return
|
||||||
|
const parts = item.parts.filter((value) => value.id !== part.id)
|
||||||
|
if (parts.length === 0) {
|
||||||
|
clearOptimistic(sessionID, messageID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items.set(messageID, {
|
||||||
|
...item,
|
||||||
|
parts,
|
||||||
|
confirmedParts: merge(item.confirmedParts ?? [], [part]),
|
||||||
|
confirmedMessage: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmOptimistic = (sessionID: string, messageID: string, confirmedParts: Part[]) => {
|
||||||
|
const items = optimistic.get(sessionID)
|
||||||
|
const item = items?.get(messageID)
|
||||||
|
if (!items || !item) return
|
||||||
|
const confirmed = new Set(confirmedParts.map((part) => part.id))
|
||||||
|
const parts = item.parts.filter((part) => !confirmed.has(part.id))
|
||||||
|
if (parts.length === 0) {
|
||||||
|
clearOptimistic(sessionID, messageID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items.set(messageID, {
|
||||||
|
...item,
|
||||||
|
parts,
|
||||||
|
confirmedParts: merge(item.confirmedParts ?? [], confirmedParts),
|
||||||
|
confirmedMessage: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackPartChange = (sessionID: string, messageID: string, partID: string) => {
|
||||||
|
const load = messageLoads.get(sessionID)
|
||||||
|
if (!load) return
|
||||||
|
// A part event keeps an existing parent when the fetched page omits it without overriding fetched metadata.
|
||||||
|
const messages = data.message[sessionID]
|
||||||
|
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
|
||||||
|
load.retainedMessages.add(messageID)
|
||||||
|
const parts = load.touchedParts.get(messageID)
|
||||||
|
if (parts) {
|
||||||
|
parts.add(partID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
load.touchedParts.set(messageID, new Set([partID]))
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetMessageLoad = (sessionID: string, load: MessageLoadState) => {
|
||||||
|
load.touchedMessages.clear()
|
||||||
|
load.retainedMessages.clear()
|
||||||
|
load.touchedParts.clear()
|
||||||
|
load.carriedDeltaParts.clear()
|
||||||
|
load.clearedMessageParts.clear()
|
||||||
|
for (const messageID of load.removedMessages) {
|
||||||
|
load.touchedMessages.add(messageID)
|
||||||
|
load.clearedMessageParts.add(messageID)
|
||||||
|
}
|
||||||
|
for (const [messageID, parts] of load.deltaParts) {
|
||||||
|
load.touchedParts.set(messageID, new Set(parts))
|
||||||
|
load.carriedDeltaParts.set(messageID, new Set(parts))
|
||||||
|
const messages = data.message[sessionID]
|
||||||
|
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
|
||||||
|
load.retainedMessages.add(messageID)
|
||||||
|
}
|
||||||
|
for (const [messageID, parts] of load.removedParts) {
|
||||||
|
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
|
||||||
|
parts.forEach((partID) => touched.add(partID))
|
||||||
|
load.touchedParts.set(messageID, touched)
|
||||||
|
const messages = data.message[sessionID]
|
||||||
|
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
|
||||||
|
load.retainedMessages.add(messageID)
|
||||||
|
}
|
||||||
|
for (const [messageID, parts] of load.optimisticParts) {
|
||||||
|
load.removedMessages.delete(messageID)
|
||||||
|
load.clearedMessageParts.add(messageID)
|
||||||
|
load.touchedMessages.add(messageID)
|
||||||
|
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
|
||||||
|
parts.forEach((partID) => touched.add(partID))
|
||||||
|
load.touchedParts.set(messageID, touched)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const evict = (sessionIDs: string[]) => {
|
const evict = (sessionIDs: string[]) => {
|
||||||
if (sessionIDs.length === 0) return
|
if (sessionIDs.length === 0) return
|
||||||
|
const evicted = new Set(sessionIDs)
|
||||||
|
for (const [partID, item] of deltaBases) {
|
||||||
|
if (evicted.has(item.sessionID)) deltaBases.delete(partID)
|
||||||
|
}
|
||||||
sessionIDs.forEach((sessionID) => {
|
sessionIDs.forEach((sessionID) => {
|
||||||
generations.set(sessionID, (generations.get(sessionID) ?? 0) + 1)
|
generations.delete(sessionID)
|
||||||
clearOptimistic(sessionID)
|
clearOptimistic(sessionID)
|
||||||
requests.delete(sessionID)
|
requests.delete(sessionID)
|
||||||
inflight.delete(sessionID)
|
inflight.delete(sessionID)
|
||||||
inflightDiff.delete(sessionID)
|
inflightDiff.delete(sessionID)
|
||||||
inflightTodo.delete(sessionID)
|
inflightTodo.delete(sessionID)
|
||||||
|
messageLoads.delete(sessionID)
|
||||||
|
pendingParts.delete(sessionID)
|
||||||
|
orphanParts.delete(sessionID)
|
||||||
|
removedMessages.delete(sessionID)
|
||||||
})
|
})
|
||||||
setData(
|
setData(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
@@ -230,6 +424,7 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
...inflight.keys(),
|
...inflight.keys(),
|
||||||
...inflightDiff.keys(),
|
...inflightDiff.keys(),
|
||||||
...inflightTodo.keys(),
|
...inflightTodo.keys(),
|
||||||
|
...messageLoads.keys(),
|
||||||
...optimistic.keys(),
|
...optimistic.keys(),
|
||||||
...Object.entries(data.permission)
|
...Object.entries(data.permission)
|
||||||
.filter(([, items]) => items.length > 0)
|
.filter(([, items]) => items.length > 0)
|
||||||
@@ -247,8 +442,11 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }),
|
pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }),
|
||||||
)
|
)
|
||||||
|
|
||||||
const fetchMessages = async (sessionID: string, limit: number, before?: string) => {
|
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
|
||||||
const response = await retry(() => client.session.messages({ sessionID, limit, before }))
|
const response = await (options?.retry ?? retry)(() => {
|
||||||
|
onAttempt?.()
|
||||||
|
return client.session.messages({ sessionID, limit, before })
|
||||||
|
})
|
||||||
const items = (response.data ?? []).filter((item) => !!item?.info?.id)
|
const items = (response.data ?? []).filter((item) => !!item?.info?.id)
|
||||||
return {
|
return {
|
||||||
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
|
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
|
||||||
@@ -261,30 +459,153 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const replaceMessages = (sessionID: string, messages: Message[]) => {
|
||||||
|
const messageIDs = new Set(messages.map((message) => message.id))
|
||||||
|
const dropped = (data.message[sessionID] ?? []).filter((message) => !messageIDs.has(message.id))
|
||||||
|
setData("message", sessionID, reconcile(messages, { key: "id" }))
|
||||||
|
setData(
|
||||||
|
produce((draft) => {
|
||||||
|
for (const message of dropped) deleteMessageParts(draft, message.id)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return messageIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
const replaceParts = (
|
||||||
|
sessionID: string,
|
||||||
|
items: MessagePage["part"],
|
||||||
|
messageIDs: Set<string>,
|
||||||
|
load?: MessageLoadState,
|
||||||
|
) => {
|
||||||
|
for (const item of items) {
|
||||||
|
if (!messageIDs.has(item.id)) continue
|
||||||
|
const fetched = load?.clearedMessageParts.has(item.id)
|
||||||
|
? []
|
||||||
|
: item.part.filter((part) => !SKIP_PARTS.has(part.type))
|
||||||
|
const fetchedIDs = new Set(fetched.map((part) => part.id))
|
||||||
|
const pending = pendingParts.get(sessionID)?.get(item.id)
|
||||||
|
const touched = new Set([...(load?.touchedParts.get(item.id) ?? []), ...(pending ?? [])])
|
||||||
|
for (const part of fetched) {
|
||||||
|
const accumulated = data.part_text_accum_delta[part.id]
|
||||||
|
const base = deltaBases.get(part.id)?.base
|
||||||
|
const preserveDelta =
|
||||||
|
base !== undefined &&
|
||||||
|
accumulated !== undefined &&
|
||||||
|
"text" in part &&
|
||||||
|
typeof part.text === "string" &&
|
||||||
|
part.text.startsWith(base) &&
|
||||||
|
accumulated.startsWith(part.text) &&
|
||||||
|
accumulated !== part.text
|
||||||
|
if (preserveDelta) touched.add(part.id)
|
||||||
|
if (load?.carriedDeltaParts.get(item.id)?.has(part.id) && !preserveDelta) touched.delete(part.id)
|
||||||
|
}
|
||||||
|
for (const partID of load?.carriedDeltaParts.get(item.id) ?? []) {
|
||||||
|
if (!fetchedIDs.has(partID)) touched.delete(partID)
|
||||||
|
}
|
||||||
|
const parts = reconcileFetched(fetched, data.part[item.id] ?? [], { touched })
|
||||||
|
if (!parts.length) {
|
||||||
|
orphanParts.get(sessionID)?.delete(item.id)
|
||||||
|
setData(produce((draft) => deleteMessageParts(draft, item.id)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const partIDs = new Set(parts.map((part) => part.id))
|
||||||
|
setData(
|
||||||
|
"part_text_accum_delta",
|
||||||
|
produce((draft) => {
|
||||||
|
for (const part of data.part[item.id] ?? []) {
|
||||||
|
if (!partIDs.has(part.id) || !touched.has(part.id)) {
|
||||||
|
delete draft[part.id]
|
||||||
|
deltaBases.delete(part.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
setData("part", item.id, reconcile(parts, { key: "id" }))
|
||||||
|
orphanParts.get(sessionID)?.delete(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyMessagePage = (
|
||||||
|
sessionID: string,
|
||||||
|
page: MessagePage,
|
||||||
|
load: MessageLoadState | undefined,
|
||||||
|
preserveUnfetched: boolean | ((message: Message) => boolean),
|
||||||
|
cleanupOrphans: boolean,
|
||||||
|
) => {
|
||||||
|
const merged = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
|
||||||
|
merged.observed.forEach((item) => {
|
||||||
|
if (!load?.clearedMessageParts.has(item.messageID)) confirmOptimistic(sessionID, item.messageID, item.parts)
|
||||||
|
})
|
||||||
|
const touchedMessages = new Set([...(load?.touchedMessages ?? []), ...(removedMessages.get(sessionID) ?? [])])
|
||||||
|
const messages = reconcileFetched(merged.session, data.message[sessionID] ?? [], {
|
||||||
|
touched: touchedMessages,
|
||||||
|
retained: load?.retainedMessages,
|
||||||
|
preserveUnfetched,
|
||||||
|
})
|
||||||
|
batch(() => {
|
||||||
|
const messageIDs = replaceMessages(sessionID, messages)
|
||||||
|
replaceParts(sessionID, merged.part, messageIDs, load)
|
||||||
|
const orphans = orphanParts.get(sessionID)
|
||||||
|
if (cleanupOrphans && page.complete && orphans) {
|
||||||
|
for (const messageID of orphans) {
|
||||||
|
if (!messageIDs.has(messageID)) setData(produce((draft) => deleteMessageParts(draft, messageID)))
|
||||||
|
}
|
||||||
|
orphanParts.delete(sessionID)
|
||||||
|
}
|
||||||
|
setMeta("limit", sessionID, messages.length)
|
||||||
|
setMeta("cursor", sessionID, merged.cursor)
|
||||||
|
setMeta("complete", sessionID, merged.complete)
|
||||||
|
setMeta("at", sessionID, Date.now())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const loadMessages = async (sessionID: string, limit: number, before?: string, mode?: "replace" | "prepend") => {
|
const loadMessages = async (sessionID: string, limit: number, before?: string, mode?: "replace" | "prepend") => {
|
||||||
if (meta.loading[sessionID]) return
|
if (meta.loading[sessionID]) return
|
||||||
const generation = generations.get(sessionID) ?? 0
|
const active = generation(sessionID)
|
||||||
|
const load: MessageLoadState = {
|
||||||
|
touchedMessages: new Set(),
|
||||||
|
removedMessages: new Set(),
|
||||||
|
retainedMessages: new Set(),
|
||||||
|
touchedParts: new Map(),
|
||||||
|
deltaParts: new Map(),
|
||||||
|
carriedDeltaParts: new Map(),
|
||||||
|
removedParts: new Map(),
|
||||||
|
optimisticParts: new Map(),
|
||||||
|
orphanParents: new Set(),
|
||||||
|
clearedMessageParts: new Set(),
|
||||||
|
}
|
||||||
|
messageLoads.set(sessionID, load)
|
||||||
setMeta("loading", sessionID, true)
|
setMeta("loading", sessionID, true)
|
||||||
await fetchMessages(sessionID, limit, before)
|
let applied = false
|
||||||
|
await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
|
||||||
.then((page) => {
|
.then((page) => {
|
||||||
if ((generations.get(sessionID) ?? 0) !== generation) return
|
if (generations.get(sessionID) !== active) return
|
||||||
const next = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
|
const first = page.session.reduce<Message | undefined>(
|
||||||
next.confirmed.forEach((messageID) => clearOptimistic(sessionID, messageID))
|
(oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest),
|
||||||
const messages = mode === "prepend" ? merge(data.message[sessionID] ?? [], next.session) : next.session
|
undefined,
|
||||||
batch(() => {
|
)
|
||||||
setData("message", sessionID, reconcile(messages, { key: "id" }))
|
const preserveUnfetched =
|
||||||
for (const item of next.part) {
|
mode === "prepend" || (!page.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0)))
|
||||||
const parts = item.part.filter((part) => !SKIP_PARTS.has(part.type))
|
applyMessagePage(
|
||||||
if (parts.length) setData("part", item.id, reconcile(parts, { key: "id" }))
|
sessionID,
|
||||||
}
|
page,
|
||||||
setMeta("limit", sessionID, messages.length)
|
messageLoads.get(sessionID) === load ? load : undefined,
|
||||||
setMeta("cursor", sessionID, next.cursor)
|
preserveUnfetched,
|
||||||
setMeta("complete", sessionID, next.complete)
|
mode !== "prepend",
|
||||||
setMeta("at", sessionID, Date.now())
|
)
|
||||||
})
|
applied = true
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if ((generations.get(sessionID) ?? 0) === generation) setMeta("loading", sessionID, false)
|
if (!applied && generations.get(sessionID) === active && messageLoads.get(sessionID) === load) {
|
||||||
|
for (const messageID of load.orphanParents) {
|
||||||
|
if (!orphanParts.get(sessionID)?.has(messageID)) continue
|
||||||
|
setData(produce((draft) => deleteMessageParts(draft, messageID)))
|
||||||
|
orphanParts.get(sessionID)?.delete(messageID)
|
||||||
|
}
|
||||||
|
if (orphanParts.get(sessionID)?.size === 0) orphanParts.delete(sessionID)
|
||||||
|
}
|
||||||
|
if (messageLoads.get(sessionID) === load) messageLoads.delete(sessionID)
|
||||||
|
if (generations.get(sessionID) === active) setMeta("loading", sessionID, false)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,7 +660,13 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
const eventID = eventSessionID(event)
|
const eventID = eventSessionID(event)
|
||||||
if (eventID) {
|
if (eventID) {
|
||||||
touch(eventID)
|
touch(eventID)
|
||||||
if (!data.info[eventID]) void resolve(eventID).catch(() => {})
|
if (
|
||||||
|
!data.info[eventID] &&
|
||||||
|
event.type !== "session.created" &&
|
||||||
|
event.type !== "session.updated" &&
|
||||||
|
event.type !== "session.deleted"
|
||||||
|
)
|
||||||
|
void resolve(eventID).catch(() => {})
|
||||||
}
|
}
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "session.created":
|
case "session.created":
|
||||||
@@ -378,6 +705,21 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
}
|
}
|
||||||
case "message.updated": {
|
case "message.updated": {
|
||||||
const info = cleanMessage((event.properties as { info: Message }).info)
|
const info = cleanMessage((event.properties as { info: Message }).info)
|
||||||
|
const load = messageLoads.get(info.sessionID)
|
||||||
|
load?.touchedMessages.add(info.id)
|
||||||
|
load?.removedMessages.delete(info.id)
|
||||||
|
const items = optimistic.get(info.sessionID)
|
||||||
|
const item = items?.get(info.id)
|
||||||
|
if (items && item) {
|
||||||
|
if (item.parts.length === 0) clearOptimistic(info.sessionID, info.id)
|
||||||
|
if (item.parts.length > 0) items.set(info.id, { ...item, confirmedMessage: true })
|
||||||
|
}
|
||||||
|
const orphans = orphanParts.get(info.sessionID)
|
||||||
|
orphans?.delete(info.id)
|
||||||
|
if (orphans?.size === 0) orphanParts.delete(info.sessionID)
|
||||||
|
const removedMessagesForSession = removedMessages.get(info.sessionID)
|
||||||
|
removedMessagesForSession?.delete(info.id)
|
||||||
|
if (removedMessagesForSession?.size === 0) removedMessages.delete(info.sessionID)
|
||||||
const messages = data.message[info.sessionID]
|
const messages = data.message[info.sessionID]
|
||||||
if (!messages) {
|
if (!messages) {
|
||||||
setData("message", info.sessionID, [info])
|
setData("message", info.sessionID, [info])
|
||||||
@@ -395,6 +737,20 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
}
|
}
|
||||||
case "message.removed": {
|
case "message.removed": {
|
||||||
const props = event.properties as { sessionID: string; messageID: string }
|
const props = event.properties as { sessionID: string; messageID: string }
|
||||||
|
const load = messageLoads.get(props.sessionID)
|
||||||
|
load?.touchedMessages.add(props.messageID)
|
||||||
|
load?.removedMessages.add(props.messageID)
|
||||||
|
load?.clearedMessageParts.add(props.messageID)
|
||||||
|
load?.deltaParts.delete(props.messageID)
|
||||||
|
load?.carriedDeltaParts.delete(props.messageID)
|
||||||
|
load?.removedParts.delete(props.messageID)
|
||||||
|
load?.optimisticParts.delete(props.messageID)
|
||||||
|
pendingParts.get(props.sessionID)?.delete(props.messageID)
|
||||||
|
if (pendingParts.get(props.sessionID)?.size === 0) pendingParts.delete(props.sessionID)
|
||||||
|
const removedMessagesForSession = removedMessages.get(props.sessionID) ?? new Set<string>()
|
||||||
|
removedMessagesForSession.add(props.messageID)
|
||||||
|
removedMessages.set(props.sessionID, removedMessagesForSession)
|
||||||
|
clearOptimistic(props.sessionID, props.messageID)
|
||||||
setData(
|
setData(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
const messages = draft.message[props.sessionID]
|
const messages = draft.message[props.sessionID]
|
||||||
@@ -402,8 +758,7 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
const result = Binary.search(messages, props.messageID, (message) => message.id)
|
const result = Binary.search(messages, props.messageID, (message) => message.id)
|
||||||
if (result.found) messages.splice(result.index, 1)
|
if (result.found) messages.splice(result.index, 1)
|
||||||
}
|
}
|
||||||
for (const part of draft.part[props.messageID] ?? []) delete draft.part_text_accum_delta[part.id]
|
deleteMessageParts(draft, props.messageID)
|
||||||
delete draft.part[props.messageID]
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -411,6 +766,42 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
case "message.part.updated": {
|
case "message.part.updated": {
|
||||||
const part = (event.properties as { part: Part }).part
|
const part = (event.properties as { part: Part }).part
|
||||||
if (SKIP_PARTS.has(part.type)) return
|
if (SKIP_PARTS.has(part.type)) return
|
||||||
|
const messages = data.message[part.sessionID]
|
||||||
|
const load = messageLoads.get(part.sessionID)
|
||||||
|
const missing = !messages || !Binary.search(messages, part.messageID, (message) => message.id).found
|
||||||
|
// Outside a page load, accepting a part without its ordered parent event would create an unbounded orphan.
|
||||||
|
if (
|
||||||
|
missing &&
|
||||||
|
(!load ||
|
||||||
|
load.clearedMessageParts.has(part.messageID) ||
|
||||||
|
removedMessages.get(part.sessionID)?.has(part.messageID))
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if (missing) {
|
||||||
|
const orphans = orphanParts.get(part.sessionID) ?? new Set<string>()
|
||||||
|
orphans.add(part.messageID)
|
||||||
|
orphanParts.set(part.sessionID, orphans)
|
||||||
|
load?.orphanParents.add(part.messageID)
|
||||||
|
}
|
||||||
|
const deltas = load?.deltaParts.get(part.messageID)
|
||||||
|
deltas?.delete(part.id)
|
||||||
|
if (deltas?.size === 0) load?.deltaParts.delete(part.messageID)
|
||||||
|
const carried = load?.carriedDeltaParts.get(part.messageID)
|
||||||
|
carried?.delete(part.id)
|
||||||
|
if (carried?.size === 0) load?.carriedDeltaParts.delete(part.messageID)
|
||||||
|
const removed = load?.removedParts.get(part.messageID)
|
||||||
|
removed?.delete(part.id)
|
||||||
|
if (removed?.size === 0) load?.removedParts.delete(part.messageID)
|
||||||
|
const pending = pendingParts.get(part.sessionID)?.get(part.messageID)
|
||||||
|
pending?.delete(part.id)
|
||||||
|
if (pending?.size === 0) pendingParts.get(part.sessionID)?.delete(part.messageID)
|
||||||
|
if (pendingParts.get(part.sessionID)?.size === 0) pendingParts.delete(part.sessionID)
|
||||||
|
const optimistic = load?.optimisticParts.get(part.messageID)
|
||||||
|
optimistic?.delete(part.id)
|
||||||
|
if (optimistic?.size === 0) load?.optimisticParts.delete(part.messageID)
|
||||||
|
deltaBases.delete(part.id)
|
||||||
|
trackPartChange(part.sessionID, part.messageID, part.id)
|
||||||
|
confirmOptimisticPart(part.sessionID, part.messageID, part)
|
||||||
setData(
|
setData(
|
||||||
"part_text_accum_delta",
|
"part_text_accum_delta",
|
||||||
produce((draft) => void delete draft[part.id]),
|
produce((draft) => void delete draft[part.id]),
|
||||||
@@ -431,10 +822,34 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "message.part.removed": {
|
case "message.part.removed": {
|
||||||
const props = event.properties as { messageID: string; partID: string }
|
const props = event.properties as { sessionID: string; messageID: string; partID: string }
|
||||||
|
// Part removal is event-only on the server, so its tombstone lasts until a later update or eviction.
|
||||||
|
const pending = pendingParts.get(props.sessionID) ?? new Map<string, Set<string>>()
|
||||||
|
const parts = pending.get(props.messageID) ?? new Set<string>()
|
||||||
|
parts.add(props.partID)
|
||||||
|
pending.set(props.messageID, parts)
|
||||||
|
pendingParts.set(props.sessionID, pending)
|
||||||
|
const deltas = messageLoads.get(props.sessionID)?.deltaParts.get(props.messageID)
|
||||||
|
deltas?.delete(props.partID)
|
||||||
|
if (deltas?.size === 0) messageLoads.get(props.sessionID)?.deltaParts.delete(props.messageID)
|
||||||
|
const load = messageLoads.get(props.sessionID)
|
||||||
|
const carried = load?.carriedDeltaParts.get(props.messageID)
|
||||||
|
carried?.delete(props.partID)
|
||||||
|
if (carried?.size === 0) load?.carriedDeltaParts.delete(props.messageID)
|
||||||
|
if (load) {
|
||||||
|
const parts = load.removedParts.get(props.messageID) ?? new Set<string>()
|
||||||
|
parts.add(props.partID)
|
||||||
|
load.removedParts.set(props.messageID, parts)
|
||||||
|
const optimistic = load.optimisticParts.get(props.messageID)
|
||||||
|
optimistic?.delete(props.partID)
|
||||||
|
if (optimistic?.size === 0) load.optimisticParts.delete(props.messageID)
|
||||||
|
}
|
||||||
|
trackPartChange(props.sessionID, props.messageID, props.partID)
|
||||||
|
clearOptimisticPart(props.sessionID, props.messageID, props.partID)
|
||||||
setData(
|
setData(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
delete draft.part_text_accum_delta[props.partID]
|
delete draft.part_text_accum_delta[props.partID]
|
||||||
|
deltaBases.delete(props.partID)
|
||||||
const parts = draft.part[props.messageID]
|
const parts = draft.part[props.messageID]
|
||||||
if (!parts) return
|
if (!parts) return
|
||||||
const result = Binary.search(parts, props.partID, (part) => part.id)
|
const result = Binary.search(parts, props.partID, (part) => part.id)
|
||||||
@@ -445,13 +860,31 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "message.part.delta": {
|
case "message.part.delta": {
|
||||||
const props = event.properties as { messageID: string; partID: string; field: string; delta: string }
|
const props = event.properties as {
|
||||||
|
sessionID: string
|
||||||
|
messageID: string
|
||||||
|
partID: string
|
||||||
|
field: string
|
||||||
|
delta: string
|
||||||
|
}
|
||||||
const parts = data.part[props.messageID]
|
const parts = data.part[props.messageID]
|
||||||
if (!parts) return
|
if (!parts) return
|
||||||
const result = Binary.search(parts, props.partID, (part) => part.id)
|
const result = Binary.search(parts, props.partID, (part) => part.id)
|
||||||
if (!result.found) return
|
if (!result.found) return
|
||||||
|
trackPartChange(props.sessionID, props.messageID, props.partID)
|
||||||
|
const load = messageLoads.get(props.sessionID)
|
||||||
|
if (load) {
|
||||||
|
const parts = load.deltaParts.get(props.messageID) ?? new Set<string>()
|
||||||
|
parts.add(props.partID)
|
||||||
|
load.deltaParts.set(props.messageID, parts)
|
||||||
|
const carried = load.carriedDeltaParts.get(props.messageID)
|
||||||
|
carried?.delete(props.partID)
|
||||||
|
if (carried?.size === 0) load.carriedDeltaParts.delete(props.messageID)
|
||||||
|
}
|
||||||
const field = props.field as keyof (typeof parts)[number]
|
const field = props.field as keyof (typeof parts)[number]
|
||||||
const current = parts[result.index]?.[field]
|
const current = parts[result.index]?.[field]
|
||||||
|
if (!deltaBases.has(props.partID) && typeof current === "string")
|
||||||
|
deltaBases.set(props.partID, { base: current, sessionID: props.sessionID })
|
||||||
setData(
|
setData(
|
||||||
"part_text_accum_delta",
|
"part_text_accum_delta",
|
||||||
props.partID,
|
props.partID,
|
||||||
@@ -559,32 +992,70 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
},
|
},
|
||||||
optimistic: {
|
optimistic: {
|
||||||
add(input: { sessionID: string; message: Message; parts: Part[] }) {
|
add(input: { sessionID: string; message: Message; parts: Part[] }) {
|
||||||
|
const parts = input.parts
|
||||||
|
.filter((part) => !!part?.id && !SKIP_PARTS.has(part.type))
|
||||||
|
.sort((a, b) => cmp(a.id, b.id))
|
||||||
|
const load = messageLoads.get(input.sessionID)
|
||||||
|
if (load?.clearedMessageParts.has(input.message.id)) {
|
||||||
|
const touched = load.touchedParts.get(input.message.id) ?? new Set<string>()
|
||||||
|
parts.forEach((part) => touched.add(part.id))
|
||||||
|
load.touchedParts.set(input.message.id, touched)
|
||||||
|
}
|
||||||
|
if (load) {
|
||||||
|
load.removedMessages.delete(input.message.id)
|
||||||
|
load.optimisticParts.set(input.message.id, new Set(parts.map((part) => part.id)))
|
||||||
|
}
|
||||||
const items = optimistic.get(input.sessionID)
|
const items = optimistic.get(input.sessionID)
|
||||||
if (items) items.set(input.message.id, input)
|
const removedMessagesForSession = removedMessages.get(input.sessionID)
|
||||||
if (!items) optimistic.set(input.sessionID, new Map([[input.message.id, input]]))
|
removedMessagesForSession?.delete(input.message.id)
|
||||||
|
if (removedMessagesForSession?.size === 0) removedMessages.delete(input.sessionID)
|
||||||
|
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
|
||||||
|
if (!items)
|
||||||
|
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
|
||||||
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]))
|
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]))
|
||||||
setData(
|
setData(
|
||||||
"part",
|
"part_text_accum_delta",
|
||||||
input.message.id,
|
produce((draft) => {
|
||||||
input.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
|
for (const part of [...(data.part[input.message.id] ?? []), ...parts]) {
|
||||||
|
delete draft[part.id]
|
||||||
|
deltaBases.delete(part.id)
|
||||||
|
}
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
setData("part", input.message.id, parts)
|
||||||
},
|
},
|
||||||
remove(input: { sessionID: string; messageID: string }) {
|
remove(input: { sessionID: string; messageID: string }) {
|
||||||
|
const item = optimistic.get(input.sessionID)?.get(input.messageID)
|
||||||
|
if (!item) return
|
||||||
|
messageLoads.get(input.sessionID)?.optimisticParts.delete(input.messageID)
|
||||||
clearOptimistic(input.sessionID, input.messageID)
|
clearOptimistic(input.sessionID, input.messageID)
|
||||||
|
if (item.confirmedMessage) {
|
||||||
|
const partIDs = new Set(item.parts.map((part) => part.id))
|
||||||
|
setData(
|
||||||
|
produce((draft) => {
|
||||||
|
for (const part of item.parts) {
|
||||||
|
delete draft.part_text_accum_delta[part.id]
|
||||||
|
deltaBases.delete(part.id)
|
||||||
|
}
|
||||||
|
const parts = draft.part[input.messageID]
|
||||||
|
if (!parts) return
|
||||||
|
draft.part[input.messageID] = parts.filter((part) => !partIDs.has(part.id))
|
||||||
|
if (draft.part[input.messageID]?.length === 0) delete draft.part[input.messageID]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
|
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
|
||||||
setData(
|
setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
|
||||||
"part",
|
|
||||||
produce((draft) => void delete draft[input.messageID]),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
diff(sessionID: string, options?: { force?: boolean }) {
|
diff(sessionID: string, options?: { force?: boolean }) {
|
||||||
touch(sessionID)
|
touch(sessionID)
|
||||||
if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve()
|
if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve()
|
||||||
return runInflight(inflightDiff, sessionID, () => {
|
return runInflight(inflightDiff, sessionID, () => {
|
||||||
const generation = generations.get(sessionID) ?? 0
|
const active = generation(sessionID)
|
||||||
return retry(() => client.session.diff({ sessionID })).then((result) => {
|
return retry(() => client.session.diff({ sessionID })).then((result) => {
|
||||||
if ((generations.get(sessionID) ?? 0) !== generation) return
|
if (generations.get(sessionID) !== active) return
|
||||||
setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" }))
|
setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" }))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -593,9 +1064,9 @@ export function createServerSession(client: OpencodeClient) {
|
|||||||
touch(sessionID)
|
touch(sessionID)
|
||||||
if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve()
|
if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve()
|
||||||
return runInflight(inflightTodo, sessionID, () => {
|
return runInflight(inflightTodo, sessionID, () => {
|
||||||
const generation = generations.get(sessionID) ?? 0
|
const active = generation(sessionID)
|
||||||
return retry(() => client.session.todo({ sessionID })).then((result) => {
|
return retry(() => client.session.todo({ sessionID })).then((result) => {
|
||||||
if ((generations.get(sessionID) ?? 0) !== generation) return
|
if (generations.get(sessionID) !== active) return
|
||||||
setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
|
setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse } from "@opencode-ai/sdk/v2/client"
|
import type {
|
||||||
|
Config,
|
||||||
|
McpResource,
|
||||||
|
OpencodeClient,
|
||||||
|
Path,
|
||||||
|
Project,
|
||||||
|
ProviderAuthResponse,
|
||||||
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
|
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
|
||||||
@@ -15,6 +22,7 @@ import {
|
|||||||
loadPathQuery,
|
loadPathQuery,
|
||||||
loadProjectsQuery,
|
loadProjectsQuery,
|
||||||
loadProvidersQuery,
|
loadProvidersQuery,
|
||||||
|
loadReferencesQuery,
|
||||||
} from "./global-sync/bootstrap"
|
} from "./global-sync/bootstrap"
|
||||||
import { createChildStoreManager } from "./global-sync/child-store"
|
import { createChildStoreManager } from "./global-sync/child-store"
|
||||||
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
||||||
@@ -56,6 +64,13 @@ export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: Opencod
|
|||||||
queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}),
|
queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
||||||
|
queryOptions<Record<string, McpResource>>({
|
||||||
|
queryKey: [scope, directory, "mcpResources"] as const,
|
||||||
|
queryFn: () => sdk.experimental.resource.list().then((r) => r.data ?? {}),
|
||||||
|
placeholderData: {},
|
||||||
|
})
|
||||||
|
|
||||||
export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [scope, directory, "lsp"] as const,
|
queryKey: [scope, directory, "lsp"] as const,
|
||||||
@@ -75,7 +90,9 @@ function makeQueryOptionsApi(
|
|||||||
path: (directory: PathKey | null) =>
|
path: (directory: PathKey | null) =>
|
||||||
loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
|
loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
|
||||||
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)),
|
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)),
|
||||||
|
references: (directory: PathKey) => loadReferencesQuery(scope, directory, sdkFor(directory)),
|
||||||
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)),
|
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)),
|
||||||
|
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, sdkFor(directory)),
|
||||||
lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
|
lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
|
||||||
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
|
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
|
||||||
}
|
}
|
||||||
@@ -396,6 +413,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
loadLsp: () => {
|
loadLsp: () => {
|
||||||
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
|
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
|
||||||
},
|
},
|
||||||
|
loadReferences: () => {
|
||||||
|
void queryClient.fetchQuery(queryOptionsApi.references(key))
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -484,6 +504,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
},
|
},
|
||||||
refresh: async () => {
|
refresh: async () => {
|
||||||
await queryClient.refetchQueries(queryOptionsApi.mcp(key))
|
await queryClient.refetchQueries(queryOptionsApi.mcp(key))
|
||||||
|
await queryClient.refetchQueries(queryOptionsApi.mcpResources(key))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
const fallback = server.key
|
const fallback = server.key
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
{
|
{
|
||||||
...Persist.global("tabs"),
|
...Persist.window("tabs"),
|
||||||
migrate: (value: unknown) => {
|
migrate: (value: unknown) => {
|
||||||
if (!Array.isArray(value)) return value
|
if (!Array.isArray(value)) return value
|
||||||
return value.map((tab) => {
|
return value.map((tab) => {
|
||||||
@@ -62,7 +62,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
},
|
},
|
||||||
createStore<Tab[]>([]),
|
createStore<Tab[]>([]),
|
||||||
)
|
)
|
||||||
const [recent, setRecent, , recentReady] = persisted(Persist.global("tabs.recent"), createStore<RecentTab>({}))
|
const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), createStore<RecentTab>({}))
|
||||||
|
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -138,12 +138,14 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
const next = { type: "session" as const, ...tab }
|
const next = { type: "session" as const, ...tab }
|
||||||
const existing = store.find((item) => tabKey(item) === tabKey(next))
|
const existing = store.find((item) => tabKey(item) === tabKey(next))
|
||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
setStore(
|
void startTransition(() => {
|
||||||
produce((tabs) => {
|
setStore(
|
||||||
if (tabs.some((item) => tabKey(item) === tabKey(next))) return
|
produce((tabs) => {
|
||||||
tabs.push(next)
|
if (tabs.some((item) => tabKey(item) === tabKey(next))) return
|
||||||
}),
|
tabs.push(next)
|
||||||
)
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
return next
|
return next
|
||||||
},
|
},
|
||||||
reorder(keys: string[]) {
|
reorder(keys: string[]) {
|
||||||
@@ -163,27 +165,29 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
},
|
},
|
||||||
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string) {
|
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string) {
|
||||||
const draftID = uuid()
|
const draftID = uuid()
|
||||||
setStore(
|
void startTransition(() => {
|
||||||
produce((tabs) => {
|
setStore(
|
||||||
tabs.push({ type: "draft", draftID, ...draft })
|
produce((tabs) => {
|
||||||
}),
|
tabs.push({ type: "draft", draftID, ...draft })
|
||||||
)
|
}),
|
||||||
navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID))
|
)
|
||||||
|
navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID))
|
||||||
|
})
|
||||||
},
|
},
|
||||||
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
|
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
|
||||||
setStore(
|
void startTransition(() => {
|
||||||
(tab) => tab.type === "draft" && tab.draftID === draftID,
|
setStore(
|
||||||
produce((tab) => Object.assign(tab, draft)),
|
(tab) => tab.type === "draft" && tab.draftID === draftID,
|
||||||
)
|
produce((tab) => Object.assign(tab, draft)),
|
||||||
|
)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
promoteDraft(draftID: string, session: Omit<SessionTab, "type">) {
|
promoteDraft(draftID: string, session: Omit<SessionTab, "type">) {
|
||||||
// We're viewing this draft when /new-session?draftId=… points at it. Promoting
|
// Keep the replacement and navigation atomic so /new-session never renders
|
||||||
// replaces the draft tab with a session tab, so the draft route would stop resolving
|
// after its backing draft tab has been removed from the store.
|
||||||
// and fall back home. Navigate to the new session first so we leave /new-session
|
|
||||||
// before the draft is removed from the store.
|
|
||||||
const active = location.pathname === "/new-session" && location.query.draftId === draftID
|
const active = location.pathname === "/new-session" && location.query.draftId === draftID
|
||||||
const next = { type: "session" as const, ...session }
|
const next = { type: "session" as const, ...session }
|
||||||
startTransition(() => {
|
void startTransition(() => {
|
||||||
setStore(
|
setStore(
|
||||||
produce((tabs) => {
|
produce((tabs) => {
|
||||||
const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID)
|
const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID)
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { DESKTOP_MENU } from "./desktop-menu"
|
||||||
|
|
||||||
|
describe("desktop menu", () => {
|
||||||
|
test("exports logs through the desktop command registry", () => {
|
||||||
|
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
|
||||||
|
(item) => item.type === "item" && item.label === "Export Logs...",
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(items).toHaveLength(2)
|
||||||
|
expect(items.every((item) => item.type === "item" && item.command === "logs.export" && !item.action)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -388,6 +388,20 @@ export const dict = {
|
|||||||
"wsl.onboarding.chooseDistroFirst": "Choose a distro first.",
|
"wsl.onboarding.chooseDistroFirst": "Choose a distro first.",
|
||||||
"wsl.onboarding.loadFailed": "Failed to load WSL state.",
|
"wsl.onboarding.loadFailed": "Failed to load WSL state.",
|
||||||
"wsl.onboarding.loading": "Loading...",
|
"wsl.onboarding.loading": "Loading...",
|
||||||
|
"wsl.onboarding.installedDistros": "Installed distros",
|
||||||
|
"wsl.onboarding.checkAgain": "Check again",
|
||||||
|
"wsl.onboarding.distroStatus.ready": "Ready",
|
||||||
|
"wsl.onboarding.distroStatus.checking": "Checking...",
|
||||||
|
"wsl.onboarding.distroStatus.opencodeMissing": "OpenCode not installed",
|
||||||
|
"wsl.onboarding.distroStatus.missingTools": "Missing bash, curl",
|
||||||
|
"wsl.onboarding.distroStatus.unsupported": "Unsupported · Use WSL 2",
|
||||||
|
"wsl.onboarding.needAnotherDistro": "Need another distro?",
|
||||||
|
"wsl.onboarding.needAnotherDistroHint": "Install a Linux distribution from the WSL catalog",
|
||||||
|
"wsl.onboarding.wslNotInstalled.title": "WSL not installed",
|
||||||
|
"wsl.onboarding.wslNotInstalled.description":
|
||||||
|
"WSL (Windows Subsystem for Linux) is required before OpenCode can add a WSL server",
|
||||||
|
"wsl.onboarding.wslUnavailable.title": "WSL unavailable",
|
||||||
|
"wsl.onboarding.wslUnavailable.description": "OpenCode could not verify WSL on this machine.",
|
||||||
"wsl.onboarding.installWsl": "Install WSL",
|
"wsl.onboarding.installWsl": "Install WSL",
|
||||||
"wsl.onboarding.windowsRestartRequired": "Restart Windows to finish installing WSL, then reopen OpenCode.",
|
"wsl.onboarding.windowsRestartRequired": "Restart Windows to finish installing WSL, then reopen OpenCode.",
|
||||||
"wsl.onboarding.next": "Next",
|
"wsl.onboarding.next": "Next",
|
||||||
@@ -397,6 +411,7 @@ export const dict = {
|
|||||||
"wsl.onboarding.install": "Install",
|
"wsl.onboarding.install": "Install",
|
||||||
"wsl.onboarding.installing": "Installing...",
|
"wsl.onboarding.installing": "Installing...",
|
||||||
"wsl.onboarding.installDistro": "Install distro",
|
"wsl.onboarding.installDistro": "Install distro",
|
||||||
|
"wsl.onboarding.searchDistros": "Search distros",
|
||||||
"wsl.onboarding.wsl2Required": "WSL 2 is required.",
|
"wsl.onboarding.wsl2Required": "WSL 2 is required.",
|
||||||
"wsl.onboarding.toolsRequired": "This distro needs bash and curl.",
|
"wsl.onboarding.toolsRequired": "This distro needs bash and curl.",
|
||||||
"wsl.onboarding.openTerminal": "Open terminal",
|
"wsl.onboarding.openTerminal": "Open terminal",
|
||||||
@@ -604,7 +619,12 @@ export const dict = {
|
|||||||
"session.tab.session": "Session",
|
"session.tab.session": "Session",
|
||||||
"session.tab.review": "Review",
|
"session.tab.review": "Review",
|
||||||
"session.tab.context": "Context",
|
"session.tab.context": "Context",
|
||||||
|
"session.tab.unknown": "Unknown Session",
|
||||||
"session.panel.reviewAndFiles": "Review and files",
|
"session.panel.reviewAndFiles": "Review and files",
|
||||||
|
"session.error.notFound": "This session cannot be found",
|
||||||
|
"session.error.notFound.description": "This tab points to a session that no longer exists on this server.",
|
||||||
|
"session.error.notFound.closeTab": "Close Tab",
|
||||||
|
"session.error.serverConnection": "Can't connect to this server",
|
||||||
"session.review.filesChanged": "{{count}} Files Changed",
|
"session.review.filesChanged": "{{count}} Files Changed",
|
||||||
"session.review.change.one": "Change",
|
"session.review.change.one": "Change",
|
||||||
"session.review.change.other": "Changes",
|
"session.review.change.other": "Changes",
|
||||||
@@ -637,6 +657,10 @@ export const dict = {
|
|||||||
"session.todo.expand": "Expand",
|
"session.todo.expand": "Expand",
|
||||||
"session.todo.progress": "{{done}} of {{total}} todos completed",
|
"session.todo.progress": "{{done}} of {{total}} todos completed",
|
||||||
"session.question.progress": "{{current}} of {{total}} questions",
|
"session.question.progress": "{{current}} of {{total}} questions",
|
||||||
|
"session.question.minimize": "Minimize question",
|
||||||
|
"session.question.restore": "Restore question",
|
||||||
|
"session.question.pending.one": "{{count}} pending question",
|
||||||
|
"session.question.pending.other": "{{count}} pending questions",
|
||||||
"session.followupDock.summary.one": "{{count}} queued message",
|
"session.followupDock.summary.one": "{{count}} queued message",
|
||||||
"session.followupDock.summary.other": "{{count}} queued messages",
|
"session.followupDock.summary.other": "{{count}} queued messages",
|
||||||
"session.followupDock.sendNow": "Send now",
|
"session.followupDock.sendNow": "Send now",
|
||||||
|
|||||||
+152
-3
@@ -1,6 +1,7 @@
|
|||||||
@import "@opencode-ai/ui/styles/tailwind";
|
@import "@opencode-ai/ui/styles/tailwind";
|
||||||
@import "@opencode-ai/session-ui/styles";
|
@import "@opencode-ai/session-ui/styles";
|
||||||
@import "@opencode-ai/ui/v2/styles/tailwind.css";
|
@import "@opencode-ai/ui/v2/styles/tailwind.css";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "JetBrainsMono Nerd Font Mono";
|
font-family: "JetBrainsMono Nerd Font Mono";
|
||||||
@@ -107,6 +108,45 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-session-group-header::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -12px;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 12px;
|
||||||
|
background: var(--v2-background-bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-session-group-header::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 16px;
|
||||||
|
pointer-events: none;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
var(--v2-background-bg-base) 0%,
|
||||||
|
color-mix(in srgb, var(--v2-background-bg-base) 92.0456%, transparent) 7.93%,
|
||||||
|
color-mix(in srgb, var(--v2-background-bg-base) 84.9947%, transparent) 14.14%,
|
||||||
|
color-mix(in srgb, var(--v2-background-bg-base) 78.6813%, transparent) 19%,
|
||||||
|
color-mix(in srgb, var(--v2-background-bg-base) 72.9394%, transparent) 22.85%,
|
||||||
|
color-mix(in srgb, var(--v2-background-bg-base) 67.6028%, transparent) 26.05%,
|
||||||
|
color-mix(in srgb, var(--v2-background-bg-base) 62.5055%, transparent) 28.95%,
|
||||||
|
color-mix(in srgb, var(--v2-background-bg-base) 57.4815%, transparent) 31.91%,
|
||||||
|
color-mix(in srgb, var(--v2-background-bg-base) 52.3647%, transparent) 35.27%,
|
||||||
|
color-mix(in srgb, var(--v2-background-bg-base) 46.989%, transparent) 39.4%,
|
||||||
|
color-mix(in srgb, var(--v2-background-bg-base) 41.1884%, transparent) 44.65%,
|
||||||
|
color-mix(in srgb, var(--v2-background-bg-base) 34.7969%, transparent) 51.36%,
|
||||||
|
color-mix(in srgb, var(--v2-background-bg-base) 27.6484%, transparent) 59.9%,
|
||||||
|
color-mix(in srgb, var(--v2-background-bg-base) 19.5767%, transparent) 70.62%,
|
||||||
|
color-mix(in srgb, var(--v2-background-bg-base) 10.416%, transparent) 83.87%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
[data-slot="titlebar-update-loader"] {
|
[data-slot="titlebar-update-loader"] {
|
||||||
display: block;
|
display: block;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -132,12 +172,121 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fade-in {
|
@keyframes home-projects-fade-top {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
visibility: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes home-projects-fade-bottom {
|
||||||
|
from {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="home-projects-scroll"] {
|
||||||
|
timeline-scope: --home-projects-scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="model-selector-scroll"] {
|
||||||
|
timeline-scope: --model-selector-scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="manage-models-scroll"] {
|
||||||
|
timeline-scope: --manage-models-scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="home-projects-scroll"]::before,
|
||||||
|
[data-slot="home-projects-scroll"]::after,
|
||||||
|
[data-slot="model-selector-scroll"]::before,
|
||||||
|
[data-slot="model-selector-scroll"]::after,
|
||||||
|
[data-slot="manage-models-scroll"]::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
height: 16px;
|
||||||
|
pointer-events: none;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="home-projects-scroll"]::before {
|
||||||
|
top: 0;
|
||||||
|
background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="model-selector-scroll"]::before {
|
||||||
|
top: 0;
|
||||||
|
background: linear-gradient(to bottom, var(--v2-background-bg-layer-01), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="manage-models-scroll"]::before {
|
||||||
|
top: 0;
|
||||||
|
background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="home-projects-scroll"]::after {
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(to top, var(--v2-background-bg-base), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="model-selector-scroll"]::after {
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(to top, var(--v2-background-bg-layer-01), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports (animation-timeline: --home-projects-scroll) and (timeline-scope: --home-projects-scroll) {
|
||||||
|
[data-slot="home-projects-scroll"] .scroll-view__viewport {
|
||||||
|
scroll-timeline: --home-projects-scroll y;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="home-projects-scroll"]::before {
|
||||||
|
animation: home-projects-fade-top linear both;
|
||||||
|
animation-timeline: --home-projects-scroll;
|
||||||
|
animation-range: 0 0.1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="home-projects-scroll"]::after {
|
||||||
|
animation: home-projects-fade-bottom linear both;
|
||||||
|
animation-timeline: --home-projects-scroll;
|
||||||
|
animation-range: calc(100% - 1.1px) calc(100% - 1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports (animation-timeline: --model-selector-scroll) and (timeline-scope: --model-selector-scroll) {
|
||||||
|
[data-slot="model-selector-scroll"] .scroll-view__viewport {
|
||||||
|
scroll-timeline: --model-selector-scroll y;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="model-selector-scroll"]::before {
|
||||||
|
animation: home-projects-fade-top linear both;
|
||||||
|
animation-timeline: --model-selector-scroll;
|
||||||
|
animation-range: 0 0.1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="model-selector-scroll"]::after {
|
||||||
|
animation: home-projects-fade-bottom linear both;
|
||||||
|
animation-timeline: --model-selector-scroll;
|
||||||
|
animation-range: calc(100% - 1.1px) calc(100% - 1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports (animation-timeline: --manage-models-scroll) and (timeline-scope: --manage-models-scroll) {
|
||||||
|
[data-slot="manage-models-scroll"] .settings-v2-panel {
|
||||||
|
scroll-timeline: --manage-models-scroll y;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="manage-models-scroll"]::before {
|
||||||
|
animation: home-projects-fade-top linear both;
|
||||||
|
animation-timeline: --manage-models-scroll;
|
||||||
|
animation-range: 0 0.1px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -276,7 +276,10 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center bg-background-base font-sans">
|
<div
|
||||||
|
class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center font-sans"
|
||||||
|
data-tauri-drag-region
|
||||||
|
>
|
||||||
<div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8">
|
<div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8">
|
||||||
<Logo class="w-58.5 opacity-12 shrink-0" />
|
<Logo class="w-58.5 opacity-12 shrink-0" />
|
||||||
<div class="flex flex-col items-center gap-2 text-center">
|
<div class="flex flex-col items-center gap-2 text-center">
|
||||||
|
|||||||
+269
-122
@@ -1,5 +1,6 @@
|
|||||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import {
|
import {
|
||||||
|
type ComponentProps,
|
||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
createResource,
|
createResource,
|
||||||
@@ -68,6 +69,10 @@ import { archiveHomeSession } from "./home-session-archive"
|
|||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
|
|
||||||
const HOME_SESSION_LIMIT = 64
|
const HOME_SESSION_LIMIT = 64
|
||||||
|
const HOME_SESSION_HEADER_STICKY_TOP = 12
|
||||||
|
const HOME_SESSION_HEADER_TEXT_HEIGHT = 16
|
||||||
|
const HOME_SESSION_HEADER_FADE_DISTANCE = 16
|
||||||
|
const SHOW_HOME_SESSION_ARCHIVE = false
|
||||||
const HOME_ROW_LAYOUT =
|
const HOME_ROW_LAYOUT =
|
||||||
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
|
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
|
||||||
const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0`
|
const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0`
|
||||||
@@ -132,6 +137,107 @@ function homeSessionSearchKey(record: HomeSessionRecord) {
|
|||||||
return `${pathKey(record.session.directory)}:${record.session.id}`
|
return `${pathKey(record.session.directory)}:${record.session.id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
|
||||||
|
let viewport: HTMLDivElement | undefined
|
||||||
|
let content: HTMLDivElement | undefined
|
||||||
|
let positionFrame: number | undefined
|
||||||
|
let resizeObserver: ResizeObserver | undefined
|
||||||
|
const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>()
|
||||||
|
const headerOffsets = new Map<HomeSessionGroup["id"], number>()
|
||||||
|
const [state, setState] = createStore({
|
||||||
|
titleOpacity: {} as Partial<Record<HomeSessionGroup["id"], number>>,
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const items = groups()
|
||||||
|
const ids = new Set(items.map((group) => group.id))
|
||||||
|
headerRefs.forEach((_, id) => {
|
||||||
|
if (!ids.has(id)) headerRefs.delete(id)
|
||||||
|
})
|
||||||
|
headerOffsets.forEach((_, id) => {
|
||||||
|
if (!ids.has(id)) headerOffsets.delete(id)
|
||||||
|
})
|
||||||
|
if (items.length === 0) {
|
||||||
|
content = undefined
|
||||||
|
bindResizeObserver()
|
||||||
|
}
|
||||||
|
queuePositionUpdate()
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (positionFrame !== undefined) cancelAnimationFrame(positionFrame)
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
function setViewport(el: HTMLDivElement) {
|
||||||
|
viewport = el
|
||||||
|
bindResizeObserver()
|
||||||
|
queuePositionUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setContentRef(el: HTMLDivElement) {
|
||||||
|
content = el
|
||||||
|
bindResizeObserver()
|
||||||
|
queuePositionUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHeaderRef(id: HomeSessionGroup["id"], el: HTMLDivElement) {
|
||||||
|
headerRefs.set(id, el)
|
||||||
|
queuePositionUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
function queuePositionUpdate() {
|
||||||
|
if (typeof requestAnimationFrame === "undefined") {
|
||||||
|
updatePositionCache()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (positionFrame !== undefined) return
|
||||||
|
positionFrame = requestAnimationFrame(() => {
|
||||||
|
positionFrame = undefined
|
||||||
|
updatePositionCache()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePositionCache() {
|
||||||
|
if (!viewport) return
|
||||||
|
groups().forEach((group) => {
|
||||||
|
const el = headerRefs.get(group.id)
|
||||||
|
if (!el) return
|
||||||
|
headerOffsets.set(group.id, el.offsetTop)
|
||||||
|
})
|
||||||
|
update(viewport.scrollTop)
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(scrollTop: number) {
|
||||||
|
const items = groups()
|
||||||
|
items.forEach((group, index) => {
|
||||||
|
const nextOffset = items
|
||||||
|
.slice(index + 1)
|
||||||
|
.map((item) => headerOffsets.get(item.id))
|
||||||
|
.find((offset) => offset !== undefined)
|
||||||
|
const fadeEnd = HOME_SESSION_HEADER_STICKY_TOP + HOME_SESSION_HEADER_TEXT_HEIGHT
|
||||||
|
const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop
|
||||||
|
const opacity =
|
||||||
|
nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE))
|
||||||
|
setState("titleOpacity", group.id, Math.round(opacity * 1000) / 1000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleOpacity(id: HomeSessionGroup["id"]) {
|
||||||
|
return state.titleOpacity[id] ?? 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindResizeObserver() {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
if (typeof ResizeObserver === "undefined") return
|
||||||
|
resizeObserver = new ResizeObserver(() => queuePositionUpdate())
|
||||||
|
if (viewport) resizeObserver.observe(viewport)
|
||||||
|
if (content) resizeObserver.observe(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { setViewport, setContentRef, setHeaderRef, update, titleOpacity }
|
||||||
|
}
|
||||||
|
|
||||||
export function NewHome() {
|
export function NewHome() {
|
||||||
const sync = useServerSync()
|
const sync = useServerSync()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
@@ -222,6 +328,7 @@ export function NewHome() {
|
|||||||
})
|
})
|
||||||
const searchOpen = createMemo(() => state.searchFocused && search().length > 0)
|
const searchOpen = createMemo(() => state.searchFocused && search().length > 0)
|
||||||
const groups = createMemo(() => groupSessions(records(), language))
|
const groups = createMemo(() => groupSessions(records(), language))
|
||||||
|
const sessionHeaderOpacity = useHomeSessionHeaderOpacity(groups)
|
||||||
const prefetched = new Set<string>()
|
const prefetched = new Set<string>()
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -301,6 +408,7 @@ export function NewHome() {
|
|||||||
|
|
||||||
function selectProject(conn: ServerConnection.Any, directory: string) {
|
function selectProject(conn: ServerConnection.Any, directory: string) {
|
||||||
const key = ServerConnection.key(conn)
|
const key = ServerConnection.key(conn)
|
||||||
|
if (global.servers.health[key]?.healthy === false) return
|
||||||
if (
|
if (
|
||||||
!global
|
!global
|
||||||
.ensureServerCtx(conn)
|
.ensureServerCtx(conn)
|
||||||
@@ -341,15 +449,15 @@ export function NewHome() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function unseenCount(conn: ServerConnection.Any, project: LocalProject) {
|
function unseenCount(conn: ServerConnection.Any, project: LocalProject) {
|
||||||
if (ServerConnection.key(conn) !== server.key) return 0
|
const state = notification.ensureServerState(ServerConnection.key(conn))
|
||||||
return directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0)
|
return directories(project).reduce((total, directory) => total + state.project.unseenCount(directory), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearNotifications(conn: ServerConnection.Any, project: LocalProject) {
|
function clearNotifications(conn: ServerConnection.Any, project: LocalProject) {
|
||||||
if (ServerConnection.key(conn) !== server.key) return
|
const state = notification.ensureServerState(ServerConnection.key(conn))
|
||||||
directories(project)
|
directories(project)
|
||||||
.filter((directory) => notification.project.unseenCount(directory) > 0)
|
.filter((directory) => state.project.unseenCount(directory) > 0)
|
||||||
.forEach((directory) => notification.project.markViewed(directory))
|
.forEach((directory) => state.project.markViewed(directory))
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSession(session: Session) {
|
function openSession(session: Session) {
|
||||||
@@ -407,7 +515,7 @@ export function NewHome() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 lg:overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
|
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 lg:overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
|
||||||
<div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 pb-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6 lg:pb-16">
|
<div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6">
|
||||||
<HomeProjectColumn
|
<HomeProjectColumn
|
||||||
projects={projects()}
|
projects={projects()}
|
||||||
selected={selection()}
|
selected={selection()}
|
||||||
@@ -433,7 +541,7 @@ export function NewHome() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
class="min-h-0 min-w-0 flex-1 flex flex-col pt-6 lg:pt-12"
|
class="min-h-0 min-w-0 flex-1 flex flex-col pt-6 lg:pt-12 relative"
|
||||||
aria-label={language.t("sidebar.project.recentSessions")}
|
aria-label={language.t("sidebar.project.recentSessions")}
|
||||||
>
|
>
|
||||||
<HomeSessionSearch
|
<HomeSessionSearch
|
||||||
@@ -454,7 +562,25 @@ export function NewHome() {
|
|||||||
onClose={closeSearch}
|
onClose={closeSearch}
|
||||||
onSelect={selectSearchSession}
|
onSelect={selectSearchSession}
|
||||||
/>
|
/>
|
||||||
<ScrollView class="mt-3 min-h-0 flex-1">
|
<ScrollView
|
||||||
|
class="mt-3 -mr-3 min-h-0 flex-1 relative"
|
||||||
|
viewportRef={sessionHeaderOpacity.setViewport}
|
||||||
|
onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)}
|
||||||
|
>
|
||||||
|
<Show when={groups().length > 0 && newSessionProject()}>
|
||||||
|
<div class="pointer-events-none absolute top-3 right-3 z-20 flex">
|
||||||
|
<ButtonV2
|
||||||
|
data-action="home-new-session"
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="normal"
|
||||||
|
icon="edit"
|
||||||
|
class="pointer-events-auto h-7 px-2 [font-weight:530]"
|
||||||
|
onClick={openNewSession}
|
||||||
|
>
|
||||||
|
{language.t("command.session.new")}
|
||||||
|
</ButtonV2>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={!sessionLoad.isLoading}
|
when={!sessionLoad.isLoading}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -467,15 +593,19 @@ export function NewHome() {
|
|||||||
when={groups().length > 0}
|
when={groups().length > 0}
|
||||||
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
|
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
|
||||||
>
|
>
|
||||||
<div class="pt-3 flex flex-col gap-6">
|
<div ref={sessionHeaderOpacity.setContentRef} class="flex flex-col pt-3 pr-3 pb-16">
|
||||||
<For each={groups()}>
|
<For each={groups()}>
|
||||||
{(group, index) => (
|
{(group, index) => (
|
||||||
<div class="flex min-w-0 flex-col gap-4">
|
<>
|
||||||
<HomeSessionGroupHeader
|
<HomeSessionGroupHeader
|
||||||
title={group.title}
|
title={group.title}
|
||||||
onNewSession={index() === 0 && newSessionProject() ? openNewSession : undefined}
|
titleOpacity={sessionHeaderOpacity.titleOpacity(group.id)}
|
||||||
|
ref={(el) => sessionHeaderOpacity.setHeaderRef(group.id, el)}
|
||||||
|
elevated={index() === 0}
|
||||||
/>
|
/>
|
||||||
<div class="flex min-w-0 flex-col gap-px">
|
<div
|
||||||
|
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === groups().length - 1 ? "" : "mb-6"}`}
|
||||||
|
>
|
||||||
<For each={group.sessions}>
|
<For each={group.sessions}>
|
||||||
{(record) => (
|
{(record) => (
|
||||||
<HomeSessionRow
|
<HomeSessionRow
|
||||||
@@ -489,7 +619,7 @@ export function NewHome() {
|
|||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
@@ -538,10 +668,10 @@ function HomeProjectColumn(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
class="mt-6 flex min-w-0 flex-col gap-4 lg:mt-14 lg:pt-[52px]"
|
class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:mt-14 lg:pt-[52px]"
|
||||||
aria-label={props.language.t("home.projects")}
|
aria-label={props.language.t("home.projects")}
|
||||||
>
|
>
|
||||||
<div class="flex h-7 min-w-0 items-center justify-between pl-1.5">
|
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
|
||||||
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
|
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
|
||||||
<Show when={global.servers.list().length === 1}>
|
<Show when={global.servers.list().length === 1}>
|
||||||
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
|
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
|
||||||
@@ -558,42 +688,51 @@ function HomeProjectColumn(props: {
|
|||||||
</TooltipV2>
|
</TooltipV2>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show
|
<ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
|
||||||
when={global.servers.list().length > 1}
|
<Show
|
||||||
fallback={<HomeProjectList {...props} server={global.servers.list()[0]!} />}
|
when={global.servers.list().length > 1}
|
||||||
>
|
fallback={
|
||||||
<For each={global.servers.list()}>
|
<div class="pr-3">
|
||||||
{(item) => {
|
<HomeProjectList {...props} server={global.servers.list()[0]!} />
|
||||||
const key = ServerConnection.key(item)
|
</div>
|
||||||
const healthy = () => !!global.servers.health[key]?.healthy
|
}
|
||||||
const serverCtx = global.ensureServerCtx(item)
|
>
|
||||||
const collapsed = () => !!state().collapsed[key]
|
<div class="flex min-w-0 flex-col gap-4 pr-3">
|
||||||
return (
|
<For each={global.servers.list()}>
|
||||||
<div class="flex max-h-[min(572px,calc(100vh_-_300px))] min-w-0 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
{(item) => {
|
||||||
<HomeServerRow
|
const key = ServerConnection.key(item)
|
||||||
server={item}
|
const healthy = () => !!global.servers.health[key]?.healthy
|
||||||
selected={props.selected.server === key && !props.selected.directory}
|
const serverCtx = global.ensureServerCtx(item)
|
||||||
healthy={healthy()}
|
const projects = () => serverCtx.projects.list()
|
||||||
collapsed={collapsed()}
|
const hasProjects = () => projects().length > 0
|
||||||
health={global.servers.health[key]}
|
const collapsed = () => !!state().collapsed[key]
|
||||||
controller={controller}
|
return (
|
||||||
focusServer={props.focusServer}
|
<div class="flex min-w-0 flex-col gap-1">
|
||||||
chooseProject={props.chooseProject}
|
<HomeServerRow
|
||||||
openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
|
server={item}
|
||||||
toggleCollapsed={() => setState("collapsed", key, !state().collapsed[key])}
|
selected={props.selected.server === key && !props.selected.directory}
|
||||||
language={props.language}
|
collapsed={collapsed()}
|
||||||
/>
|
health={global.servers.health[key]}
|
||||||
<Show when={healthy() && !collapsed()}>
|
controller={controller}
|
||||||
<div class="mx-3 h-px bg-v2-border-border-base" />
|
focusServer={props.focusServer}
|
||||||
<HomeProjectList {...props} server={item} projects={serverCtx.projects.list()} />
|
chooseProject={props.chooseProject}
|
||||||
</Show>
|
openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
|
||||||
</div>
|
toggleCollapsed={() => setState("collapsed", key, !state().collapsed[key])}
|
||||||
)
|
language={props.language}
|
||||||
}}
|
/>
|
||||||
</For>
|
<Show when={healthy() && hasProjects() && !collapsed()}>
|
||||||
</Show>
|
<div class="mx-3 h-px bg-v2-border-border-base" />
|
||||||
|
<HomeProjectList {...props} server={item} projects={projects()} />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</ScrollView>
|
||||||
<HomeUtilityNav
|
<HomeUtilityNav
|
||||||
class="mt-4 hidden lg:flex"
|
class="mb-8 mt-4 hidden shrink-0 lg:flex"
|
||||||
openSettings={props.openSettings}
|
openSettings={props.openSettings}
|
||||||
openHelp={props.openHelp}
|
openHelp={props.openHelp}
|
||||||
language={props.language}
|
language={props.language}
|
||||||
@@ -633,7 +772,6 @@ function HomeUtilityNav(props: {
|
|||||||
function HomeServerRow(props: {
|
function HomeServerRow(props: {
|
||||||
server: ServerConnection.Any
|
server: ServerConnection.Any
|
||||||
selected: boolean
|
selected: boolean
|
||||||
healthy: boolean
|
|
||||||
collapsed: boolean
|
collapsed: boolean
|
||||||
health: ServerHealth | undefined
|
health: ServerHealth | undefined
|
||||||
controller: ReturnType<typeof useServerManagementController>
|
controller: ReturnType<typeof useServerManagementController>
|
||||||
@@ -643,39 +781,46 @@ function HomeServerRow(props: {
|
|||||||
toggleCollapsed: () => void
|
toggleCollapsed: () => void
|
||||||
language: ReturnType<typeof useLanguage>
|
language: ReturnType<typeof useLanguage>
|
||||||
}) {
|
}) {
|
||||||
|
const global = useGlobal()
|
||||||
const [state, setState] = createStore({ menuOpen: false })
|
const [state, setState] = createStore({ menuOpen: false })
|
||||||
|
const healthy = () => !!props.health?.healthy
|
||||||
|
const canToggle = () => healthy() && global.ensureServerCtx(props.server).projects.list().length > 0
|
||||||
return (
|
return (
|
||||||
<div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
|
<div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
|
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
|
||||||
data-selected={props.selected ? "" : undefined}
|
data-selected={props.selected ? "" : undefined}
|
||||||
disabled={!props.healthy}
|
disabled={!healthy()}
|
||||||
onClick={() => props.focusServer(props.server)}
|
onClick={() => props.focusServer(props.server)}
|
||||||
>
|
>
|
||||||
<Show when={props.healthy}>
|
<span
|
||||||
<span
|
data-action="home-server-collapse"
|
||||||
data-action="home-server-collapse"
|
class="inline-flex -ml-0.5 -mr-1.5 size-5 shrink-0 items-center justify-center rounded-[4px] text-v2-icon-icon-muted"
|
||||||
class="inline-flex -ml-0.5 -mr-1.5 size-5 shrink-0 items-center justify-center rounded-[4px] text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
|
classList={{
|
||||||
aria-label={
|
"hover:bg-v2-overlay-simple-overlay-hover": canToggle(),
|
||||||
props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
|
"cursor-default opacity-40": !canToggle(),
|
||||||
}
|
}}
|
||||||
aria-expanded={!props.collapsed}
|
aria-label={
|
||||||
onClick={(event) => {
|
props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
|
||||||
event.preventDefault()
|
}
|
||||||
event.stopPropagation()
|
aria-disabled={!canToggle()}
|
||||||
props.toggleCollapsed()
|
aria-expanded={canToggle() ? !props.collapsed : undefined}
|
||||||
}}
|
onClick={(event) => {
|
||||||
onPointerDown={(event) => event.preventDefault()}
|
event.preventDefault()
|
||||||
>
|
event.stopPropagation()
|
||||||
<IconV2
|
if (!canToggle()) return
|
||||||
name="chevron-down"
|
props.toggleCollapsed()
|
||||||
size="small"
|
}}
|
||||||
class="transition-transform duration-150 ease-in-out"
|
onPointerDown={(event) => event.preventDefault()}
|
||||||
style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }}
|
>
|
||||||
/>
|
<IconV2
|
||||||
</span>
|
name="chevron-down"
|
||||||
</Show>
|
size="small"
|
||||||
|
class="transition-transform duration-150 ease-in-out"
|
||||||
|
style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
<div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
|
<div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
|
||||||
<ServerHealthIndicator health={props.health} />
|
<ServerHealthIndicator health={props.health} />
|
||||||
</div>
|
</div>
|
||||||
@@ -766,15 +911,18 @@ function HomeProjectRow(props: {
|
|||||||
clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
|
clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
|
||||||
language: ReturnType<typeof useLanguage>
|
language: ReturnType<typeof useLanguage>
|
||||||
}) {
|
}) {
|
||||||
|
const global = useGlobal()
|
||||||
|
const serverUnreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
|
||||||
const [state, setState] = createStore({ menuOpen: false })
|
const [state, setState] = createStore({ menuOpen: false })
|
||||||
return (
|
return (
|
||||||
<div class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]">
|
<div class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-component="home-project-row"
|
data-component="home-project-row"
|
||||||
class={`${HOME_PROJECT_NAV_ROW} pr-16`}
|
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
|
||||||
data-selected={props.selected ? "" : undefined}
|
data-selected={props.selected ? "" : undefined}
|
||||||
aria-current={props.selected ? "page" : undefined}
|
aria-current={props.selected ? "page" : undefined}
|
||||||
|
disabled={serverUnreachable()}
|
||||||
onClick={() => props.selectProject(props.server, props.project.worktree)}
|
onClick={() => props.selectProject(props.server, props.project.worktree)}
|
||||||
>
|
>
|
||||||
<HomeProjectAvatar project={props.project} />
|
<HomeProjectAvatar project={props.project} />
|
||||||
@@ -849,6 +997,7 @@ function HomeSessionLeading(props: {
|
|||||||
session: Session
|
session: Session
|
||||||
server: ServerConnection.Key
|
server: ServerConnection.Key
|
||||||
activeServer: boolean
|
activeServer: boolean
|
||||||
|
revealProjectOnHover: boolean
|
||||||
}) {
|
}) {
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const hasOpenTab = createMemo(() => sessionHasOpenTab(tabs.store, props.server, props.session))
|
const hasOpenTab = createMemo(() => sessionHasOpenTab(tabs.store, props.server, props.session))
|
||||||
@@ -857,8 +1006,8 @@ function HomeSessionLeading(props: {
|
|||||||
<Show when={hasOpenTab()}>
|
<Show when={hasOpenTab()}>
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="pointer-events-none absolute top-1/2 h-[7px] w-[3px] -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
|
class="pointer-events-none absolute top-1/2 h-3 w-0.5 -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
|
||||||
style={{ right: "calc(100% + 5px)" }}
|
style={{ right: "calc(100% + 4px)" }}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<SessionTabAvatar
|
<SessionTabAvatar
|
||||||
@@ -866,6 +1015,7 @@ function HomeSessionLeading(props: {
|
|||||||
directory={props.session.directory}
|
directory={props.session.directory}
|
||||||
sessionId={props.session.id}
|
sessionId={props.session.id}
|
||||||
activeServer={props.activeServer}
|
activeServer={props.activeServer}
|
||||||
|
revealProjectOnHover={props.revealProjectOnHover}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -956,7 +1106,7 @@ function HomeSessionSearch(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div ref={root} data-component="home-session-search" class="relative z-10 w-full">
|
<div ref={root} data-component="home-session-search" class="relative z-30 w-full">
|
||||||
<Show when={props.open}>
|
<Show when={props.open}>
|
||||||
<div
|
<div
|
||||||
data-component="home-session-search-panel"
|
data-component="home-session-search-panel"
|
||||||
@@ -964,7 +1114,7 @@ function HomeSessionSearch(props: {
|
|||||||
style={{
|
style={{
|
||||||
top: "-6px",
|
top: "-6px",
|
||||||
left: "-6px",
|
left: "-6px",
|
||||||
width: "calc(100% + 14px)",
|
width: "calc(100% + 12px)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex flex-col pt-9">
|
<div class="flex flex-col pt-9">
|
||||||
@@ -1013,13 +1163,7 @@ function HomeSessionSearch(props: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<label
|
<label class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] bg-v2-background-bg-layer-02/60 py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out hover:bg-v2-background-bg-layer-02">
|
||||||
class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] bg-v2-background-bg-layer-02 py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out"
|
|
||||||
classList={{
|
|
||||||
"focus-within:shadow-[0_0_0_0.5px_var(--v2-border-border-focus),var(--v2-elevation-raised)]": !props.open,
|
|
||||||
"shadow-[0_0_0_0.5px_var(--v2-border-border-focus)]": props.open,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconV2 name="magnifying-glass" />
|
<IconV2 name="magnifying-glass" />
|
||||||
<input
|
<input
|
||||||
ref={input}
|
ref={input}
|
||||||
@@ -1105,6 +1249,7 @@ function HomeSessionSearchResultRow(props: {
|
|||||||
classList={{
|
classList={{
|
||||||
[HOME_SEARCH_RESULT_ROW]: true,
|
[HOME_SEARCH_RESULT_ROW]: true,
|
||||||
"bg-v2-overlay-simple-overlay-hover": props.selected,
|
"bg-v2-overlay-simple-overlay-hover": props.selected,
|
||||||
|
group: !!showProjectName(),
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => props.onHighlight()}
|
onMouseEnter={() => props.onHighlight()}
|
||||||
onClick={() => props.onSelect(props.record.session)}
|
onClick={() => props.onSelect(props.record.session)}
|
||||||
@@ -1114,6 +1259,7 @@ function HomeSessionSearchResultRow(props: {
|
|||||||
session={props.record.session}
|
session={props.record.session}
|
||||||
server={props.server}
|
server={props.server}
|
||||||
activeServer={props.activeServer}
|
activeServer={props.activeServer}
|
||||||
|
revealProjectOnHover={!!showProjectName()}
|
||||||
/>
|
/>
|
||||||
<div class="flex min-w-0 flex-1 items-center gap-1.5">
|
<div class="flex min-w-0 flex-1 items-center gap-1.5">
|
||||||
<span
|
<span
|
||||||
@@ -1129,25 +1275,20 @@ function HomeSessionSearchResultRow(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) {
|
function HomeSessionGroupHeader(props: {
|
||||||
const language = useLanguage()
|
title: string
|
||||||
|
titleOpacity: number
|
||||||
|
ref: ComponentProps<"div">["ref"]
|
||||||
|
elevated?: boolean
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div class="flex h-7 min-w-0 items-center justify-between pl-[18px]">
|
<div
|
||||||
<div class={HOME_SECTION_LABEL}>{props.title}</div>
|
ref={props.ref}
|
||||||
<Show when={props.onNewSession}>
|
class={`pointer-events-none sticky top-3 flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`}
|
||||||
{(onNewSession) => (
|
>
|
||||||
<ButtonV2
|
<div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}>
|
||||||
data-action="home-new-session"
|
{props.title}
|
||||||
variant="ghost-muted"
|
</div>
|
||||||
size="normal"
|
|
||||||
icon="edit"
|
|
||||||
class="h-7 px-2 [font-weight:530]"
|
|
||||||
onClick={onNewSession()}
|
|
||||||
>
|
|
||||||
{language.t("command.session.new")}
|
|
||||||
</ButtonV2>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1165,7 +1306,10 @@ function HomeSessionRow(props: {
|
|||||||
const showProjectName = () => props.showProjectName && props.record.projectName
|
const showProjectName = () => props.showProjectName && props.record.projectName
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]">
|
<div
|
||||||
|
class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]"
|
||||||
|
classList={{ group: !!showProjectName() }}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-component="home-session-row"
|
data-component="home-session-row"
|
||||||
@@ -1177,6 +1321,7 @@ function HomeSessionRow(props: {
|
|||||||
session={props.record.session}
|
session={props.record.session}
|
||||||
server={props.server}
|
server={props.server}
|
||||||
activeServer={props.activeServer}
|
activeServer={props.activeServer}
|
||||||
|
revealProjectOnHover={!!showProjectName()}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
|
class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
|
||||||
@@ -1189,22 +1334,24 @@ function HomeSessionRow(props: {
|
|||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
<div class="hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/session:opacity-100 focus-within:opacity-100">
|
<Show when={SHOW_HOME_SESSION_ARCHIVE}>
|
||||||
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={language.t("common.archive")}>
|
<div class="hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/session:opacity-100 focus-within:opacity-100">
|
||||||
<IconButtonV2
|
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={language.t("common.archive")}>
|
||||||
data-action="home-session-archive"
|
<IconButtonV2
|
||||||
variant="ghost-muted"
|
data-action="home-session-archive"
|
||||||
size="large"
|
variant="ghost-muted"
|
||||||
icon={<IconV2 name="archive" />}
|
size="large"
|
||||||
aria-label={language.t("common.archive")}
|
icon={<IconV2 name="archive" />}
|
||||||
onClick={(event) => {
|
aria-label={language.t("common.archive")}
|
||||||
event.preventDefault()
|
onClick={(event) => {
|
||||||
event.stopPropagation()
|
event.preventDefault()
|
||||||
void props.archiveSession(props.record.session)
|
event.stopPropagation()
|
||||||
}}
|
void props.archiveSession(props.record.session)
|
||||||
/>
|
}}
|
||||||
</TooltipV2>
|
/>
|
||||||
</div>
|
</TooltipV2>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,18 @@
|
|||||||
import { createEffect, Suspense, type ParentProps } from "solid-js"
|
import { createEffect, Suspense, type ParentProps } from "solid-js"
|
||||||
import { useNavigate, useParams } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { DebugBar } from "@/components/debug-bar"
|
import { DebugBar } from "@/components/debug-bar"
|
||||||
import { HelpButton } from "@/components/help-button"
|
import { HelpButton } from "@/components/help-button"
|
||||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||||
import { useNotification } from "@/context/notification"
|
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { setNavigate } from "@/utils/notification-click"
|
import { setNavigate } from "@/utils/notification-click"
|
||||||
import { setV2Toast, ToastRegion } from "@/utils/toast"
|
import { setV2Toast, ToastRegion } from "@/utils/toast"
|
||||||
|
|
||||||
export default function NewLayout(props: ParentProps) {
|
export default function NewLayout(props: ParentProps) {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const notification = useNotification()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const params = useParams<{ id?: string }>()
|
|
||||||
setNavigate(navigate)
|
setNavigate(navigate)
|
||||||
|
|
||||||
createEffect(() => setV2Toast(true))
|
createEffect(() => setV2Toast(true))
|
||||||
createEffect(() => {
|
|
||||||
if (!notification.ready() || !params.id) return
|
|
||||||
if (notification.session.unseenCount(params.id) === 0) return
|
|
||||||
notification.session.markViewed(params.id)
|
|
||||||
})
|
|
||||||
|
|
||||||
const update: TitlebarUpdate = {
|
const update: TitlebarUpdate = {
|
||||||
version: () => {
|
version: () => {
|
||||||
@@ -44,7 +36,7 @@ export default function NewLayout(props: ParentProps) {
|
|||||||
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
|
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
|
||||||
<Suspense>{props.children}</Suspense>
|
<Suspense>{props.children}</Suspense>
|
||||||
</main>
|
</main>
|
||||||
{import.meta.env.DEV && <DebugBar />}
|
{import.meta.env.DEV && <DebugBar inline />}
|
||||||
<HelpButton />
|
<HelpButton />
|
||||||
<ToastRegion v2 />
|
<ToastRegion v2 />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -943,18 +943,6 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
keybind: "mod+comma",
|
keybind: "mod+comma",
|
||||||
onSelect: () => openSettings(),
|
onSelect: () => openSettings(),
|
||||||
},
|
},
|
||||||
...(platform.platform === "desktop" && platform.exportDebugLogs
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
id: "logs.export",
|
|
||||||
title: "Export logs",
|
|
||||||
category: language.t("command.category.settings"),
|
|
||||||
onSelect: () => {
|
|
||||||
void platform.exportDebugLogs?.()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
{
|
{
|
||||||
id: "session.previous",
|
id: "session.previous",
|
||||||
title: language.t("command.session.previous"),
|
title: language.t("command.session.previous"),
|
||||||
|
|||||||
@@ -11,32 +11,28 @@ export function SessionTabAvatar(props: {
|
|||||||
directory: string
|
directory: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
activeServer: boolean
|
activeServer: boolean
|
||||||
|
revealProjectOnHover?: boolean
|
||||||
}) {
|
}) {
|
||||||
const directory = () => props.directory
|
const directory = () => props.directory
|
||||||
const sessionId = () => props.sessionId
|
const sessionId = () => props.sessionId
|
||||||
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
|
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
|
||||||
|
const projectAvatar = () => (
|
||||||
|
<ProjectAvatar
|
||||||
|
fallback={displayName(props.project ?? { worktree: props.directory })}
|
||||||
|
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
|
||||||
|
variant={getProjectAvatarVariant(props.project?.icon?.color)}
|
||||||
|
unread={state.unread()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<Show
|
<Show when={state.loading()} fallback={projectAvatar()}>
|
||||||
when={state.loading()}
|
|
||||||
fallback={
|
|
||||||
<ProjectAvatar
|
|
||||||
fallback={displayName(props.project ?? { worktree: props.directory })}
|
|
||||||
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
|
|
||||||
variant={getProjectAvatarVariant(props.project?.icon?.color)}
|
|
||||||
unread={state.unread()}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span class="relative block size-4 shrink-0">
|
<span class="relative block size-4 shrink-0">
|
||||||
<SessionProgressIndicatorV2 class="absolute inset-0 group-hover:invisible" />
|
<SessionProgressIndicatorV2
|
||||||
<span class="invisible absolute inset-0 group-hover:visible">
|
class={`absolute inset-0 ${props.revealProjectOnHover === false ? "" : "group-hover:invisible"}`}
|
||||||
<ProjectAvatar
|
/>
|
||||||
fallback={displayName(props.project ?? { worktree: props.directory })}
|
<Show when={props.revealProjectOnHover !== false}>
|
||||||
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
|
<span class="invisible absolute inset-0 group-hover:visible">{projectAvatar()}</span>
|
||||||
variant={getProjectAvatarVariant(props.project?.icon?.color)}
|
</Show>
|
||||||
unread={state.unread()}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
|
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
|
||||||
import {
|
import {
|
||||||
batch,
|
batch,
|
||||||
|
ErrorBoundary,
|
||||||
onCleanup,
|
onCleanup,
|
||||||
Show,
|
Show,
|
||||||
Match,
|
Match,
|
||||||
@@ -10,8 +11,10 @@ import {
|
|||||||
createMemo,
|
createMemo,
|
||||||
createEffect,
|
createEffect,
|
||||||
createComputed,
|
createComputed,
|
||||||
|
createResource,
|
||||||
on,
|
on,
|
||||||
onMount,
|
onMount,
|
||||||
|
type ParentProps,
|
||||||
untrack,
|
untrack,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
@@ -19,29 +22,37 @@ import { createMediaQuery } from "@solid-primitives/media"
|
|||||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
import { debounce } from "@solid-primitives/scheduled"
|
import { debounce } from "@solid-primitives/scheduled"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
|
import { FileProvider, selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||||
import { Select } from "@opencode-ai/ui/select"
|
import { Select } from "@opencode-ai/ui/select"
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
||||||
import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
|
import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
|
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
|
||||||
import { useLocation, useNavigate, useSearchParams } from "@solidjs/router"
|
import { Navigate, useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||||
import { NewSessionView, SessionHeader } from "@/components/session"
|
import { NewSessionView, SessionHeader } from "@/components/session"
|
||||||
import { useComments } from "@/context/comments"
|
import { ErrorPage } from "@/pages/error"
|
||||||
|
import { CommentsProvider, useComments } from "@/context/comments"
|
||||||
|
import { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { usePrompt } from "@/context/prompt"
|
import { ModelsProvider } from "@/context/models"
|
||||||
|
import { useNotification } from "@/context/notification"
|
||||||
|
import { PermissionProvider } from "@/context/permission"
|
||||||
|
import { PromptProvider, usePrompt } from "@/context/prompt"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
import { ServerConnection, serverName, useServer } from "@/context/server"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useTerminal } from "@/context/terminal"
|
import { useTabs } from "@/context/tabs"
|
||||||
|
import { TerminalProvider, useTerminal } from "@/context/terminal"
|
||||||
import { PromptInput } from "@/components/prompt-input"
|
import { PromptInput } from "@/components/prompt-input"
|
||||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||||
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
|
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
|
||||||
@@ -73,8 +84,8 @@ import { Identifier } from "@/utils/id"
|
|||||||
import { diffs as list } from "@/utils/diffs"
|
import { diffs as list } from "@/utils/diffs"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { extractPromptFromParts } from "@/utils/prompt"
|
import { extractPromptFromParts } from "@/utils/prompt"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError, isSessionNotFoundError } from "@/utils/server-errors"
|
||||||
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
import { legacySessionHref, requireServerKey, selectSessionLineage, sessionHref } from "@/utils/session-route"
|
||||||
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
|
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
|
||||||
import { createSessionOwnership } from "./session/session-ownership"
|
import { createSessionOwnership } from "./session/session-ownership"
|
||||||
|
|
||||||
@@ -91,6 +102,15 @@ const sessionViewState = () => ({
|
|||||||
changes: "git" as ChangeMode,
|
changes: "git" as ChangeMode,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function isLocalSessionNotFoundError(error: unknown, sessionID: string) {
|
||||||
|
return error instanceof Error && error.message === `Session not found: ${sessionID}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentSessionNotFoundError(error: unknown, sessionID: string | undefined) {
|
||||||
|
if (!sessionID) return false
|
||||||
|
return isSessionNotFoundError(error, sessionID) || isLocalSessionNotFoundError(error, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
async function runPromptRollbackMutation<T, R>(input: {
|
async function runPromptRollbackMutation<T, R>(input: {
|
||||||
capturePrompt: () => { current: () => T[]; set: (value: T[]) => void; reset: () => void }
|
capturePrompt: () => { current: () => T[]; set: (value: T[]) => void; reset: () => void }
|
||||||
optimistic: (prompt: { set: (value: T[]) => void; reset: () => void }) => void
|
optimistic: (prompt: { set: (value: T[]) => void; reset: () => void }) => void
|
||||||
@@ -114,6 +134,204 @@ async function runPromptRollbackMutation<T, R>(input: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SessionPage() {
|
||||||
|
return (
|
||||||
|
<SessionProviders>
|
||||||
|
<Page />
|
||||||
|
</SessionProviders>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TargetSessionRoute() {
|
||||||
|
const params = useParams<{ serverKey: string; id: string }>()
|
||||||
|
return (
|
||||||
|
<Show when={`${params.serverKey}\0${params.id}`} keyed>
|
||||||
|
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
|
||||||
|
<ResolvedTargetSessionRoute />
|
||||||
|
</SessionRouteErrorBoundary>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionRouteErrorBoundary(
|
||||||
|
props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>,
|
||||||
|
) {
|
||||||
|
const settings = useSettings()
|
||||||
|
return (
|
||||||
|
<ErrorBoundary
|
||||||
|
fallback={(error) =>
|
||||||
|
settings.general.newLayoutDesigns() ? (
|
||||||
|
<SessionRouteFrame padded={props.padded}>
|
||||||
|
<SessionPanelFrame newLayout raised={!!props.sessionID}>
|
||||||
|
<SessionErrorFallback error={error} sessionID={props.sessionID} serverKey={props.serverKey} />
|
||||||
|
</SessionPanelFrame>
|
||||||
|
</SessionRouteFrame>
|
||||||
|
) : (
|
||||||
|
<ErrorPage error={error} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</ErrorBoundary>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionErrorFallback(props: { error: unknown; sessionID?: string; serverKey?: ServerConnection.Key }) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const server = useServer()
|
||||||
|
const tabs = useTabs()
|
||||||
|
const displayServer = createMemo(() => {
|
||||||
|
const key = props.serverKey ?? server.key
|
||||||
|
const conn = server.list.find((item) => ServerConnection.key(item) === key)
|
||||||
|
return conn ? serverName(conn) : key
|
||||||
|
})
|
||||||
|
const closeTab = () => {
|
||||||
|
if (!props.sessionID) return
|
||||||
|
tabs.removeSessionTab({ server: props.serverKey ?? server.key, sessionId: props.sessionID })
|
||||||
|
}
|
||||||
|
if (isCurrentSessionNotFoundError(props.error, props.sessionID)) {
|
||||||
|
return (
|
||||||
|
<div class="flex-1 min-h-0 overflow-hidden">
|
||||||
|
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-4">
|
||||||
|
<div class="flex flex-col items-center gap-2">
|
||||||
|
<div class="text-16-medium text-text max-w-md">{language.t("session.error.notFound")}</div>
|
||||||
|
<div class="text-13-regular text-text-weak max-w-md">
|
||||||
|
{language.t("session.error.notFound.description")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={props.sessionID}>
|
||||||
|
{(sessionID) => (
|
||||||
|
<div class="max-w-full flex flex-col items-center gap-1">
|
||||||
|
<div class="max-w-full text-11-regular text-text-faint break-all">{displayServer()}</div>
|
||||||
|
<code class="max-w-full rounded-[4px] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-text-base break-all bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)]">
|
||||||
|
{sessionID()}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<ButtonV2 variant="neutral" size="normal" icon="xmark-small" onClick={closeTab}>
|
||||||
|
{language.t("session.error.notFound.closeTab")}
|
||||||
|
</ButtonV2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <ErrorPage error={props.error} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResolvedTargetSessionRoute() {
|
||||||
|
const params = useParams<{ serverKey: string; id: string }>()
|
||||||
|
const settings = useSettings()
|
||||||
|
const tabs = useTabs()
|
||||||
|
const sync = useServerSync()
|
||||||
|
const serverKey = createMemo(() => requireServerKey(params.serverKey))
|
||||||
|
const cached = createMemo(() => sync().session.lineage.peek(params.id))
|
||||||
|
const [resolved] = createResource(
|
||||||
|
() => {
|
||||||
|
if (cached()) return
|
||||||
|
return { id: params.id, sync: sync() }
|
||||||
|
},
|
||||||
|
({ id, sync }) => sync.session.lineage.resolve(id),
|
||||||
|
)
|
||||||
|
const current = createMemo(() => selectSessionLineage(params.id, cached(), resolved()))
|
||||||
|
const directory = createMemo(() => current()?.session.directory)
|
||||||
|
const targetDirectory = () => directory()!
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const session = current()
|
||||||
|
if (!session) return
|
||||||
|
tabs.addSessionTab({
|
||||||
|
server: serverKey(),
|
||||||
|
sessionId: session.root.id,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
|
||||||
|
<Show when={directory()}>
|
||||||
|
<Show
|
||||||
|
when={settings.general.newLayoutDesigns()}
|
||||||
|
fallback={<Navigate href={legacySessionHref(directory()!, params.id)} />}
|
||||||
|
>
|
||||||
|
<SDKProvider directory={targetDirectory}>
|
||||||
|
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
|
||||||
|
<TargetSessionPage />
|
||||||
|
</DirectoryDataProvider>
|
||||||
|
</SDKProvider>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</TargetServerScopedProviders>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TargetSessionPage() {
|
||||||
|
const sdk = useSDK()
|
||||||
|
const serverSDK = useServerSDK()
|
||||||
|
return (
|
||||||
|
<Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
|
||||||
|
<SessionPage />
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TargetServerScopedProviders(
|
||||||
|
props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<PermissionProvider directory={props.directory}>
|
||||||
|
<MarkSessionNotificationsViewed sessionID={props.sessionID} />
|
||||||
|
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||||
|
</PermissionProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarkSessionNotificationsViewed(props: { sessionID?: () => string | undefined }) {
|
||||||
|
const notification = useNotification()
|
||||||
|
createEffect(() => {
|
||||||
|
const sessionID = props.sessionID?.()
|
||||||
|
if (!notification.ready() || !sessionID) return
|
||||||
|
if (notification.session.unseenCount(sessionID) === 0) return
|
||||||
|
notification.session.markViewed(sessionID)
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionProviders(props: ParentProps) {
|
||||||
|
return (
|
||||||
|
<TerminalProvider>
|
||||||
|
<FileProvider>
|
||||||
|
<PromptProvider>
|
||||||
|
<CommentsProvider>{props.children}</CommentsProvider>
|
||||||
|
</PromptProvider>
|
||||||
|
</FileProvider>
|
||||||
|
</TerminalProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionRouteFrame(props: ParentProps<{ padded?: boolean }>) {
|
||||||
|
return (
|
||||||
|
<div class="relative size-full overflow-hidden flex flex-col" classList={{ "p-2": props.padded }}>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionPanelFrame(props: ParentProps<{ newLayout: boolean; raised?: boolean }>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"flex-1 min-h-0 flex flex-col": true,
|
||||||
|
"bg-v2-background-bg-base": props.newLayout,
|
||||||
|
"bg-background-stronger": !props.newLayout,
|
||||||
|
"rounded-[10px] overflow-hidden": props.newLayout,
|
||||||
|
"shadow-[var(--v2-elevation-raised)]": props.newLayout && props.raised,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
@@ -1695,9 +1913,88 @@ export default function Page() {
|
|||||||
() => !isDesktop() && settings.general.newLayoutDesigns() && settings.general.mobileTitlebarPosition() === "bottom",
|
() => !isDesktop() && settings.general.newLayoutDesigns() && settings.general.mobileTitlebarPosition() === "bottom",
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
const sessionErrorFallback = (error: unknown, reset: () => void) => {
|
||||||
<div class="relative size-full overflow-hidden flex flex-col">
|
createEffect(on(sessionKey, reset, { defer: true }))
|
||||||
|
return <SessionErrorFallback error={error} sessionID={params.id} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionPanelContent = () => (
|
||||||
|
<>
|
||||||
{sessionSync() ?? ""}
|
{sessionSync() ?? ""}
|
||||||
|
<Show when={!isDesktop() && !!params.id && settings.general.newLayoutDesigns() && !mobileTabsBottom()}>
|
||||||
|
{mobileTabs(true)}
|
||||||
|
</Show>
|
||||||
|
<div class="flex-1 min-h-0 overflow-hidden">
|
||||||
|
<Switch>
|
||||||
|
<Match when={params.id && mobileChanges()}>
|
||||||
|
<div class="relative h-full overflow-hidden">
|
||||||
|
{reviewContent({
|
||||||
|
diffStyle: "unified",
|
||||||
|
classes: {
|
||||||
|
root: "pb-8 [&_[data-slot=session-review-list]]:pb-0",
|
||||||
|
header: "px-4 !h-16 !pb-4",
|
||||||
|
container: "px-4",
|
||||||
|
},
|
||||||
|
loadingClass: "px-4 py-4 text-text-weak",
|
||||||
|
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
<Match when={params.id}>
|
||||||
|
<Show when={messagesReady() ? params.id : undefined} keyed>
|
||||||
|
{(_id) => (
|
||||||
|
<MessageTimeline
|
||||||
|
actions={actions}
|
||||||
|
scroll={ui.scroll}
|
||||||
|
onResumeScroll={resumeScroll}
|
||||||
|
setScrollRef={setScrollRef}
|
||||||
|
onScheduleScrollState={scheduleScrollState}
|
||||||
|
onAutoScrollHandleScroll={autoScroll.handleScroll}
|
||||||
|
onMarkScrollGesture={markScrollGesture}
|
||||||
|
hasScrollGesture={hasScrollGesture}
|
||||||
|
onUserScroll={markUserScroll}
|
||||||
|
onHistoryScroll={onHistoryScroll}
|
||||||
|
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||||
|
shouldAnchorBottom={() =>
|
||||||
|
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
|
||||||
|
}
|
||||||
|
centered={centered()}
|
||||||
|
setContentRef={(el) => {
|
||||||
|
content = el
|
||||||
|
autoScroll.contentRef(el)
|
||||||
|
|
||||||
|
const root = scroller
|
||||||
|
if (root) scheduleScrollState(root)
|
||||||
|
}}
|
||||||
|
userMessages={visibleUserMessages()}
|
||||||
|
setHistoryAnchor={(handlers) => {
|
||||||
|
captureHistoryAnchor = handlers.capture
|
||||||
|
restoreHistoryAnchor = handlers.restore
|
||||||
|
}}
|
||||||
|
anchor={anchor}
|
||||||
|
setRevealMessage={(fn) => {
|
||||||
|
revealMessage = fn
|
||||||
|
}}
|
||||||
|
setScrollToEnd={(fn) => {
|
||||||
|
scrollToEnd = fn
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</Match>
|
||||||
|
<Match when={true}>
|
||||||
|
<NewSessionView worktree={newSessionWorktree()} />
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>{(_) => composerRegion()}</Show>
|
||||||
|
<Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SessionRouteFrame>
|
||||||
<SessionHeader />
|
<SessionHeader />
|
||||||
<div
|
<div
|
||||||
class="flex-1 min-h-0 flex flex-col md:flex-row"
|
class="flex-1 min-h-0 flex flex-col md:flex-row"
|
||||||
@@ -1717,85 +2014,13 @@ export default function Page() {
|
|||||||
width: sessionPanelWidth(),
|
width: sessionPanelWidth(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<SessionPanelFrame newLayout={settings.general.newLayoutDesigns()} raised={!!params.id}>
|
||||||
classList={{
|
{settings.general.newLayoutDesigns() ? (
|
||||||
"flex-1 min-h-0 flex flex-col": true,
|
<ErrorBoundary fallback={sessionErrorFallback}>{sessionPanelContent()}</ErrorBoundary>
|
||||||
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
|
) : (
|
||||||
"bg-background-stronger": !settings.general.newLayoutDesigns(),
|
sessionPanelContent()
|
||||||
"rounded-[10px] overflow-hidden": settings.general.newLayoutDesigns(),
|
)}
|
||||||
"shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id,
|
</SessionPanelFrame>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Show when={!isDesktop() && !!params.id && settings.general.newLayoutDesigns() && !mobileTabsBottom()}>
|
|
||||||
{mobileTabs(true)}
|
|
||||||
</Show>
|
|
||||||
<div class="flex-1 min-h-0 overflow-hidden">
|
|
||||||
<Switch>
|
|
||||||
<Match when={params.id && mobileChanges()}>
|
|
||||||
<div class="relative h-full overflow-hidden">
|
|
||||||
{reviewContent({
|
|
||||||
diffStyle: "unified",
|
|
||||||
classes: {
|
|
||||||
root: "pb-8 [&_[data-slot=session-review-list]]:pb-0",
|
|
||||||
header: "px-4 !h-16 !pb-4",
|
|
||||||
container: "px-4",
|
|
||||||
},
|
|
||||||
loadingClass: "px-4 py-4 text-text-weak",
|
|
||||||
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={params.id}>
|
|
||||||
<Show when={messagesReady() ? params.id : undefined} keyed>
|
|
||||||
{(_id) => (
|
|
||||||
<MessageTimeline
|
|
||||||
actions={actions}
|
|
||||||
scroll={ui.scroll}
|
|
||||||
onResumeScroll={resumeScroll}
|
|
||||||
setScrollRef={setScrollRef}
|
|
||||||
onScheduleScrollState={scheduleScrollState}
|
|
||||||
onAutoScrollHandleScroll={autoScroll.handleScroll}
|
|
||||||
onMarkScrollGesture={markScrollGesture}
|
|
||||||
hasScrollGesture={hasScrollGesture}
|
|
||||||
onUserScroll={markUserScroll}
|
|
||||||
onHistoryScroll={onHistoryScroll}
|
|
||||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
|
||||||
shouldAnchorBottom={() =>
|
|
||||||
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
|
|
||||||
}
|
|
||||||
centered={centered()}
|
|
||||||
setContentRef={(el) => {
|
|
||||||
content = el
|
|
||||||
autoScroll.contentRef(el)
|
|
||||||
|
|
||||||
const root = scroller
|
|
||||||
if (root) scheduleScrollState(root)
|
|
||||||
}}
|
|
||||||
userMessages={visibleUserMessages()}
|
|
||||||
setHistoryAnchor={(handlers) => {
|
|
||||||
captureHistoryAnchor = handlers.capture
|
|
||||||
restoreHistoryAnchor = handlers.restore
|
|
||||||
}}
|
|
||||||
anchor={anchor}
|
|
||||||
setRevealMessage={(fn) => {
|
|
||||||
revealMessage = fn
|
|
||||||
}}
|
|
||||||
setScrollToEnd={(fn) => {
|
|
||||||
scrollToEnd = fn
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</Match>
|
|
||||||
<Match when={true}>
|
|
||||||
<NewSessionView worktree={newSessionWorktree()} />
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>{(_) => composerRegion()}</Show>
|
|
||||||
<Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={desktopReviewOpen()}>
|
<Show when={desktopReviewOpen()}>
|
||||||
<div onPointerDown={() => size.start()}>
|
<div onPointerDown={() => size.start()}>
|
||||||
@@ -1832,6 +2057,6 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TerminalPanel />
|
<TerminalPanel />
|
||||||
</div>
|
</SessionRouteFrame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export function SessionComposerRegion(props: {
|
|||||||
</Show>
|
</Show>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"relative z-30": true,
|
"relative z-[70]": true,
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
"margin-top": `${-controller.lift()}px`,
|
"margin-top": `${-controller.lift()}px`,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
|
import { For, Show, createEffect, createMemo, onCleanup, onMount, type Component } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
|
import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
@@ -77,9 +78,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
customOn: cached?.customOn ?? ([] as boolean[]),
|
customOn: cached?.customOn ?? ([] as boolean[]),
|
||||||
editing: false,
|
editing: false,
|
||||||
focus: 0,
|
focus: 0,
|
||||||
|
minimized: false,
|
||||||
|
optionsHeight: 180,
|
||||||
})
|
})
|
||||||
|
|
||||||
let root: HTMLDivElement | undefined
|
let root: HTMLDivElement | undefined
|
||||||
|
let optionsRef: HTMLDivElement | undefined
|
||||||
let customRef: HTMLButtonElement | undefined
|
let customRef: HTMLButtonElement | undefined
|
||||||
let optsRef: HTMLButtonElement[] = []
|
let optsRef: HTMLButtonElement[] = []
|
||||||
let replied = false
|
let replied = false
|
||||||
@@ -96,11 +100,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
const n = Math.min(store.tab + 1, total())
|
const n = Math.min(store.tab + 1, total())
|
||||||
return language.t("session.question.progress", { current: n, total: total() })
|
return language.t("session.question.progress", { current: n, total: total() })
|
||||||
})
|
})
|
||||||
|
|
||||||
const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer")
|
const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer")
|
||||||
const customPlaceholder = () => language.t("ui.question.custom.placeholder")
|
const customPlaceholder = () => language.t("ui.question.custom.placeholder")
|
||||||
|
|
||||||
const last = createMemo(() => store.tab >= total() - 1)
|
const last = createMemo(() => store.tab >= total() - 1)
|
||||||
|
const collapse = useSpring(() => (store.minimized ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
|
||||||
|
const hidden = createMemo(() => Math.max(0, Math.min(1, collapse())))
|
||||||
|
const optionsOff = createMemo(() => hidden() > 0.98)
|
||||||
|
|
||||||
const customUpdate = (value: string, selected: boolean = on()) => {
|
const customUpdate = (value: string, selected: boolean = on()) => {
|
||||||
const prev = input().trim()
|
const prev = input().trim()
|
||||||
@@ -192,6 +198,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
focus(pickFocus())
|
focus(pickFocus())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const el = optionsRef
|
||||||
|
if (!el) return
|
||||||
|
const update = () => setStore("optionsHeight", (height) => Math.max(height, el.scrollHeight))
|
||||||
|
update()
|
||||||
|
createResizeObserver(el, update)
|
||||||
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
|
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
|
||||||
if (replied) return
|
if (replied) return
|
||||||
@@ -405,7 +419,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
const tab = store.tab + 1
|
const tab = store.tab + 1
|
||||||
setStore("tab", tab)
|
setStore("tab", tab)
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
focus(pickFocus(tab))
|
if (!store.minimized) focus(pickFocus(tab))
|
||||||
}
|
}
|
||||||
|
|
||||||
const back = () => {
|
const back = () => {
|
||||||
@@ -414,163 +428,212 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
const tab = store.tab - 1
|
const tab = store.tab - 1
|
||||||
setStore("tab", tab)
|
setStore("tab", tab)
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
focus(pickFocus(tab))
|
if (!store.minimized) focus(pickFocus(tab))
|
||||||
}
|
}
|
||||||
|
|
||||||
const jump = (tab: number) => {
|
const jump = (tab: number) => {
|
||||||
if (sending()) return
|
if (sending()) return
|
||||||
setStore("tab", tab)
|
setStore("tab", tab)
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
focus(pickFocus(tab))
|
if (!store.minimized) focus(pickFocus(tab))
|
||||||
|
}
|
||||||
|
|
||||||
|
const minimize = () => {
|
||||||
|
if (sending()) return
|
||||||
|
setStore("editing", false)
|
||||||
|
setStore("minimized", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const restore = () => {
|
||||||
|
if (sending()) return
|
||||||
|
setStore("minimized", false)
|
||||||
|
focus(pickFocus())
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DockPrompt
|
<div data-component="session-question-dock">
|
||||||
kind="question"
|
<DockPrompt
|
||||||
ref={(el) => (root = el)}
|
kind="question"
|
||||||
onKeyDown={nav}
|
ref={(el) => (root = el)}
|
||||||
header={
|
onKeyDown={nav}
|
||||||
<>
|
header={
|
||||||
<div data-slot="question-header-title">{summary()}</div>
|
<>
|
||||||
<Show when={total() > 1}>
|
<div data-slot="question-header-title">{summary()}</div>
|
||||||
<div data-slot="question-progress">
|
<div data-slot="question-header-actions">
|
||||||
<For each={questions()}>
|
<Show when={total() > 1}>
|
||||||
{(_, i) => (
|
<div data-slot="question-progress">
|
||||||
<button
|
<For each={questions()}>
|
||||||
type="button"
|
{(_, i) => (
|
||||||
data-slot="question-progress-segment"
|
<button
|
||||||
data-active={i() === store.tab}
|
type="button"
|
||||||
data-answered={answered(i())}
|
data-slot="question-progress-segment"
|
||||||
disabled={sending()}
|
data-active={i() === store.tab}
|
||||||
onClick={() => jump(i())}
|
data-answered={answered(i())}
|
||||||
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
disabled={sending()}
|
||||||
/>
|
onClick={() => jump(i())}
|
||||||
)}
|
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
||||||
</For>
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-component="icon-button"
|
||||||
|
data-icon="chevron-down"
|
||||||
|
data-size="normal"
|
||||||
|
data-variant="ghost"
|
||||||
|
disabled={sending()}
|
||||||
|
style={{ transform: `rotate(${hidden() * 180}deg)` }}
|
||||||
|
onClick={store.minimized ? restore : minimize}
|
||||||
|
aria-label={language.t(store.minimized ? "session.question.restore" : "session.question.minimize")}
|
||||||
|
>
|
||||||
|
<Icon name="chevron-down" size="small" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</>
|
||||||
</>
|
}
|
||||||
}
|
footer={
|
||||||
footer={
|
<>
|
||||||
<>
|
<Button variant="ghost" size="large" disabled={sending()} onClick={reject} aria-keyshortcuts="Escape">
|
||||||
<Button variant="ghost" size="large" disabled={sending()} onClick={reject} aria-keyshortcuts="Escape">
|
{language.t("ui.common.dismiss")}
|
||||||
{language.t("ui.common.dismiss")}
|
|
||||||
</Button>
|
|
||||||
<div data-slot="question-footer-actions">
|
|
||||||
<Show when={store.tab > 0}>
|
|
||||||
<Button variant="secondary" size="large" disabled={sending()} onClick={back}>
|
|
||||||
{language.t("ui.common.back")}
|
|
||||||
</Button>
|
|
||||||
</Show>
|
|
||||||
<Button
|
|
||||||
variant={last() ? "primary" : "secondary"}
|
|
||||||
size="large"
|
|
||||||
disabled={sending()}
|
|
||||||
onClick={next}
|
|
||||||
aria-keyshortcuts="Meta+Enter Control+Enter"
|
|
||||||
>
|
|
||||||
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
<div data-slot="question-footer-actions">
|
||||||
</>
|
<Show when={store.tab > 0}>
|
||||||
}
|
<Button variant="secondary" size="large" disabled={sending()} onClick={back}>
|
||||||
>
|
{language.t("ui.common.back")}
|
||||||
<div data-slot="question-text" class="overflow-auto">
|
</Button>
|
||||||
{question()?.question}
|
</Show>
|
||||||
</div>
|
<Button
|
||||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
variant={last() ? "primary" : "secondary"}
|
||||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
size="large"
|
||||||
</Show>
|
disabled={sending()}
|
||||||
<div data-slot="question-options">
|
onClick={next}
|
||||||
<For each={options()}>
|
aria-keyshortcuts="Meta+Enter Control+Enter"
|
||||||
{(opt, i) => (
|
>
|
||||||
<Option
|
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
||||||
multi={multi()}
|
</Button>
|
||||||
picked={picked(opt.label)}
|
</div>
|
||||||
label={opt.label}
|
</>
|
||||||
description={opt.description}
|
}
|
||||||
disabled={sending()}
|
>
|
||||||
ref={(el) => (optsRef[i()] = el)}
|
<div
|
||||||
onFocus={() => setStore("focus", i())}
|
data-slot="question-text"
|
||||||
onClick={() => selectOption(i())}
|
style={{
|
||||||
/>
|
display: store.minimized ? "-webkit-box" : undefined,
|
||||||
)}
|
"-webkit-line-clamp": store.minimized ? "3" : undefined,
|
||||||
</For>
|
"-webkit-box-orient": store.minimized ? "vertical" : undefined,
|
||||||
|
overflow: store.minimized ? "hidden" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{question()?.question}
|
||||||
|
</div>
|
||||||
|
<Show when={!store.minimized}>
|
||||||
|
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||||
|
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
<div
|
||||||
|
ref={(el) => (optionsRef = el)}
|
||||||
|
data-slot="question-options"
|
||||||
|
aria-hidden={store.minimized || optionsOff() ? "true" : undefined}
|
||||||
|
classList={{ "pointer-events-none": hidden() > 0.1 }}
|
||||||
|
style={{
|
||||||
|
"max-height": `${Math.max(0, store.optionsHeight * (1 - hidden()))}px`,
|
||||||
|
opacity: `${Math.max(0, Math.min(1, 1 - hidden()))}`,
|
||||||
|
visibility: optionsOff() ? "hidden" : "visible",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<For each={options()}>
|
||||||
|
{(opt, i) => (
|
||||||
|
<Option
|
||||||
|
multi={multi()}
|
||||||
|
picked={picked(opt.label)}
|
||||||
|
label={opt.label}
|
||||||
|
description={opt.description}
|
||||||
|
disabled={sending()}
|
||||||
|
ref={(el) => (optsRef[i()] = el)}
|
||||||
|
onFocus={() => setStore("focus", i())}
|
||||||
|
onClick={() => selectOption(i())}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
|
||||||
<Show
|
<Show
|
||||||
when={store.editing}
|
when={store.editing}
|
||||||
fallback={
|
fallback={
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
ref={customRef}
|
ref={customRef}
|
||||||
|
data-slot="question-option"
|
||||||
|
data-custom="true"
|
||||||
|
data-picked={on()}
|
||||||
|
role={multi() ? "checkbox" : "radio"}
|
||||||
|
aria-checked={on()}
|
||||||
|
disabled={sending()}
|
||||||
|
onFocus={() => setStore("focus", options().length)}
|
||||||
|
onClick={customOpen}
|
||||||
|
>
|
||||||
|
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||||
|
<span data-slot="question-option-main">
|
||||||
|
<span data-slot="option-label">{customLabel()}</span>
|
||||||
|
<span data-slot="option-description">{input() || customPlaceholder()}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form
|
||||||
data-slot="question-option"
|
data-slot="question-option"
|
||||||
data-custom="true"
|
data-custom="true"
|
||||||
data-picked={on()}
|
data-picked={on()}
|
||||||
role={multi() ? "checkbox" : "radio"}
|
role={multi() ? "checkbox" : "radio"}
|
||||||
aria-checked={on()}
|
aria-checked={on()}
|
||||||
disabled={sending()}
|
onMouseDown={(e) => {
|
||||||
onFocus={() => setStore("focus", options().length)}
|
if (sending()) {
|
||||||
onClick={customOpen}
|
e.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.target instanceof HTMLTextAreaElement) return
|
||||||
|
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
||||||
|
if (input instanceof HTMLTextAreaElement) input.focus()
|
||||||
|
}}
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
commitCustom()
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||||
<span data-slot="question-option-main">
|
<span data-slot="question-option-main">
|
||||||
<span data-slot="option-label">{customLabel()}</span>
|
<span data-slot="option-label">{customLabel()}</span>
|
||||||
<span data-slot="option-description">{input() || customPlaceholder()}</span>
|
<textarea
|
||||||
</span>
|
ref={focusCustom}
|
||||||
</button>
|
data-slot="question-custom-input"
|
||||||
}
|
placeholder={customPlaceholder()}
|
||||||
>
|
value={input()}
|
||||||
<form
|
rows={1}
|
||||||
data-slot="question-option"
|
disabled={sending()}
|
||||||
data-custom="true"
|
onKeyDown={(e) => {
|
||||||
data-picked={on()}
|
if (e.key === "Escape") {
|
||||||
role={multi() ? "checkbox" : "radio"}
|
e.preventDefault()
|
||||||
aria-checked={on()}
|
setStore("editing", false)
|
||||||
onMouseDown={(e) => {
|
focus(options().length)
|
||||||
if (sending()) {
|
return
|
||||||
e.preventDefault()
|
}
|
||||||
return
|
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
|
||||||
}
|
if (e.key !== "Enter" || e.shiftKey) return
|
||||||
if (e.target instanceof HTMLTextAreaElement) return
|
|
||||||
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
|
||||||
if (input instanceof HTMLTextAreaElement) input.focus()
|
|
||||||
}}
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
commitCustom()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
|
||||||
<span data-slot="question-option-main">
|
|
||||||
<span data-slot="option-label">{customLabel()}</span>
|
|
||||||
<textarea
|
|
||||||
ref={focusCustom}
|
|
||||||
data-slot="question-custom-input"
|
|
||||||
placeholder={customPlaceholder()}
|
|
||||||
value={input()}
|
|
||||||
rows={1}
|
|
||||||
disabled={sending()}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setStore("editing", false)
|
commitCustom()
|
||||||
focus(options().length)
|
}}
|
||||||
return
|
onInput={(e) => {
|
||||||
}
|
customUpdate(e.currentTarget.value)
|
||||||
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
|
resizeInput(e.currentTarget)
|
||||||
if (e.key !== "Enter" || e.shiftKey) return
|
}}
|
||||||
e.preventDefault()
|
/>
|
||||||
commitCustom()
|
</span>
|
||||||
}}
|
</form>
|
||||||
onInput={(e) => {
|
</Show>
|
||||||
customUpdate(e.currentTarget.value)
|
</div>
|
||||||
resizeInput(e.currentTarget)
|
</DockPrompt>
|
||||||
}}
|
</div>
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</form>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</DockPrompt>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,9 +31,14 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
|||||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
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 { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
|
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
|
import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
||||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
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"
|
import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
|
||||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||||
@@ -671,6 +676,34 @@ export function MessageTimeline(props: {
|
|||||||
if (!shareEnabled()) return
|
if (!shareEnabled()) return
|
||||||
unshareMutation.mutate(id)
|
unshareMutation.mutate(id)
|
||||||
}
|
}
|
||||||
|
const copyShareUrl = () => {
|
||||||
|
const url = shareUrl()
|
||||||
|
if (!url) return
|
||||||
|
void navigator.clipboard
|
||||||
|
.writeText(url)
|
||||||
|
.then(() =>
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
icon: "circle-check",
|
||||||
|
title: language.t("session.share.copy.copied"),
|
||||||
|
description: url,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.catch((err: unknown) =>
|
||||||
|
showToast({
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: errorMessage(err),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const selectShareUrlText: JSX.EventHandler<HTMLDivElement, MouseEvent> = (event) => {
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection) return
|
||||||
|
const range = document.createRange()
|
||||||
|
range.selectNodeContents(event.currentTarget)
|
||||||
|
selection.removeAllRanges()
|
||||||
|
selection.addRange(range)
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
@@ -703,8 +736,9 @@ export function MessageTimeline(props: {
|
|||||||
if (!sessionID() || parentID()) return
|
if (!sessionID() || parentID()) return
|
||||||
setTitle({ editing: true, draft: titleLabel() ?? "" })
|
setTitle({ editing: true, draft: titleLabel() ?? "" })
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
titleRef?.focus()
|
if (!titleRef) return
|
||||||
titleRef?.select()
|
titleRef.focus()
|
||||||
|
titleRef.select()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -856,6 +890,26 @@ export function MessageTimeline(props: {
|
|||||||
dialog.close()
|
dialog.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (settings.general.newLayoutDesigns())
|
||||||
|
return (
|
||||||
|
<DialogV2 fit>
|
||||||
|
<DialogHeader hideClose>
|
||||||
|
<DialogTitleGroup
|
||||||
|
title={language.t("session.delete.title")}
|
||||||
|
description={language.t("session.delete.confirm", { name: name() })}
|
||||||
|
/>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<ButtonV2 variant="ghost" onClick={() => dialog.close()}>
|
||||||
|
{language.t("common.cancel")}
|
||||||
|
</ButtonV2>
|
||||||
|
<ButtonV2 variant="danger" onClick={handleDelete}>
|
||||||
|
{language.t("session.delete.button")}
|
||||||
|
</ButtonV2>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogV2>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog title={language.t("session.delete.title")} fit>
|
<Dialog title={language.t("session.delete.title")} fit>
|
||||||
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
|
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
|
||||||
@@ -960,6 +1014,7 @@ export function MessageTimeline(props: {
|
|||||||
message={message()}
|
message={message()}
|
||||||
showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)}
|
showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)}
|
||||||
turnDurationMs={turnDurationMs(row().userMessageID)}
|
turnDurationMs={turnDurationMs(row().userMessageID)}
|
||||||
|
useV2Actions={settings.general.newLayoutDesigns()}
|
||||||
defaultOpen={defaultOpen()}
|
defaultOpen={defaultOpen()}
|
||||||
toolOpen={toolOpen[part().id] ?? defaultOpen()}
|
toolOpen={toolOpen[part().id] ?? defaultOpen()}
|
||||||
onToolOpenChange={(open) => setToolOpen(part().id, open)}
|
onToolOpenChange={(open) => setToolOpen(part().id, open)}
|
||||||
@@ -1067,6 +1122,7 @@ export function MessageTimeline(props: {
|
|||||||
message={message()}
|
message={message()}
|
||||||
parts={getMsgParts(userMessageRow().userMessageID)}
|
parts={getMsgParts(userMessageRow().userMessageID)}
|
||||||
actions={props.actions}
|
actions={props.actions}
|
||||||
|
useV2Actions={settings.general.newLayoutDesigns()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1302,26 +1358,31 @@ export function MessageTimeline(props: {
|
|||||||
"w-full": true,
|
"w-full": true,
|
||||||
"pb-4": true,
|
"pb-4": true,
|
||||||
"pr-3": true,
|
"pr-3": true,
|
||||||
"pl-4": settings.general.newLayoutDesigns(),
|
"pl-2": settings.general.newLayoutDesigns(),
|
||||||
"pl-2 md:pl-4": !settings.general.newLayoutDesigns(),
|
"pl-2 md:pl-4": !settings.general.newLayoutDesigns(),
|
||||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered && !settings.general.newLayoutDesigns(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="h-12 w-full flex items-center justify-between gap-2">
|
<div class="h-12 w-full flex items-center justify-between gap-2">
|
||||||
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
|
<div
|
||||||
<div class="flex items-center min-w-0 grow-1">
|
classList={{
|
||||||
|
"flex items-center gap-1 min-w-0 flex-1": true,
|
||||||
|
"pr-3": !settings.general.newLayoutDesigns(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex items-center min-w-0 flex-1 w-full">
|
||||||
<Show when={parentID()}>
|
<Show when={parentID()}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-slot="session-title-parent"
|
data-slot="session-title-parent"
|
||||||
class="min-w-0 max-w-[40%] truncate text-14-medium text-text-weak transition-colors hover:text-text-base"
|
class="min-w-0 max-w-[40%] truncate px-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
|
||||||
onClick={navigateParent}
|
onClick={navigateParent}
|
||||||
>
|
>
|
||||||
{parentTitle()}
|
{parentTitle()}
|
||||||
</button>
|
</button>
|
||||||
<span
|
<span
|
||||||
data-slot="session-title-separator"
|
data-slot="session-title-separator"
|
||||||
class="px-2 text-14-medium text-text-weak"
|
class="-translate-y-[0.5px] px-1 text-[11px] font-medium text-v2-text-text-faint"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
/
|
/
|
||||||
@@ -1333,8 +1394,13 @@ export function MessageTimeline(props: {
|
|||||||
fallback={
|
fallback={
|
||||||
<h1
|
<h1
|
||||||
data-slot="session-title-child"
|
data-slot="session-title-child"
|
||||||
class="text-14-medium text-text-strong truncate grow-1 min-w-0"
|
classList={{
|
||||||
onDblClick={openTitleEditor}
|
"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":
|
||||||
|
settings.general.newLayoutDesigns(),
|
||||||
|
"grow-1 min-w-0": !settings.general.newLayoutDesigns(),
|
||||||
|
}}
|
||||||
|
onClick={openTitleEditor}
|
||||||
>
|
>
|
||||||
{childTitle()}
|
{childTitle()}
|
||||||
</h1>
|
</h1>
|
||||||
@@ -1347,8 +1413,17 @@ export function MessageTimeline(props: {
|
|||||||
data-slot="session-title-child"
|
data-slot="session-title-child"
|
||||||
value={title.draft}
|
value={title.draft}
|
||||||
disabled={titleMutation.isPending}
|
disabled={titleMutation.isPending}
|
||||||
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px] pl-1 -ml-1"
|
classList={{
|
||||||
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
"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]": !settings.general.newLayoutDesigns(),
|
||||||
|
"field-sizing-content self-start rounded-[6px] px-2 py-1 ":
|
||||||
|
settings.general.newLayoutDesigns(),
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
"--inline-input-shadow": settings.general.newLayoutDesigns()
|
||||||
|
? "none"
|
||||||
|
: "var(--shadow-xs-border-select)",
|
||||||
|
}}
|
||||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
@@ -1370,88 +1445,170 @@ export function MessageTimeline(props: {
|
|||||||
</div>
|
</div>
|
||||||
<Show when={sessionID()} keyed>
|
<Show when={sessionID()} keyed>
|
||||||
{(id) => (
|
{(id) => (
|
||||||
<div class="shrink-0 flex items-center gap-3">
|
<div
|
||||||
<SessionContextUsage placement="bottom" />
|
classList={{
|
||||||
|
"shrink-0 flex items-center": true,
|
||||||
|
"gap-2": settings.general.newLayoutDesigns(),
|
||||||
|
"gap-3": !settings.general.newLayoutDesigns(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SessionContextUsage
|
||||||
|
placement="bottom"
|
||||||
|
buttonAppearance={settings.general.newLayoutDesigns() ? "v2" : "default"}
|
||||||
|
/>
|
||||||
<Show when={!parentID()}>
|
<Show when={!parentID()}>
|
||||||
<DropdownMenu
|
<Show
|
||||||
gutter={4}
|
when={settings.general.newLayoutDesigns()}
|
||||||
placement="bottom-end"
|
fallback={
|
||||||
open={title.menuOpen}
|
<DropdownMenu
|
||||||
onOpenChange={(open) => {
|
gutter={4}
|
||||||
setTitle("menuOpen", open)
|
placement="bottom-end"
|
||||||
if (open) return
|
open={title.menuOpen}
|
||||||
}}
|
onOpenChange={(open) => {
|
||||||
>
|
setTitle("menuOpen", open)
|
||||||
<DropdownMenu.Trigger
|
if (open) return
|
||||||
as={IconButton}
|
|
||||||
icon="dot-grid"
|
|
||||||
variant="ghost"
|
|
||||||
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
|
||||||
classList={{
|
|
||||||
"bg-surface-base-active": share.open || title.pendingShare,
|
|
||||||
}}
|
|
||||||
aria-label={language.t("common.moreOptions")}
|
|
||||||
aria-expanded={title.menuOpen || share.open || title.pendingShare}
|
|
||||||
ref={(el: HTMLButtonElement) => {
|
|
||||||
more = el
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<DropdownMenu.Portal>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
style={{ "min-width": "104px" }}
|
|
||||||
onCloseAutoFocus={(event) => {
|
|
||||||
if (title.pendingRename) {
|
|
||||||
event.preventDefault()
|
|
||||||
setTitle("pendingRename", false)
|
|
||||||
openTitleEditor()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (title.pendingShare) {
|
|
||||||
event.preventDefault()
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
setShare({ open: true, dismiss: null })
|
|
||||||
setTitle("pendingShare", false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Trigger
|
||||||
onSelect={() => {
|
as={IconButton}
|
||||||
setTitle("pendingRename", true)
|
icon="dot-grid"
|
||||||
setTitle("menuOpen", false)
|
variant="ghost"
|
||||||
|
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||||
|
classList={{
|
||||||
|
"bg-surface-base-active": share.open || title.pendingShare,
|
||||||
}}
|
}}
|
||||||
>
|
aria-label={language.t("common.moreOptions")}
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
aria-expanded={title.menuOpen || share.open || title.pendingShare}
|
||||||
</DropdownMenu.Item>
|
ref={(el: HTMLButtonElement) => {
|
||||||
<Show when={shareEnabled()}>
|
more = el
|
||||||
<DropdownMenu.Item
|
}}
|
||||||
onSelect={() => {
|
/>
|
||||||
setTitle({ pendingShare: true, menuOpen: false })
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
style={{ "min-width": "104px" }}
|
||||||
|
onCloseAutoFocus={(event) => {
|
||||||
|
if (title.pendingRename) {
|
||||||
|
event.preventDefault()
|
||||||
|
setTitle("pendingRename", false)
|
||||||
|
openTitleEditor()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (title.pendingShare) {
|
||||||
|
event.preventDefault()
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setShare({ open: true, dismiss: null })
|
||||||
|
setTitle("pendingShare", false)
|
||||||
|
})
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>
|
<DropdownMenu.Item
|
||||||
{language.t("session.share.action.share")}
|
onSelect={() => {
|
||||||
</DropdownMenu.ItemLabel>
|
setTitle("pendingRename", true)
|
||||||
</DropdownMenu.Item>
|
setTitle("menuOpen", false)
|
||||||
</Show>
|
}}
|
||||||
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
|
>
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Separator />
|
<Show when={shareEnabled()}>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
|
onSelect={() => {
|
||||||
|
setTitle({ pendingShare: true, menuOpen: false })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemLabel>
|
||||||
|
{language.t("session.share.action.share")}
|
||||||
|
</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</Show>
|
||||||
|
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
|
||||||
|
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuV2
|
||||||
|
gutter={6}
|
||||||
|
placement="bottom-end"
|
||||||
|
open={title.menuOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setTitle("menuOpen", open)
|
||||||
|
if (open) return
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuV2.Trigger
|
||||||
|
as={IconButtonV2}
|
||||||
|
icon={<IconV2 name="outline-dots" />}
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="large"
|
||||||
|
state={share.open || title.pendingShare ? "pressed" : undefined}
|
||||||
|
aria-label={language.t("common.moreOptions")}
|
||||||
|
aria-expanded={title.menuOpen || share.open || title.pendingShare}
|
||||||
|
ref={(el: HTMLButtonElement) => {
|
||||||
|
more = el
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MenuV2.Portal>
|
||||||
|
<MenuV2.Content
|
||||||
|
style={{ width: "120px", "min-width": "120px" }}
|
||||||
|
onCloseAutoFocus={(event) => {
|
||||||
|
if (title.pendingRename) {
|
||||||
|
event.preventDefault()
|
||||||
|
setTitle("pendingRename", false)
|
||||||
|
openTitleEditor()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (title.pendingShare) {
|
||||||
|
event.preventDefault()
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setShare({ open: true, dismiss: null })
|
||||||
|
setTitle("pendingShare", false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
<MenuV2.Item
|
||||||
</DropdownMenu.Item>
|
onSelect={() => {
|
||||||
</DropdownMenu.Content>
|
setTitle("pendingRename", true)
|
||||||
</DropdownMenu.Portal>
|
setTitle("menuOpen", false)
|
||||||
</DropdownMenu>
|
}}
|
||||||
|
>
|
||||||
|
{language.t("common.rename")}
|
||||||
|
</MenuV2.Item>
|
||||||
|
<Show when={shareEnabled()}>
|
||||||
|
<MenuV2.Item
|
||||||
|
onSelect={() => {
|
||||||
|
setTitle({ pendingShare: true, menuOpen: false })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{language.t("session.share.action.share")}...
|
||||||
|
</MenuV2.Item>
|
||||||
|
</Show>
|
||||||
|
<MenuV2.Item onSelect={() => void archiveSession(id)}>
|
||||||
|
{language.t("common.archive")}
|
||||||
|
</MenuV2.Item>
|
||||||
|
<MenuV2.Separator />
|
||||||
|
<MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}>
|
||||||
|
{language.t("common.delete")}...
|
||||||
|
</MenuV2.Item>
|
||||||
|
</MenuV2.Content>
|
||||||
|
</MenuV2.Portal>
|
||||||
|
</MenuV2>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<KobaltePopover
|
<KobaltePopover
|
||||||
open={share.open}
|
open={share.open}
|
||||||
anchorRef={() => more}
|
anchorRef={() => more}
|
||||||
placement="bottom-end"
|
placement="bottom-end"
|
||||||
gutter={4}
|
gutter={settings.general.newLayoutDesigns() ? 6 : 4}
|
||||||
modal={false}
|
modal={false}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (open) setShare("dismiss", null)
|
if (open) setShare("dismiss", null)
|
||||||
@@ -1461,6 +1618,10 @@ export function MessageTimeline(props: {
|
|||||||
<KobaltePopover.Portal>
|
<KobaltePopover.Portal>
|
||||||
<KobaltePopover.Content
|
<KobaltePopover.Content
|
||||||
data-component="popover-content"
|
data-component="popover-content"
|
||||||
|
classList={{
|
||||||
|
"flex w-80 max-w-none flex-col items-start gap-3 rounded-[10px] border-0 bg-v2-background-bg-layer-01 p-3 shadow-[var(--v2-elevation-floating)]":
|
||||||
|
settings.general.newLayoutDesigns(),
|
||||||
|
}}
|
||||||
style={{ "min-width": "320px" }}
|
style={{ "min-width": "320px" }}
|
||||||
onEscapeKeyDown={(event) => {
|
onEscapeKeyDown={(event) => {
|
||||||
setShare({ dismiss: "escape", open: false })
|
setShare({ dismiss: "escape", open: false })
|
||||||
@@ -1478,24 +1639,90 @@ export function MessageTimeline(props: {
|
|||||||
setShare("dismiss", null)
|
setShare("dismiss", null)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex flex-col p-3">
|
<Show
|
||||||
<div class="flex flex-col gap-1">
|
when={settings.general.newLayoutDesigns()}
|
||||||
<div class="text-13-medium text-text-strong">
|
fallback={
|
||||||
|
<div class="flex flex-col p-3">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="text-13-medium text-text-strong">
|
||||||
|
{language.t("session.share.popover.title")}
|
||||||
|
</div>
|
||||||
|
<div class="text-12-regular text-text-weak">
|
||||||
|
{shareUrl()
|
||||||
|
? language.t("session.share.popover.description.shared")
|
||||||
|
: language.t("session.share.popover.description.unshared")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex flex-col gap-2">
|
||||||
|
<Show
|
||||||
|
when={shareUrl()}
|
||||||
|
fallback={
|
||||||
|
<Button
|
||||||
|
size="large"
|
||||||
|
variant="primary"
|
||||||
|
class="w-full"
|
||||||
|
onClick={shareSession}
|
||||||
|
disabled={shareMutation.isPending}
|
||||||
|
>
|
||||||
|
{shareMutation.isPending
|
||||||
|
? language.t("session.share.action.publishing")
|
||||||
|
: language.t("session.share.action.publish")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<TextField
|
||||||
|
value={shareUrl() ?? ""}
|
||||||
|
readOnly
|
||||||
|
copyable
|
||||||
|
copyKind="link"
|
||||||
|
tabIndex={-1}
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<Button
|
||||||
|
size="large"
|
||||||
|
variant="secondary"
|
||||||
|
class="w-full shadow-none border border-border-weak-base"
|
||||||
|
onClick={unshareSession}
|
||||||
|
disabled={unshareMutation.isPending}
|
||||||
|
>
|
||||||
|
{unshareMutation.isPending
|
||||||
|
? language.t("session.share.action.unpublishing")
|
||||||
|
: language.t("session.share.action.unpublish")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="large"
|
||||||
|
variant="primary"
|
||||||
|
class="w-full"
|
||||||
|
onClick={viewShare}
|
||||||
|
disabled={unshareMutation.isPending}
|
||||||
|
>
|
||||||
|
{language.t("session.share.action.view")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="flex w-full flex-col gap-1.5 px-0.5 pt-0.5">
|
||||||
|
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base [font-variation-settings:'slnt'_0]">
|
||||||
{language.t("session.share.popover.title")}
|
{language.t("session.share.popover.title")}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-12-regular text-text-weak">
|
<div class="select-none text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-variation-settings:'slnt'_0]">
|
||||||
{shareUrl()
|
{shareUrl()
|
||||||
? language.t("session.share.popover.description.shared")
|
? language.t("session.share.popover.description.shared")
|
||||||
: language.t("session.share.popover.description.unshared")}
|
: language.t("session.share.popover.description.unshared")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 flex flex-col gap-2">
|
<div class="flex w-full flex-col gap-2">
|
||||||
<Show
|
<Show
|
||||||
when={shareUrl()}
|
when={shareUrl()}
|
||||||
fallback={
|
fallback={
|
||||||
<Button
|
<ButtonV2
|
||||||
size="large"
|
variant="contrast"
|
||||||
variant="primary"
|
|
||||||
class="w-full"
|
class="w-full"
|
||||||
onClick={shareSession}
|
onClick={shareSession}
|
||||||
disabled={shareMutation.isPending}
|
disabled={shareMutation.isPending}
|
||||||
@@ -1503,48 +1730,57 @@ export function MessageTimeline(props: {
|
|||||||
{shareMutation.isPending
|
{shareMutation.isPending
|
||||||
? language.t("session.share.action.publishing")
|
? language.t("session.share.action.publishing")
|
||||||
: language.t("session.share.action.publish")}
|
: language.t("session.share.action.publish")}
|
||||||
</Button>
|
</ButtonV2>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<TextField
|
<div
|
||||||
value={shareUrl() ?? ""}
|
class="flex h-8 w-full items-center gap-1.5 rounded-[6px] py-1 pl-2.5 pr-1.5 shadow-[var(--v2-elevation-button-neutral)]"
|
||||||
readOnly
|
style={{
|
||||||
copyable
|
background:
|
||||||
copyKind="link"
|
"linear-gradient(180deg, var(--v2-alpha-light-2) 0%, var(--v2-alpha-light-0) 100%), var(--v2-background-bg-button-neutral)",
|
||||||
tabIndex={-1}
|
}}
|
||||||
class="w-full"
|
>
|
||||||
/>
|
<div
|
||||||
<div class="grid grid-cols-2 gap-2">
|
class="min-w-0 flex-1 truncate select-text cursor-text text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-variation-settings:'slnt'_0]"
|
||||||
<Button
|
onClick={selectShareUrlText}
|
||||||
size="large"
|
>
|
||||||
variant="secondary"
|
{shareUrl()}
|
||||||
class={
|
</div>
|
||||||
settings.general.newLayoutDesigns()
|
<IconButtonV2
|
||||||
? "w-full shadow-none border-[0.5px] border-border-weak-base"
|
type="button"
|
||||||
: "w-full shadow-none border border-border-weak-base"
|
size="small"
|
||||||
}
|
variant="ghost-muted"
|
||||||
|
icon={<IconV2 name="outline-copy" />}
|
||||||
|
aria-label={language.t("session.share.copy.copyLink")}
|
||||||
|
onClick={copyShareUrl}
|
||||||
|
/>
|
||||||
|
<IconButtonV2
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
variant="ghost-muted"
|
||||||
|
icon={<IconV2 name="outline-square-arrow" />}
|
||||||
|
aria-label={language.t("session.share.action.view")}
|
||||||
|
onClick={viewShare}
|
||||||
|
disabled={unshareMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex w-full">
|
||||||
|
<ButtonV2
|
||||||
|
variant="outline"
|
||||||
|
class="w-full"
|
||||||
onClick={unshareSession}
|
onClick={unshareSession}
|
||||||
disabled={unshareMutation.isPending}
|
disabled={unshareMutation.isPending}
|
||||||
>
|
>
|
||||||
{unshareMutation.isPending
|
{unshareMutation.isPending
|
||||||
? language.t("session.share.action.unpublishing")
|
? language.t("session.share.action.unpublishing")
|
||||||
: language.t("session.share.action.unpublish")}
|
: language.t("session.share.action.unpublish")}
|
||||||
</Button>
|
</ButtonV2>
|
||||||
<Button
|
|
||||||
size="large"
|
|
||||||
variant="primary"
|
|
||||||
class="w-full"
|
|
||||||
onClick={viewShare}
|
|
||||||
disabled={unshareMutation.isPending}
|
|
||||||
>
|
|
||||||
{language.t("session.share.action.view")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Show>
|
||||||
</KobaltePopover.Content>
|
</KobaltePopover.Content>
|
||||||
</KobaltePopover.Portal>
|
</KobaltePopover.Portal>
|
||||||
</KobaltePopover>
|
</KobaltePopover>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type PersistedWithReady<T> = [
|
|||||||
|
|
||||||
type PersistTarget = {
|
type PersistTarget = {
|
||||||
storage?: string
|
storage?: string
|
||||||
|
scope?: "window"
|
||||||
legacyStorageNames?: string[]
|
legacyStorageNames?: string[]
|
||||||
key: string
|
key: string
|
||||||
legacy?: string[]
|
legacy?: string[]
|
||||||
@@ -24,6 +25,7 @@ type PersistTarget = {
|
|||||||
|
|
||||||
const LEGACY_STORAGE = "default.dat"
|
const LEGACY_STORAGE = "default.dat"
|
||||||
const GLOBAL_STORAGE = "opencode.global.dat"
|
const GLOBAL_STORAGE = "opencode.global.dat"
|
||||||
|
const WINDOW_STORAGE = "opencode.window"
|
||||||
const LOCAL_PREFIX = "opencode."
|
const LOCAL_PREFIX = "opencode."
|
||||||
const fallback = new Map<string, boolean>()
|
const fallback = new Map<string, boolean>()
|
||||||
|
|
||||||
@@ -347,6 +349,11 @@ function draftStorage(draftID: string) {
|
|||||||
return `opencode.draft.${head}.${sum}.dat`
|
return `opencode.draft.${head}.${sum}.dat`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function windowStorage(windowID: string) {
|
||||||
|
const safe = (windowID || "browser").replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||||
|
return `${WINDOW_STORAGE}.${safe}.dat`
|
||||||
|
}
|
||||||
|
|
||||||
function legacyWorkspaceStorage(dir: string) {
|
function legacyWorkspaceStorage(dir: string) {
|
||||||
const storage = workspaceStorage(pathKey(dir))
|
const storage = workspaceStorage(pathKey(dir))
|
||||||
const result = new Set<string>()
|
const result = new Set<string>()
|
||||||
@@ -467,6 +474,8 @@ export const PersistTesting = {
|
|||||||
localStorageWithPrefix,
|
localStorageWithPrefix,
|
||||||
migrateLegacy,
|
migrateLegacy,
|
||||||
normalize,
|
normalize,
|
||||||
|
resolveTarget,
|
||||||
|
windowStorage,
|
||||||
workspaceStorage,
|
workspaceStorage,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,6 +483,9 @@ export const Persist = {
|
|||||||
global(key: string, legacy?: string[]): PersistTarget {
|
global(key: string, legacy?: string[]): PersistTarget {
|
||||||
return { storage: GLOBAL_STORAGE, key, legacy }
|
return { storage: GLOBAL_STORAGE, key, legacy }
|
||||||
},
|
},
|
||||||
|
window(key: string, legacy?: string[]): PersistTarget {
|
||||||
|
return { scope: "window", key, legacy }
|
||||||
|
},
|
||||||
draft(draftID: string, key: string, legacy?: string[]): PersistTarget {
|
draft(draftID: string, key: string, legacy?: string[]): PersistTarget {
|
||||||
return { storage: draftStorage(draftID), key: `draft:${key}`, legacy }
|
return { storage: draftStorage(draftID), key: `draft:${key}`, legacy }
|
||||||
},
|
},
|
||||||
@@ -503,6 +515,16 @@ export const Persist = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveTarget(target: PersistTarget, platform: Platform): PersistTarget {
|
||||||
|
if (target.scope !== "window") return target
|
||||||
|
if (platform.platform === "desktop" && !platform.windowID) return { ...target, storage: GLOBAL_STORAGE }
|
||||||
|
const windowID = platform.platform === "desktop" ? (platform.windowID ?? "browser") : "browser"
|
||||||
|
return {
|
||||||
|
...target,
|
||||||
|
storage: windowStorage(windowID),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function removePersisted(
|
export function removePersisted(
|
||||||
target: { storage?: string; legacyStorageNames?: string[]; key: string },
|
target: { storage?: string; legacyStorageNames?: string[]; key: string },
|
||||||
platform?: Platform,
|
platform?: Platform,
|
||||||
@@ -533,7 +555,7 @@ export function persisted<T>(
|
|||||||
store: [Store<T>, SetStoreFunction<T>],
|
store: [Store<T>, SetStoreFunction<T>],
|
||||||
): PersistedWithReady<T> {
|
): PersistedWithReady<T> {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const config: PersistTarget = typeof target === "string" ? { key: target } : target
|
const config = resolveTarget(typeof target === "string" ? { key: target } : target, platform)
|
||||||
|
|
||||||
const defaults = snapshot(store[0])
|
const defaults = snapshot(store[0])
|
||||||
const legacy = config.legacy ?? []
|
const legacy = config.legacy ?? []
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
|
import { createEffect } from "solid-js"
|
||||||
|
import type { Accessor } from "solid-js"
|
||||||
|
import {
|
||||||
|
addServerProbePlan,
|
||||||
|
createProbeFailureGate,
|
||||||
|
runAddableProbePlan,
|
||||||
|
type AddServerProbePlan,
|
||||||
|
type WslAddServerView,
|
||||||
|
} from "./settings-model"
|
||||||
|
import type { WslInstalledDistro, WslServersPlatform, WslServersState } from "./types"
|
||||||
|
|
||||||
|
export function useWslAddServerProbes(input: {
|
||||||
|
state: Accessor<WslServersState | undefined>
|
||||||
|
api: WslServersPlatform
|
||||||
|
view: Accessor<WslAddServerView>
|
||||||
|
adding: Accessor<boolean>
|
||||||
|
busy: Accessor<boolean>
|
||||||
|
selectedDistro: Accessor<string | null>
|
||||||
|
addableInstalledDistros: Accessor<WslInstalledDistro[]>
|
||||||
|
onError: (error: unknown) => void
|
||||||
|
}) {
|
||||||
|
const gate = createProbeFailureGate()
|
||||||
|
const probe = useMutation(() => ({
|
||||||
|
mutationFn: async (command: AddServerProbePlan) => {
|
||||||
|
if (command.kind === "addable") {
|
||||||
|
await runAddableProbePlan({ plan: command.plan, api: input.api })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (command.plan.action === "probeRuntime") await input.api.probeRuntime()
|
||||||
|
if (command.plan.action === "refreshDistros") await input.api.refreshDistros()
|
||||||
|
},
|
||||||
|
onError: input.onError,
|
||||||
|
onSettled: (_result, error, command) => {
|
||||||
|
if (command) gate.settle(command.key, error)
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (probe.isPending) return
|
||||||
|
const command = addServerProbePlan({
|
||||||
|
state: input.state(),
|
||||||
|
view: input.view(),
|
||||||
|
adding: input.adding(),
|
||||||
|
busy: input.busy(),
|
||||||
|
selectedDistro: input.selectedDistro(),
|
||||||
|
addableInstalledDistros: input.addableInstalledDistros(),
|
||||||
|
})
|
||||||
|
if (!command || !gate.accepts(command.key)) return
|
||||||
|
probe.mutate(command)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
probingAddable: () => probe.isPending && probe.variables?.kind === "addable",
|
||||||
|
resetProbeFailure: () => gate.reset(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,28 @@
|
|||||||
import { Button } from "@opencode-ai/ui/button"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch } from "solid-js"
|
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 { LoaderV2 } from "@opencode-ai/ui/v2/loader-v2"
|
||||||
|
import { RadioGroupV2, RadioItemV2 } from "@opencode-ai/ui/v2/radio-v2"
|
||||||
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
|
import { createMemo, For, Show } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { useWslAddServerProbes } from "./add-server-probes"
|
||||||
import { useWslServers } from "./context"
|
import { useWslServers } from "./context"
|
||||||
import { enterWslOpencodeStep } from "./settings-model"
|
import { addServerViewModel, type AddServerText } from "./settings-model"
|
||||||
|
import "./dialog-add-wsl-server.css"
|
||||||
|
|
||||||
type WslServerStep = "wsl" | "distro" | "opencode"
|
function isWslRuntimeMissing(error: string | null | undefined) {
|
||||||
|
if (!error) return true
|
||||||
|
return /WSL is not installed|not been installed|wsl(?:\.exe)? --install/i.test(error)
|
||||||
|
}
|
||||||
|
|
||||||
const STEPS: WslServerStep[] = ["wsl", "distro", "opencode"]
|
function translate(language: ReturnType<typeof useLanguage>, value: AddServerText) {
|
||||||
|
if (value.params) return language.t(value.key, value.params)
|
||||||
function isHiddenDistro(name: string) {
|
return language.t(value.key)
|
||||||
return /^docker-desktop(?:-data)?$/i.test(name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DialogWslServerProps {
|
interface DialogWslServerProps {
|
||||||
@@ -22,210 +30,272 @@ interface DialogWslServerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DialogAddWslServer(props: DialogWslServerProps = {}) {
|
export function DialogAddWslServer(props: DialogWslServerProps = {}) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const controller = useWslAddServerController(props)
|
||||||
|
const model = controller.model
|
||||||
|
const primaryButton = () => model().primaryButton
|
||||||
|
const primaryButtonStyle = () => {
|
||||||
|
const width = primaryButton().width
|
||||||
|
if (!width) return undefined
|
||||||
|
return { width }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show
|
||||||
|
when={!controller.wslServers.isPending && !controller.wslServers.isError}
|
||||||
|
fallback={
|
||||||
|
<Dialog fit class="settings-v2-wsl-dialog">
|
||||||
|
<Show
|
||||||
|
when={!controller.wslServers.isError}
|
||||||
|
fallback={<div class="settings-v2-wsl-loading">{controller.loadError()}</div>}
|
||||||
|
>
|
||||||
|
<div class="settings-v2-wsl-loading">
|
||||||
|
<LoaderV2 />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Dialog>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={model().runtimeState === "ready"}
|
||||||
|
fallback={
|
||||||
|
<Show
|
||||||
|
when={model().runtimeState === "checking" || model().runtimeState === "loading"}
|
||||||
|
fallback={
|
||||||
|
<DialogWslSetup
|
||||||
|
state={model().runtimeState}
|
||||||
|
error={controller.runtimeError()}
|
||||||
|
installable={isWslRuntimeMissing(controller.runtimeError())}
|
||||||
|
busy={model().busy}
|
||||||
|
onInstall={controller.installWsl}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Dialog fit class="settings-v2-wsl-dialog">
|
||||||
|
<div class="settings-v2-wsl-loading">
|
||||||
|
<LoaderV2 />
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Dialog fit class="settings-v2-wsl-dialog">
|
||||||
|
<DialogHeader hideClose={true}>
|
||||||
|
<DialogTitle>
|
||||||
|
{controller.view() === "main" ? language.t("wsl.server.add") : language.t("wsl.onboarding.installDistro")}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<DividerV2 />
|
||||||
|
<Show
|
||||||
|
when={controller.view() === "main"}
|
||||||
|
fallback={
|
||||||
|
<>
|
||||||
|
<DialogBody class="settings-v2-wsl-dialog-body settings-v2-wsl-catalog-picker">
|
||||||
|
<TextInputV2
|
||||||
|
class="settings-v2-wsl-catalog-search"
|
||||||
|
appearance="large"
|
||||||
|
placeholder={language.t("wsl.onboarding.searchDistros")}
|
||||||
|
value={controller.catalogSearch()}
|
||||||
|
disabled={model().busy}
|
||||||
|
onInput={(event) => controller.setCatalogSearch(event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<div class="settings-v2-wsl-catalog-list">
|
||||||
|
<RadioGroupV2
|
||||||
|
hideLabel
|
||||||
|
class="settings-v2-wsl-distro-group"
|
||||||
|
label={language.t("wsl.onboarding.installDistro")}
|
||||||
|
value={model().catalogTarget ?? undefined}
|
||||||
|
onChange={controller.setCatalogTarget}
|
||||||
|
disabled={model().busy}
|
||||||
|
>
|
||||||
|
<For each={model().filteredInstallableDistros}>
|
||||||
|
{(item) => (
|
||||||
|
<RadioItemV2
|
||||||
|
class="settings-v2-wsl-distro-row settings-v2-wsl-catalog-row"
|
||||||
|
value={item.name}
|
||||||
|
disabled={model().busy}
|
||||||
|
label={<span class="settings-v2-wsl-distro-label">{item.label}</span>}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</RadioGroupV2>
|
||||||
|
</div>
|
||||||
|
</DialogBody>
|
||||||
|
<DialogFooter>
|
||||||
|
<ButtonV2 variant="neutral" disabled={model().busy} onClick={controller.closeCatalog}>
|
||||||
|
{language.t("common.cancel")}
|
||||||
|
</ButtonV2>
|
||||||
|
<ButtonV2
|
||||||
|
variant={model().installingCatalogDistro ? "loading" : "contrast"}
|
||||||
|
disabled={!model().installingCatalogDistro && (model().busy || !model().catalogTarget)}
|
||||||
|
style={{ width: "99px" }}
|
||||||
|
onClick={controller.installCatalogDistro}
|
||||||
|
>
|
||||||
|
<Show when={model().installingCatalogDistro} fallback={language.t("wsl.onboarding.installDistro")}>
|
||||||
|
<LoaderV2 />
|
||||||
|
</Show>
|
||||||
|
</ButtonV2>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DialogBody class="settings-v2-wsl-dialog-body">
|
||||||
|
<div class="settings-v2-wsl-section-header">
|
||||||
|
<span class="settings-v2-wsl-section-title">{language.t("wsl.onboarding.installedDistros")}</span>
|
||||||
|
<ButtonV2
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="small"
|
||||||
|
disabled={model().busy}
|
||||||
|
onClick={controller.refreshDistros}
|
||||||
|
>
|
||||||
|
{language.t("wsl.onboarding.checkAgain")}
|
||||||
|
</ButtonV2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show
|
||||||
|
when={model().addableInstalledDistros.length > 0}
|
||||||
|
fallback={
|
||||||
|
<div class="settings-v2-wsl-distro-list">
|
||||||
|
<div class="settings-v2-wsl-distro-empty">
|
||||||
|
{model().visibleInstalledDistros.length
|
||||||
|
? language.t("wsl.onboarding.allDistrosAdded")
|
||||||
|
: language.t("wsl.onboarding.noDistros")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="settings-v2-wsl-distro-list">
|
||||||
|
<RadioGroupV2
|
||||||
|
hideLabel
|
||||||
|
class="settings-v2-wsl-distro-group"
|
||||||
|
label={language.t("wsl.onboarding.installedDistros")}
|
||||||
|
value={model().selectedDistro ?? undefined}
|
||||||
|
onChange={controller.setSelectedDistro}
|
||||||
|
disabled={model().busy}
|
||||||
|
>
|
||||||
|
<For each={model().addableInstalledDistros}>
|
||||||
|
{(item) => {
|
||||||
|
const status = () => model().distroStatuses[item.name] ?? null
|
||||||
|
return (
|
||||||
|
<RadioItemV2
|
||||||
|
class={`settings-v2-wsl-distro-row${item.version === 1 ? " settings-v2-wsl-distro-row--unsupported" : ""}`}
|
||||||
|
value={item.name}
|
||||||
|
disabled={item.version === 1 || model().busy}
|
||||||
|
label={<span class="settings-v2-wsl-distro-label">{item.name}</span>}
|
||||||
|
description={
|
||||||
|
<Show when={status()}>
|
||||||
|
{(value) => (
|
||||||
|
<span class="settings-v2-wsl-distro-status" data-tone={value().tone}>
|
||||||
|
{translate(language, value().label)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</RadioGroupV2>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={model().installableDistros.length > 0}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="settings-v2-wsl-catalog-card"
|
||||||
|
disabled={model().busy}
|
||||||
|
onClick={controller.openCatalog}
|
||||||
|
>
|
||||||
|
<span class="settings-v2-wsl-catalog-icon" aria-hidden="true">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M13.5564 10.4443V13.5554H4.22309C3.24087 13.5554 2.44531 13.5554 2.44531 13.5554V10.4443M11.112 5.99989L8.00087 9.111L4.88976 5.99989M8.00087 9.111L8.00087 2.44434"
|
||||||
|
stroke="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="settings-v2-wsl-catalog-copy">
|
||||||
|
<span class="settings-v2-wsl-catalog-title">{language.t("wsl.onboarding.needAnotherDistro")}</span>
|
||||||
|
<span class="settings-v2-wsl-catalog-description">
|
||||||
|
{language.t("wsl.onboarding.needAnotherDistroHint")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="settings-v2-wsl-catalog-chevron" aria-hidden="true">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M6 12L10 8L6 4" stroke="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</DialogBody>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<ButtonV2 variant="neutral" disabled={controller.adding()} onClick={controller.close}>
|
||||||
|
{language.t("common.cancel")}
|
||||||
|
</ButtonV2>
|
||||||
|
<ButtonV2
|
||||||
|
variant={primaryButton().loading ? "loading" : primaryButton().variant}
|
||||||
|
disabled={!primaryButton().loading && primaryButton().disabled}
|
||||||
|
style={primaryButtonStyle()}
|
||||||
|
onClick={controller.runPrimary}
|
||||||
|
>
|
||||||
|
<Show when={primaryButton().loading} fallback={translate(language, primaryButton().label)}>
|
||||||
|
<LoaderV2 />
|
||||||
|
</Show>
|
||||||
|
</ButtonV2>
|
||||||
|
</DialogFooter>
|
||||||
|
</Show>
|
||||||
|
</Dialog>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useWslAddServerController(props: DialogWslServerProps) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const wslServers = useWslServers()
|
const wslServers = useWslServers()
|
||||||
const api = platform.wslServers!
|
const api = platform.wslServers!
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
step: undefined as WslServerStep | undefined,
|
view: "main" as "main" | "catalog",
|
||||||
selectedDistro: null as string | null,
|
selectedDistro: null as string | null,
|
||||||
installTarget: undefined as string | undefined,
|
catalogSearch: "",
|
||||||
|
catalogTarget: null as string | null,
|
||||||
adding: false,
|
adding: false,
|
||||||
})
|
})
|
||||||
const current = () => wslServers.data
|
const current = () => wslServers.data
|
||||||
let disposed = false
|
const viewModel = (probingAddable: boolean) =>
|
||||||
onCleanup(() => {
|
addServerViewModel({
|
||||||
disposed = true
|
state: current(),
|
||||||
|
view: store.view,
|
||||||
|
selectedDistro: store.selectedDistro,
|
||||||
|
catalogSearch: store.catalogSearch,
|
||||||
|
catalogTarget: store.catalogTarget,
|
||||||
|
adding: store.adding,
|
||||||
|
probingAddable,
|
||||||
|
})
|
||||||
|
const baseModel = createMemo(() => viewModel(false))
|
||||||
|
const probes = useWslAddServerProbes({
|
||||||
|
state: current,
|
||||||
|
api,
|
||||||
|
view: () => store.view,
|
||||||
|
adding: () => store.adding,
|
||||||
|
busy: () => baseModel().busy,
|
||||||
|
selectedDistro: () => baseModel().selectedDistro,
|
||||||
|
addableInstalledDistros: () => baseModel().addableInstalledDistros,
|
||||||
|
onError: (error) => requestError(language, error),
|
||||||
})
|
})
|
||||||
const busy = createMemo(() => !!current()?.job || store.adding)
|
const model = createMemo(() => viewModel(probes.probingAddable()))
|
||||||
const visibleInstalledDistros = createMemo(() =>
|
|
||||||
(current()?.installed ?? []).filter((item) => !isHiddenDistro(item.name)),
|
|
||||||
)
|
|
||||||
const visibleOnlineDistros = createMemo(() => (current()?.online ?? []).filter((item) => !isHiddenDistro(item.name)))
|
|
||||||
const defaultInstalledDistro = createMemo(() => visibleInstalledDistros().find((item) => item.isDefault) ?? null)
|
|
||||||
const existingServerDistros = createMemo(() => new Set((current()?.servers ?? []).map((item) => item.config.distro)))
|
|
||||||
const addableInstalledDistros = createMemo(() => {
|
|
||||||
return visibleInstalledDistros().filter((item) => !existingServerDistros().has(item.name))
|
|
||||||
})
|
|
||||||
const selectedDistro = createMemo(() => {
|
|
||||||
if (store.selectedDistro && addableInstalledDistros().some((item) => item.name === store.selectedDistro)) {
|
|
||||||
return store.selectedDistro
|
|
||||||
}
|
|
||||||
const distro = defaultInstalledDistro()
|
|
||||||
if (distro && !existingServerDistros().has(distro.name)) return distro.name
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
const selectedProbe = createMemo(() => {
|
|
||||||
const distro = selectedDistro()
|
|
||||||
if (!distro) return null
|
|
||||||
return current()?.distroProbes[distro] ?? null
|
|
||||||
})
|
|
||||||
const selectedInstalled = createMemo(() => {
|
|
||||||
const distro = selectedDistro()
|
|
||||||
if (!distro) return null
|
|
||||||
return (current()?.installed ?? []).find((item) => item.name === distro) ?? null
|
|
||||||
})
|
|
||||||
const opencodeCheck = createMemo(() => {
|
|
||||||
const distro = selectedDistro()
|
|
||||||
if (!distro) return null
|
|
||||||
return current()?.opencodeChecks[distro] ?? null
|
|
||||||
})
|
|
||||||
const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart)
|
|
||||||
const distroReady = createMemo(() => {
|
|
||||||
const probe = selectedProbe()
|
|
||||||
if (!probe || !selectedDistro()) return false
|
|
||||||
if (selectedInstalled()?.version === 1) return false
|
|
||||||
return probe.canExecute && probe.hasBash && probe.hasCurl
|
|
||||||
})
|
|
||||||
const opencodeReady = createMemo(() => {
|
|
||||||
const check = opencodeCheck()
|
|
||||||
return !!check?.resolvedPath && !check.error
|
|
||||||
})
|
|
||||||
const distroWarningProbe = createMemo(() => {
|
|
||||||
const probe = selectedProbe()
|
|
||||||
if (!probe) return null
|
|
||||||
if (distroReady()) return null
|
|
||||||
return probe
|
|
||||||
})
|
|
||||||
const distroUnavailableMessage = createMemo(() => {
|
|
||||||
const probe = distroWarningProbe()
|
|
||||||
const distro = selectedDistro()
|
|
||||||
if (!probe || probe.canExecute || !distro) return null
|
|
||||||
if (!selectedInstalled()) return language.t("wsl.onboarding.distroNotInstalled", { distro })
|
|
||||||
return language.t("wsl.onboarding.openDistroOnce", { distro })
|
|
||||||
})
|
|
||||||
const distroMissingTools = createMemo(() => {
|
|
||||||
const probe = distroWarningProbe()
|
|
||||||
if (!probe?.canExecute) return null
|
|
||||||
if (probe.hasBash && probe.hasCurl) return null
|
|
||||||
return probe
|
|
||||||
})
|
|
||||||
const installableDistros = createMemo(() => {
|
|
||||||
const online = visibleOnlineDistros()
|
|
||||||
const installed = new Set(visibleInstalledDistros().map((item) => item.name))
|
|
||||||
const hasVersionedUbuntu = online.some((item) => /^Ubuntu-\d/.test(item.name))
|
|
||||||
return online
|
|
||||||
.filter((item) => !installed.has(item.name))
|
|
||||||
.filter((item) => !(item.name === "Ubuntu" && hasVersionedUbuntu))
|
|
||||||
})
|
|
||||||
const installTarget = createMemo(
|
|
||||||
() => installableDistros().find((item) => item.name === store.installTarget) ?? installableDistros()[0] ?? null,
|
|
||||||
)
|
|
||||||
const installingDistro = createMemo(() => current()?.job?.kind === "install-distro")
|
|
||||||
const installingOpencode = createMemo(() => {
|
|
||||||
const job = current()?.job
|
|
||||||
return job?.kind === "install-opencode" && job.distro === selectedDistro()
|
|
||||||
})
|
|
||||||
const allReady = createMemo(() => wslReady() && distroReady() && opencodeReady())
|
|
||||||
const addDisabled = createMemo(() => {
|
|
||||||
const job = current()?.job
|
|
||||||
if (!job) return store.adding
|
|
||||||
return store.adding || job.kind !== "probe-opencode"
|
|
||||||
})
|
|
||||||
const recommendedStep = createMemo<WslServerStep>(() => {
|
|
||||||
if (!wslReady()) return "wsl"
|
|
||||||
if (!distroReady()) return "distro"
|
|
||||||
return "opencode"
|
|
||||||
})
|
|
||||||
// activeStep falls back to recommendedStep when the user hasn't picked one.
|
|
||||||
// Once the user clicks a step tab we respect their choice rather than snapping
|
|
||||||
// them back when a probe result updates recommendedStep.
|
|
||||||
const activeStep = createMemo(() => store.step ?? recommendedStep())
|
|
||||||
|
|
||||||
const autoProbe = createMemo(() => {
|
const openCatalog = () => {
|
||||||
const state = current()
|
const first = model().installableDistros[0]
|
||||||
if (!state || busy()) return null
|
setStore({
|
||||||
if (state.pendingRestart) return null
|
view: "catalog",
|
||||||
if (!state.runtime) return { key: "runtime", run: () => api.probeRuntime() }
|
catalogSearch: "",
|
||||||
if (!wslReady()) return null
|
catalogTarget: first?.name ?? null,
|
||||||
if (!state.installed.length && !state.online.length) {
|
})
|
||||||
return { key: "distros", run: () => api.refreshDistros() }
|
}
|
||||||
}
|
|
||||||
const distro = selectedDistro()
|
|
||||||
if (distro && !state.distroProbes[distro]) {
|
|
||||||
return { key: `probe-distro:${distro}`, run: () => api.probeDistro(distro) }
|
|
||||||
}
|
|
||||||
if (!distro || !distroReady()) return null
|
|
||||||
if (!state.opencodeChecks[distro]) {
|
|
||||||
return { key: `probe-opencode:${distro}`, run: () => api.probeOpencode(distro) }
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
|
|
||||||
let lastAutoProbe: string | null = null
|
|
||||||
createEffect(() => {
|
|
||||||
const probe = autoProbe()
|
|
||||||
if (!probe || probe.key === lastAutoProbe) return
|
|
||||||
const key = probe.key
|
|
||||||
lastAutoProbe = key
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
await probe.run()
|
|
||||||
} catch (err) {
|
|
||||||
if (disposed) return
|
|
||||||
// Allow the same probe to run again when reactive inputs next change
|
|
||||||
// (e.g. user reselects a distro). Without this the user would be stuck
|
|
||||||
// on a transient wsl.exe failure until they pick a different distro.
|
|
||||||
if (lastAutoProbe === key) lastAutoProbe = null
|
|
||||||
requestError(language, err)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
})
|
|
||||||
|
|
||||||
const wslMessage = createMemo(() => {
|
|
||||||
const state = current()
|
|
||||||
if (!state || state.job?.kind === "runtime") return language.t("wsl.onboarding.checkingRuntime")
|
|
||||||
if (state.pendingRestart) return language.t("wsl.onboarding.restartRequired")
|
|
||||||
if (state.runtime?.available) return state.runtime.version ?? language.t("wsl.onboarding.ready")
|
|
||||||
return state.runtime?.error ?? language.t("wsl.onboarding.required")
|
|
||||||
})
|
|
||||||
|
|
||||||
const distroMessage = createMemo(() => {
|
|
||||||
const state = current()
|
|
||||||
if (!state) return language.t("wsl.onboarding.checkingDistros")
|
|
||||||
const distro = selectedDistro()
|
|
||||||
if (state.job?.kind === "install-distro")
|
|
||||||
return language.t("wsl.onboarding.installingDistro", { distro: state.job.distro })
|
|
||||||
if (state.job?.kind === "probe-distro")
|
|
||||||
return language.t("wsl.onboarding.checkingDistro", { distro: state.job.distro })
|
|
||||||
if (state.job?.kind === "distros") return language.t("wsl.onboarding.listingDistros")
|
|
||||||
if (distroUnavailableMessage()) return distroUnavailableMessage()!
|
|
||||||
if (selectedProbe() && distroReady())
|
|
||||||
return language.t("wsl.onboarding.distroReady", { distro: selectedProbe()!.name })
|
|
||||||
if (distro) return language.t("wsl.onboarding.finishingDistro", { distro })
|
|
||||||
return language.t("wsl.onboarding.pickDistro")
|
|
||||||
})
|
|
||||||
|
|
||||||
const opencodeMessage = createMemo(() => {
|
|
||||||
const state = current()
|
|
||||||
if (!state) return language.t("wsl.onboarding.checkingOpencode")
|
|
||||||
const distro = selectedDistro()
|
|
||||||
if (state.job?.kind === "install-opencode") {
|
|
||||||
return distro
|
|
||||||
? language.t("wsl.onboarding.updatingOpencodeIn", { distro })
|
|
||||||
: language.t("wsl.onboarding.updatingOpencode")
|
|
||||||
}
|
|
||||||
if (state.job?.kind === "probe-opencode") {
|
|
||||||
return distro
|
|
||||||
? language.t("wsl.onboarding.checkingOpencodeIn", { distro })
|
|
||||||
: language.t("wsl.onboarding.checkingOpencode")
|
|
||||||
}
|
|
||||||
if (opencodeCheck()?.error) return opencodeCheck()!.error
|
|
||||||
if (opencodeCheck()?.matchesDesktop === false) {
|
|
||||||
return distro
|
|
||||||
? language.t("wsl.onboarding.updateOpencodeIn", { distro })
|
|
||||||
: language.t("wsl.onboarding.updateOpencode")
|
|
||||||
}
|
|
||||||
if (opencodeReady()) {
|
|
||||||
return distro
|
|
||||||
? language.t("wsl.onboarding.opencodeReadyIn", { distro })
|
|
||||||
: language.t("wsl.onboarding.opencodeReady")
|
|
||||||
}
|
|
||||||
return distro
|
|
||||||
? language.t("wsl.onboarding.installOpencodeIn", { distro })
|
|
||||||
: language.t("wsl.onboarding.chooseDistroFirst")
|
|
||||||
})
|
|
||||||
|
|
||||||
const run = async (action: () => Promise<unknown>) => {
|
const run = async (action: () => Promise<unknown>) => {
|
||||||
try {
|
try {
|
||||||
@@ -235,26 +305,43 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const runSelectedDistro = (action: (distro: string) => Promise<unknown>) => {
|
const refreshDistros = () => {
|
||||||
const distro = selectedDistro()
|
void run(async () => {
|
||||||
if (!distro) return
|
probes.resetProbeFailure()
|
||||||
void run(() => action(distro))
|
await api.refreshDistros()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectDistro = (name: string) => {
|
const installDistro = (name: string) => {
|
||||||
setStore("selectedDistro", name)
|
void run(async () => {
|
||||||
setStore("step", undefined)
|
probes.resetProbeFailure()
|
||||||
|
await api.installDistro(name)
|
||||||
|
setStore("view", "main")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const openOpencodeStep = () => {
|
const installCatalogDistro = () => {
|
||||||
const distro = selectedDistro()
|
if (model().installingCatalogDistro) return
|
||||||
if (!distro) return
|
const name = model().catalogTarget
|
||||||
void run(() => enterWslOpencodeStep(distro, api.probeOpencode, (step) => setStore("step", step)))
|
if (!name) return
|
||||||
|
installDistro(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
const finish = async () => {
|
const closeCatalog = () => {
|
||||||
const distro = selectedDistro()
|
probes.resetProbeFailure()
|
||||||
if (!distro) return
|
setStore({ view: "main", catalogSearch: "", catalogTarget: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
const runPrimary = async () => {
|
||||||
|
const button = model().primaryButton
|
||||||
|
if (button.loading) return
|
||||||
|
const distro = model().selectedDistro
|
||||||
|
const action = button.action
|
||||||
|
if (!distro || !action) return
|
||||||
|
if (action === "install-opencode") {
|
||||||
|
await run(() => api.installOpencode(distro))
|
||||||
|
return
|
||||||
|
}
|
||||||
setStore("adding", true)
|
setStore("adding", true)
|
||||||
try {
|
try {
|
||||||
await api.addServer(distro)
|
await api.addServer(distro)
|
||||||
@@ -270,346 +357,99 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const steps = createMemo(() => {
|
const loadError = () => {
|
||||||
const active = activeStep()
|
|
||||||
const activeIndex = STEPS.indexOf(active)
|
|
||||||
const recommendedIndex = STEPS.indexOf(recommendedStep())
|
|
||||||
return STEPS.map((step) => {
|
|
||||||
const index = STEPS.indexOf(step)
|
|
||||||
return {
|
|
||||||
step,
|
|
||||||
title:
|
|
||||||
step === "wsl"
|
|
||||||
? language.t("wsl.server.label")
|
|
||||||
: step === "distro"
|
|
||||||
? language.t("wsl.onboarding.step.distro")
|
|
||||||
: language.t("wsl.onboarding.step.opencode"),
|
|
||||||
state:
|
|
||||||
active === step
|
|
||||||
? "current"
|
|
||||||
: step === "wsl"
|
|
||||||
? wslReady()
|
|
||||||
? "done"
|
|
||||||
: "warning"
|
|
||||||
: step === "distro"
|
|
||||||
? distroReady()
|
|
||||||
? "done"
|
|
||||||
: index > activeIndex
|
|
||||||
? "locked"
|
|
||||||
: "warning"
|
|
||||||
: opencodeCheck()?.matchesDesktop === false
|
|
||||||
? "warning"
|
|
||||||
: opencodeReady()
|
|
||||||
? "done"
|
|
||||||
: index > activeIndex
|
|
||||||
? "locked"
|
|
||||||
: "warning",
|
|
||||||
locked: index > recommendedIndex,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
const loadError = createMemo(() => {
|
|
||||||
const error = wslServers.error
|
const error = wslServers.error
|
||||||
if (!error) return language.t("wsl.onboarding.loadFailed")
|
if (!error) return language.t("wsl.onboarding.loadFailed")
|
||||||
return error instanceof Error ? error.message : String(error)
|
return error instanceof Error ? error.message : String(error)
|
||||||
})
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
wslServers,
|
||||||
|
model,
|
||||||
|
loadError,
|
||||||
|
runtimeError: () => current()?.runtime?.error ?? null,
|
||||||
|
view: () => store.view,
|
||||||
|
catalogSearch: () => store.catalogSearch,
|
||||||
|
adding: () => store.adding,
|
||||||
|
setCatalogSearch: (value: string) => setStore("catalogSearch", value),
|
||||||
|
setCatalogTarget: (value: string) => setStore("catalogTarget", value),
|
||||||
|
setSelectedDistro: (value: string) => setStore("selectedDistro", value),
|
||||||
|
openCatalog,
|
||||||
|
closeCatalog,
|
||||||
|
refreshDistros,
|
||||||
|
installCatalogDistro,
|
||||||
|
installWsl: () => void run(() => api.installWsl()),
|
||||||
|
runPrimary: () => void runPrimary(),
|
||||||
|
close: () => dialog.close(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogWslSetup(props: {
|
||||||
|
state: string
|
||||||
|
error: string | null
|
||||||
|
installable: boolean
|
||||||
|
busy: boolean
|
||||||
|
onInstall: () => void
|
||||||
|
}) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const title = () =>
|
||||||
|
props.state === "pendingRestart"
|
||||||
|
? language.t("wsl.onboarding.restartRequired")
|
||||||
|
: props.installable
|
||||||
|
? language.t("wsl.onboarding.wslNotInstalled.title")
|
||||||
|
: language.t("wsl.onboarding.wslUnavailable.title")
|
||||||
|
const description = () => {
|
||||||
|
if (props.state === "pendingRestart") return language.t("wsl.onboarding.windowsRestartRequired")
|
||||||
|
if (!props.installable) return language.t("wsl.onboarding.wslUnavailable.description")
|
||||||
|
return language.t("wsl.onboarding.wslNotInstalled.description")
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="px-5 pb-5 flex flex-col gap-4">
|
<Dialog fit class="settings-v2-wsl-not-installed-dialog">
|
||||||
<Show
|
<div class="settings-v2-wsl-not-installed-content">
|
||||||
when={!wslServers.isPending}
|
<div class="settings-v2-wsl-not-installed-message">
|
||||||
fallback={<div class="px-1 py-6 text-14-regular text-text-weak">{language.t("wsl.onboarding.loading")}</div>}
|
<svg
|
||||||
>
|
class="settings-v2-wsl-not-installed-icon"
|
||||||
<Show
|
width="24"
|
||||||
when={!wslServers.isError}
|
height="24"
|
||||||
fallback={<div class="px-1 py-6 text-14-regular text-text-weak">{loadError()}</div>}
|
viewBox="0 0 24 24"
|
||||||
>
|
fill="none"
|
||||||
<div class="flex gap-2 pb-1">
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<For each={steps()}>
|
aria-hidden="true"
|
||||||
{(item) => (
|
>
|
||||||
<button
|
<g clip-path="url(#settings-v2-wsl-warning-clip)">
|
||||||
type="button"
|
<path
|
||||||
class="basis-0 flex-1 min-w-0 rounded-md border px-3 py-2 text-left transition-colors"
|
fill-rule="evenodd"
|
||||||
classList={{
|
clip-rule="evenodd"
|
||||||
"border-border-strong-base bg-surface-base-hover": item.state === "current",
|
d="M12 -0.00244141L23.6926 20.2498H0.308594L12 -0.00244141ZM12.7954 6.32932C12.5844 6.11834 12.2982 5.99982 11.9999 5.99982C11.7015 5.99982 11.4154 6.11834 11.2044 6.32932C10.9934 6.5403 10.8749 6.82645 10.8749 7.12482V11.6248C10.8749 11.9232 10.9934 12.2093 11.2044 12.4203C11.4154 12.6313 11.7015 12.7498 11.9999 12.7498C12.2982 12.7498 12.5844 12.6313 12.7954 12.4203C13.0064 12.2093 13.1249 11.9232 13.1249 11.6248V7.12482C13.1249 6.82645 13.0064 6.5403 12.7954 6.32932ZM13.0605 17.5605C12.7792 17.8418 12.3977 17.9998 11.9999 17.9998C11.6021 17.9998 11.2205 17.8418 10.9392 17.5605C10.6579 17.2792 10.4999 16.8976 10.4999 16.4998C10.4999 16.102 10.6579 15.7205 10.9392 15.4392C11.2205 15.1579 11.6021 14.9998 11.9999 14.9998C12.3977 14.9998 12.7792 15.1579 13.0605 15.4392C13.3418 15.7205 13.4999 16.102 13.4999 16.4998C13.4999 16.8976 13.3418 17.2792 13.0605 17.5605Z"
|
||||||
"border-icon-success-base/40 bg-surface-base": item.state === "done",
|
fill="#DBDBDB"
|
||||||
"border-border-weak-base bg-background-base opacity-60": item.state === "locked",
|
/>
|
||||||
"border-icon-warning-base/40 bg-surface-base": item.state === "warning",
|
</g>
|
||||||
}}
|
<defs>
|
||||||
disabled={item.locked}
|
<clipPath id="settings-v2-wsl-warning-clip">
|
||||||
onClick={() => setStore("step", item.step)}
|
<rect width="24" height="24" fill="white" />
|
||||||
>
|
</clipPath>
|
||||||
<div class="text-13-medium text-text-strong">{item.title}</div>
|
</defs>
|
||||||
</button>
|
</svg>
|
||||||
)}
|
<h2 class="settings-v2-wsl-not-installed-title">{title()}</h2>
|
||||||
</For>
|
<p class="settings-v2-wsl-not-installed-description">{description()}</p>
|
||||||
</div>
|
<Show when={!props.installable && props.error}>
|
||||||
|
<p class="settings-v2-wsl-unavailable-error">{props.error}</p>
|
||||||
<Switch>
|
|
||||||
<Match when={activeStep() === "wsl"}>
|
|
||||||
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
|
|
||||||
<div class="flex items-center justify-between gap-3">
|
|
||||||
<div class="text-14-medium text-text-strong">{language.t("wsl.server.label")}</div>
|
|
||||||
<Show when={current()?.runtime && !wslReady() && !current()?.pendingRestart}>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="large"
|
|
||||||
disabled={busy()}
|
|
||||||
onClick={() => void run(() => api.installWsl())}
|
|
||||||
>
|
|
||||||
{language.t("wsl.onboarding.installWsl")}
|
|
||||||
</Button>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{wslMessage()}</div>
|
|
||||||
<Show when={current()?.pendingRestart}>
|
|
||||||
<div class="rounded-md border border-border-weak-base px-3 py-3">
|
|
||||||
<div class="text-12-regular text-text-warning-base">
|
|
||||||
{language.t("wsl.onboarding.windowsRestartRequired")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<div class="flex items-center justify-end">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="large"
|
|
||||||
disabled={busy() || !wslReady()}
|
|
||||||
onClick={() => setStore("step", "distro")}
|
|
||||||
>
|
|
||||||
{language.t("wsl.onboarding.next")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
|
|
||||||
<Match when={activeStep() === "distro"}>
|
|
||||||
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
|
|
||||||
<div class="flex items-center justify-between gap-3">
|
|
||||||
<div class="text-14-medium text-text-strong">{language.t("wsl.onboarding.step.distro")}</div>
|
|
||||||
<Show when={selectedDistro()}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="small"
|
|
||||||
disabled={busy()}
|
|
||||||
onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
|
|
||||||
>
|
|
||||||
{language.t("wsl.onboarding.refresh")}
|
|
||||||
</Button>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{distroMessage()}</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<Show
|
|
||||||
when={addableInstalledDistros().length > 0}
|
|
||||||
fallback={
|
|
||||||
<div class="text-12-regular text-text-weak">
|
|
||||||
{visibleInstalledDistros().length
|
|
||||||
? language.t("wsl.onboarding.allDistrosAdded")
|
|
||||||
: current()?.runtime?.available
|
|
||||||
? language.t("wsl.onboarding.noDistros")
|
|
||||||
: language.t("wsl.onboarding.checkingDistros")}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<For each={addableInstalledDistros()}>
|
|
||||||
{(item) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-md border border-border-weak-base px-3 py-2 text-left transition-colors"
|
|
||||||
classList={{ "bg-surface-raised-base": selectedDistro() === item.name }}
|
|
||||||
onClick={() => selectDistro(item.name)}
|
|
||||||
>
|
|
||||||
<div class="text-13-medium text-text-strong">{item.name}</div>
|
|
||||||
<Show when={item.isDefault}>
|
|
||||||
<div class="text-12-regular text-text-weak">{language.t("common.default")}</div>
|
|
||||||
</Show>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={installableDistros().length > 0}>
|
|
||||||
<div class="rounded-md border border-border-weak-base p-2 flex flex-col gap-2">
|
|
||||||
<div class="px-1 flex items-center justify-between gap-3">
|
|
||||||
<div class="text-12-medium text-text-weak">{language.t("wsl.onboarding.install")}</div>
|
|
||||||
<div class="flex items-center gap-2 shrink-0">
|
|
||||||
<Show when={installingDistro()}>
|
|
||||||
<Spinner class="h-4 w-4 text-icon-info-base shrink-0" />
|
|
||||||
</Show>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="small"
|
|
||||||
disabled={busy() || !installTarget()}
|
|
||||||
onClick={() => void run(() => api.installDistro(installTarget()!.name))}
|
|
||||||
>
|
|
||||||
{installingDistro()
|
|
||||||
? language.t("wsl.onboarding.installing")
|
|
||||||
: language.t("wsl.onboarding.install")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
role="radiogroup"
|
|
||||||
aria-label={language.t("wsl.onboarding.installDistro")}
|
|
||||||
class="max-h-52 overflow-y-auto rounded-md bg-background-base"
|
|
||||||
>
|
|
||||||
<For each={installableDistros()}>
|
|
||||||
{(item) => {
|
|
||||||
const selected = () => installTarget()?.name === item.name
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
aria-checked={selected()}
|
|
||||||
disabled={busy()}
|
|
||||||
class="w-full px-3 py-2 flex items-center gap-3 text-left border-b border-border-weak-base last:border-b-0 transition-colors"
|
|
||||||
classList={{
|
|
||||||
"bg-surface-raised-base": selected(),
|
|
||||||
"hover:bg-surface-base": !selected(),
|
|
||||||
}}
|
|
||||||
onClick={() => setStore("installTarget", item.name)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="mt-0.5 h-4 w-4 rounded-full border border-border-strong-base flex items-center justify-center shrink-0"
|
|
||||||
classList={{ "border-text-strong": selected() }}
|
|
||||||
>
|
|
||||||
<div class="h-2 w-2 rounded-full bg-text-strong" classList={{ hidden: !selected() }} />
|
|
||||||
</div>
|
|
||||||
<div class="min-w-0 flex-1 text-13-medium text-text-strong truncate">{item.label}</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={selectedInstalled()?.version === 1 || distroUnavailableMessage() || distroMissingTools()}>
|
|
||||||
<div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
|
|
||||||
<Show when={selectedInstalled()?.version === 1}>
|
|
||||||
<div class="text-12-regular text-text-warning-base">
|
|
||||||
{language.t("wsl.onboarding.wsl2Required")}
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={distroUnavailableMessage()}>
|
|
||||||
{(message) => <div class="text-12-regular text-text-warning-base">{message()}</div>}
|
|
||||||
</Show>
|
|
||||||
<Show when={distroMissingTools()}>
|
|
||||||
<div class="text-12-regular text-text-warning-base">
|
|
||||||
{language.t("wsl.onboarding.toolsRequired")}
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="large"
|
|
||||||
disabled={busy() || !selectedInstalled()}
|
|
||||||
onClick={() => runSelectedDistro((distro) => api.openTerminal(distro))}
|
|
||||||
>
|
|
||||||
{language.t("wsl.onboarding.openTerminal")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="large"
|
|
||||||
disabled={busy() || !selectedDistro()}
|
|
||||||
onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
|
|
||||||
>
|
|
||||||
{language.t("wsl.onboarding.refresh")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-end">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="large"
|
|
||||||
disabled={busy() || !selectedDistro() || !distroReady()}
|
|
||||||
onClick={openOpencodeStep}
|
|
||||||
>
|
|
||||||
{language.t("wsl.onboarding.next")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
|
|
||||||
<Match when={activeStep() === "opencode"}>
|
|
||||||
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
|
|
||||||
<div class="flex items-center justify-between gap-3">
|
|
||||||
<div class="text-14-medium text-text-strong">{language.t("wsl.onboarding.step.opencode")}</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Show when={selectedDistro()}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="large"
|
|
||||||
disabled={busy()}
|
|
||||||
onClick={() => runSelectedDistro((distro) => api.probeOpencode(distro))}
|
|
||||||
>
|
|
||||||
{language.t("wsl.onboarding.refresh")}
|
|
||||||
</Button>
|
|
||||||
</Show>
|
|
||||||
<Show when={!opencodeReady() || opencodeCheck()?.matchesDesktop === false}>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="large"
|
|
||||||
disabled={busy()}
|
|
||||||
onClick={() => runSelectedDistro((distro) => api.installOpencode(distro))}
|
|
||||||
>
|
|
||||||
<Show when={installingOpencode()}>
|
|
||||||
<Spinner class="size-4 shrink-0" />
|
|
||||||
</Show>
|
|
||||||
{opencodeCheck()?.resolvedPath
|
|
||||||
? language.t("wsl.onboarding.updateOpencode")
|
|
||||||
: language.t("wsl.onboarding.installOpencode")}
|
|
||||||
</Button>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{opencodeMessage()}</div>
|
|
||||||
<Show when={opencodeCheck()?.matchesDesktop === false ? opencodeCheck() : null}>
|
|
||||||
{(check) => (
|
|
||||||
<div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
|
|
||||||
<div class="text-12-regular text-text-weak">
|
|
||||||
{language.t("wsl.onboarding.path", {
|
|
||||||
path: check().resolvedPath ?? language.t("wsl.onboarding.notFound"),
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div class="text-12-regular text-text-weak">
|
|
||||||
{language.t("wsl.onboarding.version", {
|
|
||||||
version: check().version ?? language.t("wsl.onboarding.unknown"),
|
|
||||||
})}
|
|
||||||
<Show when={check().expectedVersion}>
|
|
||||||
{(expected) => (
|
|
||||||
<span>{` · ${language.t("wsl.onboarding.desktopVersion", { version: expected() })}`}</span>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<div class="text-12-regular text-text-warning-base">
|
|
||||||
{language.t("wsl.onboarding.versionMismatch")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
|
|
||||||
<Show when={activeStep() === "opencode" && allReady() && selectedDistro()}>
|
|
||||||
<div class="flex items-center justify-end gap-2">
|
|
||||||
<Button variant="ghost" size="large" disabled={store.adding} onClick={() => dialog.close()}>
|
|
||||||
{language.t("common.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button variant="primary" size="large" disabled={addDisabled()} onClick={() => void finish()}>
|
|
||||||
{store.adding ? language.t("wsl.onboarding.adding") : language.t("wsl.server.add")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
</div>
|
||||||
|
<Show when={props.state === "unavailable" && props.installable}>
|
||||||
|
<ButtonV2 variant="neutral" disabled={props.busy} onClick={props.onInstall}>
|
||||||
|
{language.t("wsl.onboarding.installWsl")}
|
||||||
|
</ButtonV2>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
<Show when={props.state !== "unavailable"}>
|
||||||
</div>
|
<ButtonV2 variant="neutral" onClick={() => dialog.close()}>
|
||||||
|
{language.t("common.close")}
|
||||||
|
</ButtonV2>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,392 @@
|
|||||||
|
[data-component="dialog-v2"].settings-v2-wsl-dialog [data-slot="dialog-container"] {
|
||||||
|
width: 480px;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
height: auto;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="dialog-v2"].settings-v2-wsl-dialog [data-slot="dialog-content"] {
|
||||||
|
align-items: stretch;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="dialog-v2"].settings-v2-wsl-dialog [data-slot="dialog-header"] {
|
||||||
|
align-items: center;
|
||||||
|
padding: 24px 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="dialog-v2"].settings-v2-wsl-dialog [data-slot="dialog-body"] {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
padding: 0;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-dialog-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
align-self: stretch;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 16px 16px 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="dialog-v2"].settings-v2-wsl-dialog [data-slot="dialog-footer"] {
|
||||||
|
padding: 0 16px 24px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="dialog-v2"].settings-v2-wsl-not-installed-dialog [data-slot="dialog-container"] {
|
||||||
|
width: 480px;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
min-height: 264px;
|
||||||
|
height: auto;
|
||||||
|
background: var(--v2-background-bg-layer-01);
|
||||||
|
box-shadow: var(--v2-elevation-overlay);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="dialog-v2"].settings-v2-wsl-not-installed-dialog [data-slot="dialog-content"] {
|
||||||
|
align-items: stretch;
|
||||||
|
min-height: 264px;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="dialog-v2"].settings-v2-wsl-not-installed-dialog [data-slot="dialog-body"] {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-not-installed-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 56px 0;
|
||||||
|
gap: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-not-installed-message {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-not-installed-icon {
|
||||||
|
flex: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-not-installed-title {
|
||||||
|
display: flex;
|
||||||
|
flex: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 530;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 20px;
|
||||||
|
letter-spacing: -0.13px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-variation-settings: "slnt" 0;
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-not-installed-description {
|
||||||
|
display: flex;
|
||||||
|
flex: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 300px;
|
||||||
|
margin: 0;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 440;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: -0.04px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-variation-settings: "slnt" 0;
|
||||||
|
color: var(--v2-text-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-unavailable-error {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 440;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: -0.04px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-variation-settings: "slnt" 0;
|
||||||
|
color: var(--v2-text-text-faint);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-section-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex: none;
|
||||||
|
align-self: stretch;
|
||||||
|
gap: 12px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 0 0 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-section-title {
|
||||||
|
flex: none;
|
||||||
|
user-select: none;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 440;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 16px;
|
||||||
|
letter-spacing: -0.04px;
|
||||||
|
font-variation-settings: "slnt" 0;
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-distro-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex: none;
|
||||||
|
align-self: stretch;
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
max-height: 220px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--v2-background-bg-base);
|
||||||
|
box-shadow: var(--v2-elevation-raised);
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-distro-group {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-distro-group [data-slot="radio-v2-items"] {
|
||||||
|
width: 100%;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-distro-row {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 62px;
|
||||||
|
padding: 14px 14px 14px 16px;
|
||||||
|
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-distro-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-distro-row [data-slot="radio-v2-item-label"] {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-distro-row [data-slot="radio-v2-item-label"]::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -14px -14px -14px -44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-distro-row [data-slot="radio-v2-item-text"] {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-distro-label {
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 440;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 16px;
|
||||||
|
letter-spacing: -0.04px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-variation-settings: "slnt" 0;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-distro-status {
|
||||||
|
flex: none;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 440;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
letter-spacing: 0.05px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-variation-settings: "slnt" 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-distro-status[data-tone="success"] {
|
||||||
|
color: var(--v2-state-fg-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-distro-status[data-tone="warning"] {
|
||||||
|
color: var(--v2-state-fg-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-distro-status[data-tone="muted"] {
|
||||||
|
color: var(--v2-text-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-distro-row:where([data-disabled]) .settings-v2-wsl-distro-status {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-distro-empty {
|
||||||
|
padding: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 440;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--v2-text-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-catalog-card {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex: none;
|
||||||
|
align-self: stretch;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 64px;
|
||||||
|
padding: 14px 14px 16px 16px;
|
||||||
|
border: 0.5px solid var(--v2-border-border-base);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-catalog-card:focus-visible {
|
||||||
|
outline: 2px solid var(--v2-border-border-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-catalog-card:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-catalog-icon {
|
||||||
|
display: flex;
|
||||||
|
flex: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: var(--v2-icon-icon-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-catalog-copy {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-catalog-title {
|
||||||
|
user-select: none;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 440;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 16px;
|
||||||
|
letter-spacing: -0.04px;
|
||||||
|
font-variation-settings: "slnt" 0;
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-catalog-description {
|
||||||
|
user-select: none;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 440;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
letter-spacing: 0.05px;
|
||||||
|
font-variation-settings: "slnt" 0;
|
||||||
|
color: var(--v2-text-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-catalog-chevron {
|
||||||
|
display: flex;
|
||||||
|
flex: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: var(--v2-icon-icon-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-catalog-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex: none;
|
||||||
|
align-self: stretch;
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
max-height: 220px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-strong);
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-catalog-picker {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-catalog-search {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-catalog-row {
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-wsl-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 24px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 440;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--v2-text-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
@@ -1,5 +1,31 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { enterWslOpencodeStep, wslOpencodeAction, wslRuntimeRetryable } from "./settings-model"
|
import {
|
||||||
|
addableProbePlan,
|
||||||
|
addServerProbePlan,
|
||||||
|
addServerViewModel,
|
||||||
|
autoProbePlan,
|
||||||
|
createProbeFailureGate,
|
||||||
|
runAddableProbePlan,
|
||||||
|
wslOpencodeAction,
|
||||||
|
wslRuntimeRetryable,
|
||||||
|
} from "./settings-model"
|
||||||
|
import type { WslServersState } from "./types"
|
||||||
|
|
||||||
|
const readyWslState = readyState()
|
||||||
|
|
||||||
|
function readyState(input: Partial<WslServersState> = {}): WslServersState {
|
||||||
|
return {
|
||||||
|
runtime: { available: true, version: "2.4.13.0", error: null },
|
||||||
|
installed: [],
|
||||||
|
online: [],
|
||||||
|
distroProbes: {},
|
||||||
|
opencodeChecks: {},
|
||||||
|
pendingRestart: false,
|
||||||
|
servers: [],
|
||||||
|
job: null,
|
||||||
|
...input,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("WSL server settings presentation", () => {
|
describe("WSL server settings presentation", () => {
|
||||||
test("retries only settled unsuccessful runtimes", () => {
|
test("retries only settled unsuccessful runtimes", () => {
|
||||||
@@ -45,13 +71,161 @@ describe("WSL server settings presentation", () => {
|
|||||||
).toBeUndefined()
|
).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("probes the selected distro before entering the OpenCode step", async () => {
|
test("plans addable distro probes with the selected distro first", () => {
|
||||||
const calls: string[] = []
|
const plan = addableProbePlan({
|
||||||
await enterWslOpencodeStep(
|
state: readyWslState,
|
||||||
"Debian",
|
view: "main",
|
||||||
async (distro) => calls.push(distro),
|
adding: false,
|
||||||
(step) => calls.push(step),
|
selectedDistro: "Ubuntu",
|
||||||
)
|
addableInstalledDistros: [
|
||||||
expect(calls).toEqual(["Debian", "opencode"])
|
{ name: "Debian", version: 2, isDefault: true },
|
||||||
|
{ name: "Ubuntu", version: 2, isDefault: false },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(plan?.key).toBe("distro:Ubuntu|distro:Debian")
|
||||||
|
expect(plan?.distros).toEqual(["Ubuntu", "Debian"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("plans bootstrap probes for missing runtime and initial distro lists", () => {
|
||||||
|
expect(autoProbePlan({ state: undefined, busy: false })).toBeUndefined()
|
||||||
|
expect(autoProbePlan({ state: { ...readyWslState, runtime: null }, busy: false })).toEqual({
|
||||||
|
key: "runtime",
|
||||||
|
action: "probeRuntime",
|
||||||
|
})
|
||||||
|
expect(autoProbePlan({ state: readyWslState, busy: false })).toEqual({
|
||||||
|
key: "distros",
|
||||||
|
action: "refreshDistros",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses one command plan for bootstrap before addable distro probing", () => {
|
||||||
|
expect(
|
||||||
|
addServerProbePlan({
|
||||||
|
state: { ...readyWslState, runtime: null },
|
||||||
|
view: "main",
|
||||||
|
adding: false,
|
||||||
|
busy: false,
|
||||||
|
selectedDistro: null,
|
||||||
|
addableInstalledDistros: [{ name: "Debian", version: 2, isDefault: true }],
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: "auto", key: "auto:runtime", plan: { key: "runtime", action: "probeRuntime" } })
|
||||||
|
|
||||||
|
expect(
|
||||||
|
addServerProbePlan({
|
||||||
|
state: {
|
||||||
|
...readyWslState,
|
||||||
|
installed: [{ name: "Debian", version: 2, isDefault: true }],
|
||||||
|
online: [{ name: "Ubuntu", label: "Ubuntu" }],
|
||||||
|
},
|
||||||
|
view: "main",
|
||||||
|
adding: false,
|
||||||
|
busy: false,
|
||||||
|
selectedDistro: "Debian",
|
||||||
|
addableInstalledDistros: [{ name: "Debian", version: 2, isDefault: true }],
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: "addable", key: "addable:distro:Debian", plan: { key: "distro:Debian", distros: ["Debian"] } })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not accept the same failed probe command until reset", () => {
|
||||||
|
const gate = createProbeFailureGate()
|
||||||
|
|
||||||
|
expect(gate.accepts("addable:distro:Debian")).toBe(true)
|
||||||
|
gate.settle("addable:distro:Debian", new Error("wsl failed"))
|
||||||
|
expect(gate.accepts("addable:distro:Debian")).toBe(false)
|
||||||
|
expect(gate.accepts("addable:distro:Ubuntu")).toBe(true)
|
||||||
|
gate.reset()
|
||||||
|
expect(gate.accepts("addable:distro:Debian")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps default distro selection stable while probes resolve", () => {
|
||||||
|
const model = addServerViewModel({
|
||||||
|
state: {
|
||||||
|
...readyWslState,
|
||||||
|
installed: [
|
||||||
|
{ name: "Debian", version: 2, isDefault: true },
|
||||||
|
{ name: "Ubuntu", version: 2, isDefault: false },
|
||||||
|
],
|
||||||
|
online: [{ name: "Alpine", label: "Alpine Linux" }],
|
||||||
|
distroProbes: {
|
||||||
|
Ubuntu: { name: "Ubuntu", canExecute: true, hasBash: true, hasCurl: true, error: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
view: "main",
|
||||||
|
selectedDistro: null,
|
||||||
|
catalogSearch: "",
|
||||||
|
catalogTarget: null,
|
||||||
|
adding: false,
|
||||||
|
probingAddable: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(model.selectedDistro).toBe("Debian")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps the dialog busy across serial addable probe job gaps", () => {
|
||||||
|
const model = addServerViewModel({
|
||||||
|
state: readyState({
|
||||||
|
installed: [{ name: "Debian", version: 2, isDefault: true }],
|
||||||
|
online: [{ name: "Ubuntu", label: "Ubuntu" }],
|
||||||
|
}),
|
||||||
|
view: "main",
|
||||||
|
selectedDistro: null,
|
||||||
|
catalogSearch: "",
|
||||||
|
catalogTarget: null,
|
||||||
|
adding: false,
|
||||||
|
probingAddable: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(model.busy).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not report ready when OpenCode is present but cannot run", () => {
|
||||||
|
const model = addServerViewModel({
|
||||||
|
state: {
|
||||||
|
...readyWslState,
|
||||||
|
installed: [{ name: "Debian", version: 2, isDefault: true }],
|
||||||
|
online: [{ name: "Ubuntu", label: "Ubuntu" }],
|
||||||
|
distroProbes: {
|
||||||
|
Debian: { name: "Debian", canExecute: true, hasBash: true, hasCurl: true, error: null },
|
||||||
|
},
|
||||||
|
opencodeChecks: {
|
||||||
|
Debian: {
|
||||||
|
distro: "Debian",
|
||||||
|
resolvedPath: "/home/me/.opencode/bin/opencode",
|
||||||
|
version: null,
|
||||||
|
expectedVersion: "1.2.3",
|
||||||
|
matchesDesktop: null,
|
||||||
|
error: "opencode is installed but could not run",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
view: "main",
|
||||||
|
selectedDistro: null,
|
||||||
|
catalogSearch: "",
|
||||||
|
catalogTarget: null,
|
||||||
|
adding: false,
|
||||||
|
probingAddable: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(model.distroStatuses.Debian).toEqual({
|
||||||
|
label: { key: "wsl.onboarding.installOpencode" },
|
||||||
|
tone: "warning",
|
||||||
|
})
|
||||||
|
expect(model.primaryButton.action).toBe("install-opencode")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("delegates addable probe plans to one batch command", async () => {
|
||||||
|
const calls: string[][] = []
|
||||||
|
|
||||||
|
await runAddableProbePlan({
|
||||||
|
plan: { key: "distro:Debian|distro:Ubuntu", distros: ["Debian", "Ubuntu"] },
|
||||||
|
api: {
|
||||||
|
probeAddable: async (distros) => {
|
||||||
|
calls.push(distros)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(calls).toEqual([["Debian", "Ubuntu"]])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,19 +1,341 @@
|
|||||||
import type { WslOpencodeCheck, WslServerRuntime } from "./types"
|
import fuzzysort from "fuzzysort"
|
||||||
|
import type {
|
||||||
|
WslInstalledDistro,
|
||||||
|
WslOnlineDistro,
|
||||||
|
WslOpencodeCheck,
|
||||||
|
WslServersPlatform,
|
||||||
|
WslServerRuntime,
|
||||||
|
WslServersState,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
|
export type AddServerText = {
|
||||||
|
key: string
|
||||||
|
params?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DistroStatusTone = "success" | "warning" | "muted"
|
||||||
|
|
||||||
|
export type DistroStatus = {
|
||||||
|
label: AddServerText
|
||||||
|
tone: DistroStatusTone
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AddServerPrimaryButton = {
|
||||||
|
variant: "neutral" | "contrast"
|
||||||
|
label: AddServerText
|
||||||
|
disabled: boolean
|
||||||
|
action: "install-opencode" | "add" | null
|
||||||
|
loading: boolean
|
||||||
|
width: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AddServerRuntimeState = "loading" | "pendingRestart" | "checking" | "unavailable" | "ready"
|
||||||
|
|
||||||
|
export type AddableProbePlan = {
|
||||||
|
key: string
|
||||||
|
distros: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AutoProbePlan = { key: "runtime"; action: "probeRuntime" } | { key: "distros"; action: "refreshDistros" }
|
||||||
|
|
||||||
|
export type AddServerProbePlan =
|
||||||
|
| { kind: "auto"; key: string; plan: AutoProbePlan }
|
||||||
|
| { kind: "addable"; key: string; plan: AddableProbePlan }
|
||||||
|
|
||||||
|
export type WslAddServerView = "main" | "catalog"
|
||||||
|
|
||||||
|
function isHiddenDistro(name: string) {
|
||||||
|
return /^docker-desktop(?:-data)?$/i.test(name)
|
||||||
|
}
|
||||||
|
|
||||||
export const wslRuntimeRetryable = (runtime: WslServerRuntime) =>
|
export const wslRuntimeRetryable = (runtime: WslServerRuntime) =>
|
||||||
runtime.kind === "failed" || runtime.kind === "stopped"
|
runtime.kind === "failed" || runtime.kind === "stopped"
|
||||||
|
|
||||||
export async function enterWslOpencodeStep(
|
|
||||||
distro: string,
|
|
||||||
probe: (distro: string) => Promise<unknown>,
|
|
||||||
select: (step: "opencode") => void,
|
|
||||||
) {
|
|
||||||
await probe(distro)
|
|
||||||
select("opencode")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function wslOpencodeAction(check?: WslOpencodeCheck) {
|
export function wslOpencodeAction(check?: WslOpencodeCheck) {
|
||||||
if (!check) return
|
if (!check) return
|
||||||
if (!check.resolvedPath) return "Install OpenCode"
|
if (!check.resolvedPath) return "Install OpenCode"
|
||||||
if (check.matchesDesktop === false) return "Update OpenCode"
|
if (check.matchesDesktop === false) return "Update OpenCode"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function wslDistroReady(state: WslServersState | undefined, name: string) {
|
||||||
|
const installed = state?.installed.find((item) => item.name === name)
|
||||||
|
const probe = state?.distroProbes[name]
|
||||||
|
if (!probe || !installed) return false
|
||||||
|
if (installed.version === 1) return false
|
||||||
|
return probe.canExecute && probe.hasBash && probe.hasCurl
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addServerViewModel(input: {
|
||||||
|
state: WslServersState | undefined
|
||||||
|
view: WslAddServerView
|
||||||
|
selectedDistro: string | null
|
||||||
|
catalogSearch: string
|
||||||
|
catalogTarget: string | null
|
||||||
|
adding: boolean
|
||||||
|
probingAddable: boolean
|
||||||
|
}) {
|
||||||
|
const state = input.state
|
||||||
|
const visibleInstalledDistros = (state?.installed ?? []).filter((item) => !isHiddenDistro(item.name))
|
||||||
|
const visibleOnlineDistros = (state?.online ?? []).filter((item) => !isHiddenDistro(item.name))
|
||||||
|
const existingServerDistros = new Set((state?.servers ?? []).map((item) => item.config.distro))
|
||||||
|
const addableInstalledDistros = visibleInstalledDistros.filter((item) => !existingServerDistros.has(item.name))
|
||||||
|
const selectedDistro = addServerSelectedDistro(input.selectedDistro, visibleInstalledDistros, addableInstalledDistros)
|
||||||
|
const opencodeCheck = selectedDistro ? (state?.opencodeChecks[selectedDistro] ?? null) : null
|
||||||
|
const installableDistros = addServerInstallableDistros(visibleInstalledDistros, visibleOnlineDistros)
|
||||||
|
const filteredInstallableDistros = addServerFilteredInstallableDistros(installableDistros, input.catalogSearch)
|
||||||
|
const catalogTarget = addServerCatalogTarget(input.catalogTarget, filteredInstallableDistros)
|
||||||
|
const busy = !!state?.job || input.adding || input.probingAddable
|
||||||
|
|
||||||
|
return {
|
||||||
|
busy,
|
||||||
|
runtimeState: addServerRuntimeState(state),
|
||||||
|
visibleInstalledDistros,
|
||||||
|
visibleOnlineDistros,
|
||||||
|
addableInstalledDistros,
|
||||||
|
selectedDistro,
|
||||||
|
opencodeCheck,
|
||||||
|
wslReady: !!state?.runtime?.available && !state?.pendingRestart,
|
||||||
|
distroStatuses: Object.fromEntries(
|
||||||
|
addableInstalledDistros.flatMap((item) => {
|
||||||
|
const status = addServerDistroStatus({ state, name: item.name, probingAddable: input.probingAddable })
|
||||||
|
if (!status) return []
|
||||||
|
return [[item.name, status]]
|
||||||
|
}),
|
||||||
|
) as Record<string, DistroStatus | undefined>,
|
||||||
|
primaryButton: addServerPrimaryButton({
|
||||||
|
state,
|
||||||
|
selectedDistro,
|
||||||
|
opencodeCheck,
|
||||||
|
adding: input.adding,
|
||||||
|
probingAddable: input.probingAddable,
|
||||||
|
}),
|
||||||
|
installableDistros,
|
||||||
|
filteredInstallableDistros,
|
||||||
|
catalogTarget,
|
||||||
|
installingCatalogDistro: state?.job?.kind === "install-distro",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addServerSelectedDistro(
|
||||||
|
selected: string | null,
|
||||||
|
visibleInstalledDistros: WslInstalledDistro[],
|
||||||
|
addableInstalledDistros: WslInstalledDistro[],
|
||||||
|
) {
|
||||||
|
if (selected && addableInstalledDistros.some((item) => item.name === selected && item.version !== 1)) return selected
|
||||||
|
const defaultDistro = visibleInstalledDistros.find((item) => item.isDefault)
|
||||||
|
if (
|
||||||
|
defaultDistro &&
|
||||||
|
defaultDistro.version !== 1 &&
|
||||||
|
addableInstalledDistros.some((item) => item.name === defaultDistro.name)
|
||||||
|
) {
|
||||||
|
return defaultDistro.name
|
||||||
|
}
|
||||||
|
return addableInstalledDistros.find((item) => item.version !== 1)?.name ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function addServerRuntimeState(state: WslServersState | undefined): AddServerRuntimeState {
|
||||||
|
if (!state) return "loading"
|
||||||
|
if (state.pendingRestart) return "pendingRestart"
|
||||||
|
if (!state.runtime) return "checking"
|
||||||
|
if (!state.runtime.available) return "unavailable"
|
||||||
|
return "ready"
|
||||||
|
}
|
||||||
|
|
||||||
|
function addServerDistroStatus(input: {
|
||||||
|
state: WslServersState | undefined
|
||||||
|
name: string
|
||||||
|
probingAddable: boolean
|
||||||
|
}): DistroStatus | undefined {
|
||||||
|
const installed = input.state?.installed.find((item) => item.name === input.name)
|
||||||
|
if (installed?.version === 1) return { label: { key: "wsl.onboarding.distroStatus.unsupported" }, tone: "muted" }
|
||||||
|
const job = input.state?.job
|
||||||
|
const probe = input.state?.distroProbes[input.name]
|
||||||
|
if (!probe) {
|
||||||
|
if (input.probingAddable || (job?.kind === "probe-addable" && job.distros.includes(input.name))) {
|
||||||
|
return checkingStatus()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!probe.canExecute) {
|
||||||
|
if (!installed) {
|
||||||
|
return { label: { key: "wsl.onboarding.distroNotInstalled", params: { distro: input.name } }, tone: "warning" }
|
||||||
|
}
|
||||||
|
return { label: { key: "wsl.onboarding.openDistroOnce", params: { distro: input.name } }, tone: "warning" }
|
||||||
|
}
|
||||||
|
if (!probe.hasBash || !probe.hasCurl) {
|
||||||
|
return { label: { key: "wsl.onboarding.distroStatus.missingTools" }, tone: "warning" }
|
||||||
|
}
|
||||||
|
const check = input.state?.opencodeChecks[input.name]
|
||||||
|
if (!check) {
|
||||||
|
if (input.probingAddable || (job?.kind === "probe-addable" && job.distros.includes(input.name))) {
|
||||||
|
return checkingStatus()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (check.matchesDesktop === false) return { label: { key: "wsl.onboarding.updateOpencode" }, tone: "warning" }
|
||||||
|
if (!check.resolvedPath) return { label: { key: "wsl.onboarding.distroStatus.opencodeMissing" }, tone: "warning" }
|
||||||
|
if (check.error) return { label: { key: "wsl.onboarding.installOpencode" }, tone: "warning" }
|
||||||
|
return { label: { key: "wsl.onboarding.distroStatus.ready" }, tone: "success" }
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkingStatus(): DistroStatus {
|
||||||
|
return { label: { key: "wsl.onboarding.distroStatus.checking" }, tone: "muted" }
|
||||||
|
}
|
||||||
|
|
||||||
|
function addServerPrimaryButton(input: {
|
||||||
|
state: WslServersState | undefined
|
||||||
|
selectedDistro: string | null
|
||||||
|
opencodeCheck: WslOpencodeCheck | null
|
||||||
|
adding: boolean
|
||||||
|
probingAddable: boolean
|
||||||
|
}): AddServerPrimaryButton {
|
||||||
|
const ready = !!input.selectedDistro && wslDistroReady(input.state, input.selectedDistro)
|
||||||
|
const probingSelected = input.probingAddable && !addServerSelectedDistroSettled(input.state, input.selectedDistro)
|
||||||
|
const probingOpencode =
|
||||||
|
probingSelected ||
|
||||||
|
(ready &&
|
||||||
|
(!input.opencodeCheck ||
|
||||||
|
(!!input.selectedDistro &&
|
||||||
|
input.state?.job?.kind === "probe-addable" &&
|
||||||
|
input.state.job.distros.includes(input.selectedDistro))))
|
||||||
|
const installingOpencode =
|
||||||
|
input.state?.job?.kind === "install-opencode" && input.state.job.distro === input.selectedDistro
|
||||||
|
if (!ready || probingOpencode) {
|
||||||
|
return {
|
||||||
|
variant: "contrast",
|
||||||
|
label: probingSelected ? { key: "wsl.onboarding.distroStatus.checking" } : { key: "wsl.server.add" },
|
||||||
|
disabled: true,
|
||||||
|
action: null,
|
||||||
|
loading: probingSelected,
|
||||||
|
width: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!addServerOpencodeReady(input.opencodeCheck)) {
|
||||||
|
const update = !!input.opencodeCheck?.resolvedPath && input.opencodeCheck.matchesDesktop === false
|
||||||
|
return {
|
||||||
|
variant: "neutral",
|
||||||
|
label: installingOpencode
|
||||||
|
? { key: "wsl.onboarding.updatingOpencode" }
|
||||||
|
: update
|
||||||
|
? { key: "wsl.onboarding.updateOpencode" }
|
||||||
|
: { key: "wsl.onboarding.installOpencode" },
|
||||||
|
disabled: !!input.state?.job || input.adding,
|
||||||
|
action: "install-opencode",
|
||||||
|
loading: installingOpencode,
|
||||||
|
width: update ? "138px" : "129px",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
variant: "contrast",
|
||||||
|
label: input.adding ? { key: "wsl.onboarding.adding" } : { key: "wsl.server.add" },
|
||||||
|
disabled: input.adding || !!input.state?.job,
|
||||||
|
action: "add",
|
||||||
|
loading: input.adding,
|
||||||
|
width: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addServerOpencodeReady(check: WslOpencodeCheck | null) {
|
||||||
|
return !!check?.resolvedPath && check.matchesDesktop !== false && !check.error
|
||||||
|
}
|
||||||
|
|
||||||
|
function addServerSelectedDistroSettled(state: WslServersState | undefined, selectedDistro: string | null) {
|
||||||
|
if (!selectedDistro) return false
|
||||||
|
const installed = state?.installed.find((item) => item.name === selectedDistro)
|
||||||
|
if (installed?.version === 1) return false
|
||||||
|
if (!state?.distroProbes[selectedDistro]) return false
|
||||||
|
if (!wslDistroReady(state, selectedDistro)) return true
|
||||||
|
return !!state.opencodeChecks[selectedDistro]
|
||||||
|
}
|
||||||
|
|
||||||
|
function addServerInstallableDistros(installedDistros: WslInstalledDistro[], onlineDistros: WslOnlineDistro[]) {
|
||||||
|
const installed = new Set(installedDistros.map((item) => item.name))
|
||||||
|
const hasVersionedUbuntu = onlineDistros.some((item) => /^Ubuntu-\d/.test(item.name))
|
||||||
|
return onlineDistros
|
||||||
|
.filter((item) => !installed.has(item.name))
|
||||||
|
.filter((item) => item.name !== "Ubuntu" || !hasVersionedUbuntu)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addServerFilteredInstallableDistros(installableDistros: WslOnlineDistro[], search: string) {
|
||||||
|
const query = search.trim()
|
||||||
|
if (!query) return installableDistros
|
||||||
|
return fuzzysort.go(query, installableDistros, { keys: ["label", "name"] }).map((item) => item.obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addServerCatalogTarget(target: string | null, distros: WslOnlineDistro[]) {
|
||||||
|
if (target && distros.some((item) => item.name === target)) return target
|
||||||
|
return distros[0]?.name ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addableProbePlan(input: {
|
||||||
|
state: WslServersState | undefined
|
||||||
|
view: WslAddServerView
|
||||||
|
adding: boolean
|
||||||
|
selectedDistro: string | null
|
||||||
|
addableInstalledDistros: WslInstalledDistro[]
|
||||||
|
}) {
|
||||||
|
const state = input.state
|
||||||
|
if (!state?.runtime?.available || state.pendingRestart || input.view !== "main" || input.adding) return
|
||||||
|
if (state.job) return
|
||||||
|
const ordered = input.selectedDistro
|
||||||
|
? [
|
||||||
|
...input.addableInstalledDistros.filter((item) => item.name === input.selectedDistro),
|
||||||
|
...input.addableInstalledDistros.filter((item) => item.name !== input.selectedDistro),
|
||||||
|
]
|
||||||
|
: input.addableInstalledDistros
|
||||||
|
const pending = ordered.flatMap((item) => {
|
||||||
|
if (item.version === 1) return []
|
||||||
|
if (!state.distroProbes[item.name]) return [`distro:${item.name}`]
|
||||||
|
if (wslDistroReady(state, item.name) && !state.opencodeChecks[item.name]) return [`opencode:${item.name}`]
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
if (!pending.length) return
|
||||||
|
return {
|
||||||
|
key: pending.join("|"),
|
||||||
|
distros: ordered.filter((item) => item.version !== 1).map((item) => item.name),
|
||||||
|
} satisfies AddableProbePlan
|
||||||
|
}
|
||||||
|
|
||||||
|
export function autoProbePlan(input: { state: WslServersState | undefined; busy: boolean }) {
|
||||||
|
if (!input.state || input.busy || input.state.pendingRestart) return
|
||||||
|
if (!input.state.runtime) return { key: "runtime", action: "probeRuntime" } satisfies AutoProbePlan
|
||||||
|
if (!input.state.runtime.available) return
|
||||||
|
if (input.state.installed.length || input.state.online.length) return
|
||||||
|
return { key: "distros", action: "refreshDistros" } satisfies AutoProbePlan
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addServerProbePlan(input: {
|
||||||
|
state: WslServersState | undefined
|
||||||
|
view: WslAddServerView
|
||||||
|
adding: boolean
|
||||||
|
busy: boolean
|
||||||
|
selectedDistro: string | null
|
||||||
|
addableInstalledDistros: WslInstalledDistro[]
|
||||||
|
}) {
|
||||||
|
const auto = autoProbePlan({ state: input.state, busy: input.busy })
|
||||||
|
if (auto) return { kind: "auto", key: `auto:${auto.key}`, plan: auto } satisfies AddServerProbePlan
|
||||||
|
const addable = addableProbePlan(input)
|
||||||
|
if (addable) return { kind: "addable", key: `addable:${addable.key}`, plan: addable } satisfies AddServerProbePlan
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProbeFailureGate() {
|
||||||
|
let failed: string | undefined
|
||||||
|
return {
|
||||||
|
accepts(key: string) {
|
||||||
|
return key !== failed
|
||||||
|
},
|
||||||
|
settle(key: string, error?: unknown) {
|
||||||
|
if (error) failed = key
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
failed = undefined
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runAddableProbePlan(input: {
|
||||||
|
plan: AddableProbePlan
|
||||||
|
api: Pick<WslServersPlatform, "probeAddable">
|
||||||
|
}) {
|
||||||
|
await input.api.probeAddable(input.plan.distros)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
|
|
||||||
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
|
||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||||
@@ -30,17 +28,7 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const openAddWsl = () => {
|
const openAddWsl = () => {
|
||||||
dialog.push(() => (
|
dialog.push(() => <DialogAddWslServer />)
|
||||||
<Dialog size="large" fit class="settings-v2-wsl-dialog">
|
|
||||||
<DialogHeader hideClose={true}>
|
|
||||||
<DialogTitle>{language.t("wsl.server.add")}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<DividerV2 />
|
|
||||||
<DialogBody>
|
|
||||||
<DialogAddWslServer />
|
|
||||||
</DialogBody>
|
|
||||||
</Dialog>
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Show
|
<Show
|
||||||
|
|||||||
@@ -53,8 +53,7 @@ export type WslJob =
|
|||||||
| { kind: "distros"; startedAt: number }
|
| { kind: "distros"; startedAt: number }
|
||||||
| { kind: "install-wsl"; startedAt: number }
|
| { kind: "install-wsl"; startedAt: number }
|
||||||
| { kind: "install-distro"; distro: string; startedAt: number }
|
| { kind: "install-distro"; distro: string; startedAt: number }
|
||||||
| { kind: "probe-distro"; distro: string; startedAt: number }
|
| { kind: "probe-addable"; distros: string[]; startedAt: number }
|
||||||
| { kind: "probe-opencode"; distro: string; startedAt: number }
|
|
||||||
| { kind: "install-opencode"; distro: string; startedAt: number }
|
| { kind: "install-opencode"; distro: string; startedAt: number }
|
||||||
|
|
||||||
export type WslServersState = {
|
export type WslServersState = {
|
||||||
@@ -77,8 +76,7 @@ export type WslServersPlatform = {
|
|||||||
refreshDistros(): Promise<void>
|
refreshDistros(): Promise<void>
|
||||||
installWsl(): Promise<void>
|
installWsl(): Promise<void>
|
||||||
installDistro(name: string): Promise<void>
|
installDistro(name: string): Promise<void>
|
||||||
probeDistro(name: string): Promise<void>
|
probeAddable(distros: string[]): Promise<void>
|
||||||
probeOpencode(name: string): Promise<void>
|
|
||||||
installOpencode(name: string): Promise<void>
|
installOpencode(name: string): Promise<void>
|
||||||
openTerminal(name: string): Promise<void>
|
openTerminal(name: string): Promise<void>
|
||||||
addServer(distro: string): Promise<WslServerConfig>
|
addServer(distro: string): Promise<WslServerConfig>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/cli",
|
"name": "@opencode-ai/cli",
|
||||||
"version": "1.17.11",
|
"version": "1.17.12",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { NodeHttpServer } from "@effect/platform-node"
|
import { NodeHttpServer } from "@effect/platform-node"
|
||||||
import { Credential } from "@opencode-ai/core/credential"
|
import { Credential } from "@opencode-ai/core/credential"
|
||||||
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
|
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
|
||||||
import { Context, Layer, Option } from "effect"
|
import { Context, Layer, Option } from "effect"
|
||||||
import * as Effect from "effect/Effect"
|
import * as Effect from "effect/Effect"
|
||||||
@@ -38,8 +40,7 @@ function bind(hostname: string, port: number, password: string) {
|
|||||||
return Layer.build(
|
return Layer.build(
|
||||||
HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe(
|
HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe(
|
||||||
Layer.provideMerge(NodeHttpServer.layer(() => createServer(), { port, host: hostname })),
|
Layer.provideMerge(NodeHttpServer.layer(() => createServer(), { port, host: hostname })),
|
||||||
Layer.provide(Credential.defaultLayer),
|
Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node]))),
|
||||||
Layer.provide(PermissionSaved.defaultLayer),
|
|
||||||
),
|
),
|
||||||
).pipe(Effect.map((context) => Context.get(context, HttpServer.HttpServer).address))
|
).pipe(Effect.map((context) => Context.get(context, HttpServer.HttpServer).address))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const Handlers = Runtime.handlers(Commands, {
|
|||||||
})
|
})
|
||||||
|
|
||||||
Runtime.run(Commands, Handlers, { version: "local" }).pipe(
|
Runtime.run(Commands, Handlers, { version: "local" }).pipe(
|
||||||
Effect.provide(Daemon.defaultLayer),
|
Effect.provide(Daemon.layer),
|
||||||
Effect.provide(NodeServices.layer),
|
Effect.provide(NodeServices.layer),
|
||||||
Effect.scoped,
|
Effect.scoped,
|
||||||
NodeRuntime.runMain,
|
NodeRuntime.runMain,
|
||||||
|
|||||||
@@ -189,6 +189,4 @@ export const layer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer
|
|
||||||
|
|
||||||
export * as Daemon from "./daemon"
|
export * as Daemon from "./daemon"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { run } from "@opencode-ai/tui"
|
import { run } from "@opencode-ai/tui"
|
||||||
import { TuiConfig } from "@opencode-ai/tui/config"
|
import { TuiConfig } from "@opencode-ai/tui/config"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
|
||||||
export function runTui(transport: { url: string; headers: RequestInit["headers"] }) {
|
export function runTui(transport: { url: string; headers: RequestInit["headers"] }) {
|
||||||
@@ -14,7 +15,7 @@ export function runTui(transport: { url: string; headers: RequestInit["headers"]
|
|||||||
async start() {},
|
async start() {},
|
||||||
async dispose() {},
|
async dispose() {},
|
||||||
},
|
},
|
||||||
}).pipe(Effect.provide(Global.defaultLayer))
|
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)))
|
||||||
}
|
}
|
||||||
|
|
||||||
const legacyDefaults: Record<string, unknown> = {
|
const legacyDefaults: Record<string, unknown> = {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Private generation target for clients derived directly from OpenCode's authorita
|
|||||||
- `@opencode-ai/client`: zero-Effect Promise client using `fetch`.
|
- `@opencode-ai/client`: zero-Effect Promise client using `fetch`.
|
||||||
- `@opencode-ai/client/effect`: rich Effect network client using an environment-provided `HttpClient`.
|
- `@opencode-ai/client/effect`: rich Effect network client using an environment-provided `HttpClient`.
|
||||||
|
|
||||||
The generated surface starts with the Session group from Server's concrete API. The build compiler reads `@opencode-ai/server/api`; the generated Effect runtime imports a client-local projection built from Protocol, with a generation-equivalence test preventing transport drift. Run `bun run generate` after changing the contract and `bun run check:generated` to detect committed-output drift.
|
The generated surface includes every standard HTTP group from Server's concrete API. The build compiler reads `@opencode-ai/server/api`; the generated Effect runtime imports a client-local projection built from Protocol, with a generation-equivalence test preventing transport drift. Custom transports such as the PTY WebSocket connection remain outside the generic HTTP client. Run `bun run generate` after changing the contract and `bun run check:generated` to detect committed-output drift.
|
||||||
|
|
||||||
The Effect entrypoint uses canonical decoded values such as `Session.ID`, `Location.Ref`, and `Prompt`. These datatypes come from the lightweight `@opencode-ai/schema` package and are re-exported so callers depend only on the client surface. Protocol owns endpoint construction and middleware placement; Server supplies the concrete middleware keys used by the build-time API.
|
The Effect entrypoint uses canonical decoded values such as `Session.ID`, `Location.Ref`, and `Prompt`. These datatypes come from the lightweight `@opencode-ai/schema` package and are re-exported so callers depend only on the client surface. Protocol owns endpoint construction and middleware placement; Server supplies the concrete middleware keys used by the build-time API.
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen"
|
import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen"
|
||||||
import { Api } from "@opencode-ai/server/api"
|
import { ClientApi, endpointNames, groupNames, omitEndpoints } from "../src/contract"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { HttpApi } from "effect/unstable/httpapi"
|
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
const contract = compile(HttpApi.make("opencode-client").add(Api.groups["server.session"]), {
|
const contract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints })
|
||||||
groupNames: { "server.session": "sessions" },
|
|
||||||
})
|
|
||||||
|
|
||||||
await Effect.runPromise(
|
await Effect.runPromise(
|
||||||
Effect.all(
|
Effect.all(
|
||||||
[
|
[
|
||||||
write(emitPromise(contract), fileURLToPath(new URL("../src/generated", import.meta.url))),
|
|
||||||
write(
|
write(
|
||||||
emitEffectImported(contract, { module: "../contract", group: "SessionGroup" }),
|
emitPromise(contract, {
|
||||||
|
outputTypes: {
|
||||||
|
"events.subscribe": {
|
||||||
|
name: "OpenCodeEventEncoded",
|
||||||
|
import: 'import type { OpenCodeEventEncoded } from "@opencode-ai/protocol/groups/event"',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
fileURLToPath(new URL("../src/generated", import.meta.url)),
|
||||||
|
),
|
||||||
|
write(
|
||||||
|
emitEffectImported(contract, { module: "../contract", api: "ClientApi" }),
|
||||||
fileURLToPath(new URL("../src/generated-effect", import.meta.url)),
|
fileURLToPath(new URL("../src/generated-effect", import.meta.url)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -11,9 +11,43 @@ class SessionLocationMiddleware extends HttpApiMiddleware.Service<SessionLocatio
|
|||||||
{ error: [InvalidRequestError, SessionNotFoundError] },
|
{ error: [InvalidRequestError, SessionNotFoundError] },
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
const Api = makeDefaultApi({
|
export const ClientApi = makeDefaultApi({
|
||||||
locationMiddleware: LocationMiddleware,
|
locationMiddleware: LocationMiddleware,
|
||||||
sessionLocationMiddleware: SessionLocationMiddleware,
|
sessionLocationMiddleware: SessionLocationMiddleware,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const SessionGroup = Api.groups["server.session"]
|
export const groupNames = {
|
||||||
|
"server.health": "health",
|
||||||
|
"server.location": "location",
|
||||||
|
"server.agent": "agents",
|
||||||
|
"server.session": "sessions",
|
||||||
|
"server.message": "messages",
|
||||||
|
"server.model": "models",
|
||||||
|
"server.provider": "providers",
|
||||||
|
"server.integration": "integrations",
|
||||||
|
"server.credential": "credentials",
|
||||||
|
"server.permission": "permissions",
|
||||||
|
"server.fs": "files",
|
||||||
|
"server.command": "commands",
|
||||||
|
"server.skill": "skills",
|
||||||
|
"server.event": "events",
|
||||||
|
"server.pty": "ptys",
|
||||||
|
"server.question": "questions",
|
||||||
|
"server.reference": "references",
|
||||||
|
"server.projectCopy": "projectCopies",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const endpointNames = {
|
||||||
|
"session.messages": "list",
|
||||||
|
"integration.connect.key": "connectKey",
|
||||||
|
"integration.connect.oauth": "connectOauth",
|
||||||
|
"integration.attempt.status": "attemptStatus",
|
||||||
|
"integration.attempt.complete": "attemptComplete",
|
||||||
|
"integration.attempt.cancel": "attemptCancel",
|
||||||
|
"permission.request.list": "listRequests",
|
||||||
|
"permission.saved.list": "listSaved",
|
||||||
|
"permission.saved.remove": "removeSaved",
|
||||||
|
"question.request.list": "listRequests",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const omitEndpoints = new Set(["fs.read", "pty.connect", "pty.connectToken"])
|
||||||
|
|||||||
@@ -2,11 +2,24 @@
|
|||||||
// Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations.
|
// Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations.
|
||||||
export * from "./generated-effect/index"
|
export * from "./generated-effect/index"
|
||||||
export { Agent } from "@opencode-ai/schema/agent"
|
export { Agent } from "@opencode-ai/schema/agent"
|
||||||
|
export { Command } from "@opencode-ai/schema/command"
|
||||||
|
export { Credential } from "@opencode-ai/schema/credential"
|
||||||
|
export { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||||
|
export { Integration } from "@opencode-ai/schema/integration"
|
||||||
export { Location } from "@opencode-ai/schema/location"
|
export { Location } from "@opencode-ai/schema/location"
|
||||||
export { Model } from "@opencode-ai/schema/model"
|
export { Model } from "@opencode-ai/schema/model"
|
||||||
|
export { Permission } from "@opencode-ai/schema/permission"
|
||||||
|
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
||||||
|
export { Project } from "@opencode-ai/schema/project"
|
||||||
|
export { ProjectCopy } from "@opencode-ai/schema/project-copy"
|
||||||
export { Provider } from "@opencode-ai/schema/provider"
|
export { Provider } from "@opencode-ai/schema/provider"
|
||||||
|
export { Pty } from "@opencode-ai/schema/pty"
|
||||||
|
export { Question } from "@opencode-ai/schema/question"
|
||||||
|
export { Reference } from "@opencode-ai/schema/reference"
|
||||||
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
|
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
|
||||||
export { Session } from "@opencode-ai/schema/session"
|
export { Session } from "@opencode-ai/schema/session"
|
||||||
export { SessionInput } from "@opencode-ai/schema/session-input"
|
export { SessionInput } from "@opencode-ai/schema/session-input"
|
||||||
export { SessionMessage } from "@opencode-ai/schema/session-message"
|
export { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
|
export { Skill } from "@opencode-ai/schema/skill"
|
||||||
export { Prompt } from "@opencode-ai/schema/prompt"
|
export { Prompt } from "@opencode-ai/schema/prompt"
|
||||||
|
export type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
||||||
|
|||||||
@@ -2,202 +2,705 @@
|
|||||||
import { Effect, Stream, Schema } from "effect"
|
import { Effect, Stream, Schema } from "effect"
|
||||||
import { Sse } from "effect/unstable/encoding"
|
import { Sse } from "effect/unstable/encoding"
|
||||||
import { HttpClientError } from "effect/unstable/http"
|
import { HttpClientError } from "effect/unstable/http"
|
||||||
import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
|
import { HttpApiClient } from "effect/unstable/httpapi"
|
||||||
import { SessionGroup } from "../contract"
|
import { ClientApi } from "../contract"
|
||||||
import { ClientError } from "./client-error"
|
import { ClientError } from "./client-error"
|
||||||
|
|
||||||
const Api = HttpApi.make("generated").add(SessionGroup)
|
type RawClient = HttpApiClient.ForApi<typeof ClientApi>
|
||||||
|
|
||||||
type RawClient = HttpApiClient.ForApi<typeof Api>
|
|
||||||
|
|
||||||
const mapClientError = <E>(error: E) =>
|
const mapClientError = <E>(error: E) =>
|
||||||
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
||||||
? new ClientError({ cause: error })
|
? new ClientError({ cause: error })
|
||||||
: error
|
: error
|
||||||
|
|
||||||
type Endpoint0_0Request = Parameters<RawClient["server.session"]["session.list"]>[0]
|
const Endpoint0_0 = (raw: RawClient["server.health"]) => () =>
|
||||||
type Endpoint0_0Input = {
|
raw["health.get"]({}).pipe(Effect.mapError(mapClientError))
|
||||||
readonly workspace?: Endpoint0_0Request["query"]["workspace"]
|
|
||||||
readonly limit?: Endpoint0_0Request["query"]["limit"]
|
const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw) })
|
||||||
readonly order?: Endpoint0_0Request["query"]["order"]
|
|
||||||
readonly search?: Endpoint0_0Request["query"]["search"]
|
type Endpoint1_0Request = Parameters<RawClient["server.location"]["location.get"]>[0]
|
||||||
readonly directory?: Endpoint0_0Request["query"]["directory"]
|
type Endpoint1_0Input = { readonly location?: Endpoint1_0Request["query"]["location"] }
|
||||||
readonly project?: Endpoint0_0Request["query"]["project"]
|
const Endpoint1_0 = (raw: RawClient["server.location"]) => (input?: Endpoint1_0Input) =>
|
||||||
readonly subpath?: Endpoint0_0Request["query"]["subpath"]
|
raw["location.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
readonly cursor?: Endpoint0_0Request["query"]["cursor"]
|
|
||||||
|
const adaptGroup1 = (raw: RawClient["server.location"]) => ({ get: Endpoint1_0(raw) })
|
||||||
|
|
||||||
|
type Endpoint2_0Request = Parameters<RawClient["server.agent"]["agent.list"]>[0]
|
||||||
|
type Endpoint2_0Input = { readonly location?: Endpoint2_0Request["query"]["location"] }
|
||||||
|
const Endpoint2_0 = (raw: RawClient["server.agent"]) => (input?: Endpoint2_0Input) =>
|
||||||
|
raw["agent.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
const adaptGroup2 = (raw: RawClient["server.agent"]) => ({ list: Endpoint2_0(raw) })
|
||||||
|
|
||||||
|
type Endpoint3_0Request = Parameters<RawClient["server.session"]["session.list"]>[0]
|
||||||
|
type Endpoint3_0Input = {
|
||||||
|
readonly workspace?: Endpoint3_0Request["query"]["workspace"]
|
||||||
|
readonly limit?: Endpoint3_0Request["query"]["limit"]
|
||||||
|
readonly order?: Endpoint3_0Request["query"]["order"]
|
||||||
|
readonly search?: Endpoint3_0Request["query"]["search"]
|
||||||
|
readonly directory?: Endpoint3_0Request["query"]["directory"]
|
||||||
|
readonly project?: Endpoint3_0Request["query"]["project"]
|
||||||
|
readonly subpath?: Endpoint3_0Request["query"]["subpath"]
|
||||||
|
readonly cursor?: Endpoint3_0Request["query"]["cursor"]
|
||||||
}
|
}
|
||||||
const Endpoint0_0 = (raw: RawClient["server.session"]) => (input?: Endpoint0_0Input) =>
|
const Endpoint3_0 = (raw: RawClient["server.session"]) => (input?: Endpoint3_0Input) =>
|
||||||
raw["session.list"]({
|
raw["session.list"]({
|
||||||
query: {
|
query: {
|
||||||
workspace: input?.workspace,
|
workspace: input?.["workspace"],
|
||||||
limit: input?.limit,
|
limit: input?.["limit"],
|
||||||
order: input?.order,
|
order: input?.["order"],
|
||||||
search: input?.search,
|
search: input?.["search"],
|
||||||
directory: input?.directory,
|
directory: input?.["directory"],
|
||||||
project: input?.project,
|
project: input?.["project"],
|
||||||
subpath: input?.subpath,
|
subpath: input?.["subpath"],
|
||||||
cursor: input?.cursor,
|
cursor: input?.["cursor"],
|
||||||
},
|
},
|
||||||
}).pipe(Effect.mapError(mapClientError))
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint0_1Request = Parameters<RawClient["server.session"]["session.create"]>[0]
|
type Endpoint3_1Request = Parameters<RawClient["server.session"]["session.create"]>[0]
|
||||||
type Endpoint0_1Input = {
|
type Endpoint3_1Input = {
|
||||||
readonly id?: Endpoint0_1Request["payload"]["id"]
|
readonly id?: Endpoint3_1Request["payload"]["id"]
|
||||||
readonly agent?: Endpoint0_1Request["payload"]["agent"]
|
readonly agent?: Endpoint3_1Request["payload"]["agent"]
|
||||||
readonly model?: Endpoint0_1Request["payload"]["model"]
|
readonly model?: Endpoint3_1Request["payload"]["model"]
|
||||||
readonly location?: Endpoint0_1Request["payload"]["location"]
|
readonly location?: Endpoint3_1Request["payload"]["location"]
|
||||||
}
|
}
|
||||||
const Endpoint0_1 = (raw: RawClient["server.session"]) => (input?: Endpoint0_1Input) =>
|
const Endpoint3_1 = (raw: RawClient["server.session"]) => (input?: Endpoint3_1Input) =>
|
||||||
raw["session.create"]({
|
raw["session.create"]({
|
||||||
payload: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location },
|
payload: { id: input?.["id"], agent: input?.["agent"], model: input?.["model"], location: input?.["location"] },
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint0_2 = (raw: RawClient["server.session"]) => () =>
|
const Endpoint3_2 = (raw: RawClient["server.session"]) => () =>
|
||||||
raw["session.active"]({}).pipe(
|
raw["session.active"]({}).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint0_3Request = Parameters<RawClient["server.session"]["session.get"]>[0]
|
type Endpoint3_3Request = Parameters<RawClient["server.session"]["session.get"]>[0]
|
||||||
type Endpoint0_3Input = { readonly sessionID: Endpoint0_3Request["params"]["sessionID"] }
|
type Endpoint3_3Input = { readonly sessionID: Endpoint3_3Request["params"]["sessionID"] }
|
||||||
const Endpoint0_3 = (raw: RawClient["server.session"]) => (input: Endpoint0_3Input) =>
|
const Endpoint3_3 = (raw: RawClient["server.session"]) => (input: Endpoint3_3Input) =>
|
||||||
raw["session.get"]({ params: { sessionID: input.sessionID } }).pipe(
|
raw["session.get"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint0_4Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0]
|
type Endpoint3_4Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0]
|
||||||
type Endpoint0_4Input = {
|
type Endpoint3_4Input = {
|
||||||
readonly sessionID: Endpoint0_4Request["params"]["sessionID"]
|
readonly sessionID: Endpoint3_4Request["params"]["sessionID"]
|
||||||
readonly agent: Endpoint0_4Request["payload"]["agent"]
|
readonly agent: Endpoint3_4Request["payload"]["agent"]
|
||||||
}
|
}
|
||||||
const Endpoint0_4 = (raw: RawClient["server.session"]) => (input: Endpoint0_4Input) =>
|
const Endpoint3_4 = (raw: RawClient["server.session"]) => (input: Endpoint3_4Input) =>
|
||||||
raw["session.switchAgent"]({ params: { sessionID: input.sessionID }, payload: { agent: input.agent } }).pipe(
|
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint0_5Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0]
|
type Endpoint3_5Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0]
|
||||||
type Endpoint0_5Input = {
|
type Endpoint3_5Input = {
|
||||||
readonly sessionID: Endpoint0_5Request["params"]["sessionID"]
|
readonly sessionID: Endpoint3_5Request["params"]["sessionID"]
|
||||||
readonly model: Endpoint0_5Request["payload"]["model"]
|
readonly model: Endpoint3_5Request["payload"]["model"]
|
||||||
}
|
}
|
||||||
const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Input) =>
|
const Endpoint3_5 = (raw: RawClient["server.session"]) => (input: Endpoint3_5Input) =>
|
||||||
raw["session.switchModel"]({ params: { sessionID: input.sessionID }, payload: { model: input.model } }).pipe(
|
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint0_6Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
|
type Endpoint3_6Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
|
||||||
type Endpoint0_6Input = {
|
type Endpoint3_6Input = {
|
||||||
readonly sessionID: Endpoint0_6Request["params"]["sessionID"]
|
readonly sessionID: Endpoint3_6Request["params"]["sessionID"]
|
||||||
readonly id?: Endpoint0_6Request["payload"]["id"]
|
readonly id?: Endpoint3_6Request["payload"]["id"]
|
||||||
readonly prompt: Endpoint0_6Request["payload"]["prompt"]
|
readonly prompt: Endpoint3_6Request["payload"]["prompt"]
|
||||||
readonly delivery?: Endpoint0_6Request["payload"]["delivery"]
|
readonly delivery?: Endpoint3_6Request["payload"]["delivery"]
|
||||||
readonly resume?: Endpoint0_6Request["payload"]["resume"]
|
readonly resume?: Endpoint3_6Request["payload"]["resume"]
|
||||||
}
|
}
|
||||||
const Endpoint0_6 = (raw: RawClient["server.session"]) => (input: Endpoint0_6Input) =>
|
const Endpoint3_6 = (raw: RawClient["server.session"]) => (input: Endpoint3_6Input) =>
|
||||||
raw["session.prompt"]({
|
raw["session.prompt"]({
|
||||||
params: { sessionID: input.sessionID },
|
params: { sessionID: input["sessionID"] },
|
||||||
payload: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume },
|
payload: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] },
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint0_7Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
type Endpoint3_7Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||||
type Endpoint0_7Input = { readonly sessionID: Endpoint0_7Request["params"]["sessionID"] }
|
type Endpoint3_7Input = { readonly sessionID: Endpoint3_7Request["params"]["sessionID"] }
|
||||||
const Endpoint0_7 = (raw: RawClient["server.session"]) => (input: Endpoint0_7Input) =>
|
const Endpoint3_7 = (raw: RawClient["server.session"]) => (input: Endpoint3_7Input) =>
|
||||||
raw["session.compact"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint0_8Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
type Endpoint3_8Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||||
type Endpoint0_8Input = { readonly sessionID: Endpoint0_8Request["params"]["sessionID"] }
|
type Endpoint3_8Input = { readonly sessionID: Endpoint3_8Request["params"]["sessionID"] }
|
||||||
const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Input) =>
|
const Endpoint3_8 = (raw: RawClient["server.session"]) => (input: Endpoint3_8Input) =>
|
||||||
raw["session.wait"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint0_9Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
type Endpoint3_9Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||||
type Endpoint0_9Input = {
|
type Endpoint3_9Input = {
|
||||||
readonly sessionID: Endpoint0_9Request["params"]["sessionID"]
|
readonly sessionID: Endpoint3_9Request["params"]["sessionID"]
|
||||||
readonly messageID: Endpoint0_9Request["payload"]["messageID"]
|
readonly messageID: Endpoint3_9Request["payload"]["messageID"]
|
||||||
readonly files?: Endpoint0_9Request["payload"]["files"]
|
readonly files?: Endpoint3_9Request["payload"]["files"]
|
||||||
}
|
}
|
||||||
const Endpoint0_9 = (raw: RawClient["server.session"]) => (input: Endpoint0_9Input) =>
|
const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Input) =>
|
||||||
raw["session.revert.stage"]({
|
raw["session.revert.stage"]({
|
||||||
params: { sessionID: input.sessionID },
|
params: { sessionID: input["sessionID"] },
|
||||||
payload: { messageID: input.messageID, files: input.files },
|
payload: { messageID: input["messageID"], files: input["files"] },
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint0_10Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
type Endpoint3_10Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||||
type Endpoint0_10Input = { readonly sessionID: Endpoint0_10Request["params"]["sessionID"] }
|
type Endpoint3_10Input = { readonly sessionID: Endpoint3_10Request["params"]["sessionID"] }
|
||||||
const Endpoint0_10 = (raw: RawClient["server.session"]) => (input: Endpoint0_10Input) =>
|
const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10Input) =>
|
||||||
raw["session.revert.clear"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint0_11Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
type Endpoint3_11Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
||||||
type Endpoint0_11Input = { readonly sessionID: Endpoint0_11Request["params"]["sessionID"] }
|
type Endpoint3_11Input = { readonly sessionID: Endpoint3_11Request["params"]["sessionID"] }
|
||||||
const Endpoint0_11 = (raw: RawClient["server.session"]) => (input: Endpoint0_11Input) =>
|
const Endpoint3_11 = (raw: RawClient["server.session"]) => (input: Endpoint3_11Input) =>
|
||||||
raw["session.revert.commit"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint0_12Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
type Endpoint3_12Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||||
type Endpoint0_12Input = { readonly sessionID: Endpoint0_12Request["params"]["sessionID"] }
|
type Endpoint3_12Input = { readonly sessionID: Endpoint3_12Request["params"]["sessionID"] }
|
||||||
const Endpoint0_12 = (raw: RawClient["server.session"]) => (input: Endpoint0_12Input) =>
|
const Endpoint3_12 = (raw: RawClient["server.session"]) => (input: Endpoint3_12Input) =>
|
||||||
raw["session.context"]({ params: { sessionID: input.sessionID } }).pipe(
|
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint0_13Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
type Endpoint3_13Request = Parameters<RawClient["server.session"]["session.history"]>[0]
|
||||||
type Endpoint0_13Input = {
|
type Endpoint3_13Input = {
|
||||||
readonly sessionID: Endpoint0_13Request["params"]["sessionID"]
|
readonly sessionID: Endpoint3_13Request["params"]["sessionID"]
|
||||||
readonly after?: Endpoint0_13Request["query"]["after"]
|
readonly limit?: Endpoint3_13Request["query"]["limit"]
|
||||||
|
readonly after?: Endpoint3_13Request["query"]["after"]
|
||||||
}
|
}
|
||||||
const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) =>
|
const Endpoint3_13 = (raw: RawClient["server.session"]) => (input: Endpoint3_13Input) =>
|
||||||
|
raw["session.history"]({
|
||||||
|
params: { sessionID: input["sessionID"] },
|
||||||
|
query: { limit: input["limit"], after: input["after"] },
|
||||||
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint3_14Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||||
|
type Endpoint3_14Input = {
|
||||||
|
readonly sessionID: Endpoint3_14Request["params"]["sessionID"]
|
||||||
|
readonly after?: Endpoint3_14Request["query"]["after"]
|
||||||
|
}
|
||||||
|
const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14Input) =>
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
raw["session.events"]({ params: { sessionID: input.sessionID }, query: { after: input.after } }).pipe(
|
raw["session.events"]({ params: { sessionID: input["sessionID"] }, query: { after: input["after"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
|
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint0_14Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
type Endpoint3_15Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||||
type Endpoint0_14Input = { readonly sessionID: Endpoint0_14Request["params"]["sessionID"] }
|
type Endpoint3_15Input = { readonly sessionID: Endpoint3_15Request["params"]["sessionID"] }
|
||||||
const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) =>
|
const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) =>
|
||||||
raw["session.interrupt"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint0_15Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
type Endpoint3_16Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||||
type Endpoint0_15Input = {
|
type Endpoint3_16Input = {
|
||||||
readonly sessionID: Endpoint0_15Request["params"]["sessionID"]
|
readonly sessionID: Endpoint3_16Request["params"]["sessionID"]
|
||||||
readonly messageID: Endpoint0_15Request["params"]["messageID"]
|
readonly messageID: Endpoint3_16Request["params"]["messageID"]
|
||||||
}
|
}
|
||||||
const Endpoint0_15 = (raw: RawClient["server.session"]) => (input: Endpoint0_15Input) =>
|
const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16Input) =>
|
||||||
raw["session.message"]({ params: { sessionID: input.sessionID, messageID: input.messageID } }).pipe(
|
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
)
|
)
|
||||||
|
|
||||||
const adaptGroup0 = (raw: RawClient["server.session"]) => ({
|
const adaptGroup3 = (raw: RawClient["server.session"]) => ({
|
||||||
list: Endpoint0_0(raw),
|
list: Endpoint3_0(raw),
|
||||||
create: Endpoint0_1(raw),
|
create: Endpoint3_1(raw),
|
||||||
active: Endpoint0_2(raw),
|
active: Endpoint3_2(raw),
|
||||||
get: Endpoint0_3(raw),
|
get: Endpoint3_3(raw),
|
||||||
switchAgent: Endpoint0_4(raw),
|
switchAgent: Endpoint3_4(raw),
|
||||||
switchModel: Endpoint0_5(raw),
|
switchModel: Endpoint3_5(raw),
|
||||||
prompt: Endpoint0_6(raw),
|
prompt: Endpoint3_6(raw),
|
||||||
compact: Endpoint0_7(raw),
|
compact: Endpoint3_7(raw),
|
||||||
wait: Endpoint0_8(raw),
|
wait: Endpoint3_8(raw),
|
||||||
stage: Endpoint0_9(raw),
|
stage: Endpoint3_9(raw),
|
||||||
clear: Endpoint0_10(raw),
|
clear: Endpoint3_10(raw),
|
||||||
commit: Endpoint0_11(raw),
|
commit: Endpoint3_11(raw),
|
||||||
context: Endpoint0_12(raw),
|
context: Endpoint3_12(raw),
|
||||||
events: Endpoint0_13(raw),
|
history: Endpoint3_13(raw),
|
||||||
interrupt: Endpoint0_14(raw),
|
events: Endpoint3_14(raw),
|
||||||
message: Endpoint0_15(raw),
|
interrupt: Endpoint3_15(raw),
|
||||||
|
message: Endpoint3_16(raw),
|
||||||
})
|
})
|
||||||
|
|
||||||
const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) })
|
type Endpoint4_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
|
||||||
|
type Endpoint4_0Input = {
|
||||||
|
readonly sessionID: Endpoint4_0Request["params"]["sessionID"]
|
||||||
|
readonly limit?: Endpoint4_0Request["query"]["limit"]
|
||||||
|
readonly order?: Endpoint4_0Request["query"]["order"]
|
||||||
|
readonly cursor?: Endpoint4_0Request["query"]["cursor"]
|
||||||
|
}
|
||||||
|
const Endpoint4_0 = (raw: RawClient["server.message"]) => (input: Endpoint4_0Input) =>
|
||||||
|
raw["session.messages"]({
|
||||||
|
params: { sessionID: input["sessionID"] },
|
||||||
|
query: { limit: input["limit"], order: input["order"], cursor: input["cursor"] },
|
||||||
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
const adaptGroup4 = (raw: RawClient["server.message"]) => ({ list: Endpoint4_0(raw) })
|
||||||
|
|
||||||
|
type Endpoint5_0Request = Parameters<RawClient["server.model"]["model.list"]>[0]
|
||||||
|
type Endpoint5_0Input = { readonly location?: Endpoint5_0Request["query"]["location"] }
|
||||||
|
const Endpoint5_0 = (raw: RawClient["server.model"]) => (input?: Endpoint5_0Input) =>
|
||||||
|
raw["model.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
const adaptGroup5 = (raw: RawClient["server.model"]) => ({ list: Endpoint5_0(raw) })
|
||||||
|
|
||||||
|
type Endpoint6_0Request = Parameters<RawClient["server.provider"]["provider.list"]>[0]
|
||||||
|
type Endpoint6_0Input = { readonly location?: Endpoint6_0Request["query"]["location"] }
|
||||||
|
const Endpoint6_0 = (raw: RawClient["server.provider"]) => (input?: Endpoint6_0Input) =>
|
||||||
|
raw["provider.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint6_1Request = Parameters<RawClient["server.provider"]["provider.get"]>[0]
|
||||||
|
type Endpoint6_1Input = {
|
||||||
|
readonly providerID: Endpoint6_1Request["params"]["providerID"]
|
||||||
|
readonly location?: Endpoint6_1Request["query"]["location"]
|
||||||
|
}
|
||||||
|
const Endpoint6_1 = (raw: RawClient["server.provider"]) => (input: Endpoint6_1Input) =>
|
||||||
|
raw["provider.get"]({ params: { providerID: input["providerID"] }, query: { location: input["location"] } }).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
)
|
||||||
|
|
||||||
|
const adaptGroup6 = (raw: RawClient["server.provider"]) => ({ list: Endpoint6_0(raw), get: Endpoint6_1(raw) })
|
||||||
|
|
||||||
|
type Endpoint7_0Request = Parameters<RawClient["server.integration"]["integration.list"]>[0]
|
||||||
|
type Endpoint7_0Input = { readonly location?: Endpoint7_0Request["query"]["location"] }
|
||||||
|
const Endpoint7_0 = (raw: RawClient["server.integration"]) => (input?: Endpoint7_0Input) =>
|
||||||
|
raw["integration.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint7_1Request = Parameters<RawClient["server.integration"]["integration.get"]>[0]
|
||||||
|
type Endpoint7_1Input = {
|
||||||
|
readonly integrationID: Endpoint7_1Request["params"]["integrationID"]
|
||||||
|
readonly location?: Endpoint7_1Request["query"]["location"]
|
||||||
|
}
|
||||||
|
const Endpoint7_1 = (raw: RawClient["server.integration"]) => (input: Endpoint7_1Input) =>
|
||||||
|
raw["integration.get"]({
|
||||||
|
params: { integrationID: input["integrationID"] },
|
||||||
|
query: { location: input["location"] },
|
||||||
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint7_2Request = Parameters<RawClient["server.integration"]["integration.connect.key"]>[0]
|
||||||
|
type Endpoint7_2Input = {
|
||||||
|
readonly integrationID: Endpoint7_2Request["params"]["integrationID"]
|
||||||
|
readonly location?: Endpoint7_2Request["query"]["location"]
|
||||||
|
readonly key: Endpoint7_2Request["payload"]["key"]
|
||||||
|
readonly label?: Endpoint7_2Request["payload"]["label"]
|
||||||
|
}
|
||||||
|
const Endpoint7_2 = (raw: RawClient["server.integration"]) => (input: Endpoint7_2Input) =>
|
||||||
|
raw["integration.connect.key"]({
|
||||||
|
params: { integrationID: input["integrationID"] },
|
||||||
|
query: { location: input["location"] },
|
||||||
|
payload: { key: input["key"], label: input["label"] },
|
||||||
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint7_3Request = Parameters<RawClient["server.integration"]["integration.connect.oauth"]>[0]
|
||||||
|
type Endpoint7_3Input = {
|
||||||
|
readonly integrationID: Endpoint7_3Request["params"]["integrationID"]
|
||||||
|
readonly location?: Endpoint7_3Request["query"]["location"]
|
||||||
|
readonly methodID: Endpoint7_3Request["payload"]["methodID"]
|
||||||
|
readonly inputs: Endpoint7_3Request["payload"]["inputs"]
|
||||||
|
readonly label?: Endpoint7_3Request["payload"]["label"]
|
||||||
|
}
|
||||||
|
const Endpoint7_3 = (raw: RawClient["server.integration"]) => (input: Endpoint7_3Input) =>
|
||||||
|
raw["integration.connect.oauth"]({
|
||||||
|
params: { integrationID: input["integrationID"] },
|
||||||
|
query: { location: input["location"] },
|
||||||
|
payload: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] },
|
||||||
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint7_4Request = Parameters<RawClient["server.integration"]["integration.attempt.status"]>[0]
|
||||||
|
type Endpoint7_4Input = {
|
||||||
|
readonly attemptID: Endpoint7_4Request["params"]["attemptID"]
|
||||||
|
readonly location?: Endpoint7_4Request["query"]["location"]
|
||||||
|
}
|
||||||
|
const Endpoint7_4 = (raw: RawClient["server.integration"]) => (input: Endpoint7_4Input) =>
|
||||||
|
raw["integration.attempt.status"]({
|
||||||
|
params: { attemptID: input["attemptID"] },
|
||||||
|
query: { location: input["location"] },
|
||||||
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint7_5Request = Parameters<RawClient["server.integration"]["integration.attempt.complete"]>[0]
|
||||||
|
type Endpoint7_5Input = {
|
||||||
|
readonly attemptID: Endpoint7_5Request["params"]["attemptID"]
|
||||||
|
readonly location?: Endpoint7_5Request["query"]["location"]
|
||||||
|
readonly code?: Endpoint7_5Request["payload"]["code"]
|
||||||
|
}
|
||||||
|
const Endpoint7_5 = (raw: RawClient["server.integration"]) => (input: Endpoint7_5Input) =>
|
||||||
|
raw["integration.attempt.complete"]({
|
||||||
|
params: { attemptID: input["attemptID"] },
|
||||||
|
query: { location: input["location"] },
|
||||||
|
payload: { code: input["code"] },
|
||||||
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint7_6Request = Parameters<RawClient["server.integration"]["integration.attempt.cancel"]>[0]
|
||||||
|
type Endpoint7_6Input = {
|
||||||
|
readonly attemptID: Endpoint7_6Request["params"]["attemptID"]
|
||||||
|
readonly location?: Endpoint7_6Request["query"]["location"]
|
||||||
|
}
|
||||||
|
const Endpoint7_6 = (raw: RawClient["server.integration"]) => (input: Endpoint7_6Input) =>
|
||||||
|
raw["integration.attempt.cancel"]({
|
||||||
|
params: { attemptID: input["attemptID"] },
|
||||||
|
query: { location: input["location"] },
|
||||||
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
const adaptGroup7 = (raw: RawClient["server.integration"]) => ({
|
||||||
|
list: Endpoint7_0(raw),
|
||||||
|
get: Endpoint7_1(raw),
|
||||||
|
connectKey: Endpoint7_2(raw),
|
||||||
|
connectOauth: Endpoint7_3(raw),
|
||||||
|
attemptStatus: Endpoint7_4(raw),
|
||||||
|
attemptComplete: Endpoint7_5(raw),
|
||||||
|
attemptCancel: Endpoint7_6(raw),
|
||||||
|
})
|
||||||
|
|
||||||
|
type Endpoint8_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
|
||||||
|
type Endpoint8_0Input = {
|
||||||
|
readonly credentialID: Endpoint8_0Request["params"]["credentialID"]
|
||||||
|
readonly location?: Endpoint8_0Request["query"]["location"]
|
||||||
|
readonly label: Endpoint8_0Request["payload"]["label"]
|
||||||
|
}
|
||||||
|
const Endpoint8_0 = (raw: RawClient["server.credential"]) => (input: Endpoint8_0Input) =>
|
||||||
|
raw["credential.update"]({
|
||||||
|
params: { credentialID: input["credentialID"] },
|
||||||
|
query: { location: input["location"] },
|
||||||
|
payload: { label: input["label"] },
|
||||||
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint8_1Request = Parameters<RawClient["server.credential"]["credential.remove"]>[0]
|
||||||
|
type Endpoint8_1Input = {
|
||||||
|
readonly credentialID: Endpoint8_1Request["params"]["credentialID"]
|
||||||
|
readonly location?: Endpoint8_1Request["query"]["location"]
|
||||||
|
}
|
||||||
|
const Endpoint8_1 = (raw: RawClient["server.credential"]) => (input: Endpoint8_1Input) =>
|
||||||
|
raw["credential.remove"]({
|
||||||
|
params: { credentialID: input["credentialID"] },
|
||||||
|
query: { location: input["location"] },
|
||||||
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
const adaptGroup8 = (raw: RawClient["server.credential"]) => ({ update: Endpoint8_0(raw), remove: Endpoint8_1(raw) })
|
||||||
|
|
||||||
|
type Endpoint9_0Request = Parameters<RawClient["server.permission"]["permission.request.list"]>[0]
|
||||||
|
type Endpoint9_0Input = { readonly location?: Endpoint9_0Request["query"]["location"] }
|
||||||
|
const Endpoint9_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint9_0Input) =>
|
||||||
|
raw["permission.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint9_1Request = Parameters<RawClient["server.permission"]["permission.saved.list"]>[0]
|
||||||
|
type Endpoint9_1Input = { readonly projectID?: Endpoint9_1Request["query"]["projectID"] }
|
||||||
|
const Endpoint9_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint9_1Input) =>
|
||||||
|
raw["permission.saved.list"]({ query: { projectID: input?.["projectID"] } }).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
Effect.map((value) => value.data),
|
||||||
|
)
|
||||||
|
|
||||||
|
type Endpoint9_2Request = Parameters<RawClient["server.permission"]["permission.saved.remove"]>[0]
|
||||||
|
type Endpoint9_2Input = { readonly id: Endpoint9_2Request["params"]["id"] }
|
||||||
|
const Endpoint9_2 = (raw: RawClient["server.permission"]) => (input: Endpoint9_2Input) =>
|
||||||
|
raw["permission.saved.remove"]({ params: { id: input["id"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint9_3Request = Parameters<RawClient["server.permission"]["session.permission.create"]>[0]
|
||||||
|
type Endpoint9_3Input = {
|
||||||
|
readonly sessionID: Endpoint9_3Request["params"]["sessionID"]
|
||||||
|
readonly id?: Endpoint9_3Request["payload"]["id"]
|
||||||
|
readonly action: Endpoint9_3Request["payload"]["action"]
|
||||||
|
readonly resources: Endpoint9_3Request["payload"]["resources"]
|
||||||
|
readonly save?: Endpoint9_3Request["payload"]["save"]
|
||||||
|
readonly metadata?: Endpoint9_3Request["payload"]["metadata"]
|
||||||
|
readonly source?: Endpoint9_3Request["payload"]["source"]
|
||||||
|
readonly agent?: Endpoint9_3Request["payload"]["agent"]
|
||||||
|
}
|
||||||
|
const Endpoint9_3 = (raw: RawClient["server.permission"]) => (input: Endpoint9_3Input) =>
|
||||||
|
raw["session.permission.create"]({
|
||||||
|
params: { sessionID: input["sessionID"] },
|
||||||
|
payload: {
|
||||||
|
id: input["id"],
|
||||||
|
action: input["action"],
|
||||||
|
resources: input["resources"],
|
||||||
|
save: input["save"],
|
||||||
|
metadata: input["metadata"],
|
||||||
|
source: input["source"],
|
||||||
|
agent: input["agent"],
|
||||||
|
},
|
||||||
|
}).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
Effect.map((value) => value.data),
|
||||||
|
)
|
||||||
|
|
||||||
|
type Endpoint9_4Request = Parameters<RawClient["server.permission"]["session.permission.list"]>[0]
|
||||||
|
type Endpoint9_4Input = { readonly sessionID: Endpoint9_4Request["params"]["sessionID"] }
|
||||||
|
const Endpoint9_4 = (raw: RawClient["server.permission"]) => (input: Endpoint9_4Input) =>
|
||||||
|
raw["session.permission.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
Effect.map((value) => value.data),
|
||||||
|
)
|
||||||
|
|
||||||
|
type Endpoint9_5Request = Parameters<RawClient["server.permission"]["session.permission.get"]>[0]
|
||||||
|
type Endpoint9_5Input = {
|
||||||
|
readonly sessionID: Endpoint9_5Request["params"]["sessionID"]
|
||||||
|
readonly requestID: Endpoint9_5Request["params"]["requestID"]
|
||||||
|
}
|
||||||
|
const Endpoint9_5 = (raw: RawClient["server.permission"]) => (input: Endpoint9_5Input) =>
|
||||||
|
raw["session.permission.get"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
Effect.map((value) => value.data),
|
||||||
|
)
|
||||||
|
|
||||||
|
type Endpoint9_6Request = Parameters<RawClient["server.permission"]["session.permission.reply"]>[0]
|
||||||
|
type Endpoint9_6Input = {
|
||||||
|
readonly sessionID: Endpoint9_6Request["params"]["sessionID"]
|
||||||
|
readonly requestID: Endpoint9_6Request["params"]["requestID"]
|
||||||
|
readonly reply: Endpoint9_6Request["payload"]["reply"]
|
||||||
|
readonly message?: Endpoint9_6Request["payload"]["message"]
|
||||||
|
}
|
||||||
|
const Endpoint9_6 = (raw: RawClient["server.permission"]) => (input: Endpoint9_6Input) =>
|
||||||
|
raw["session.permission.reply"]({
|
||||||
|
params: { sessionID: input["sessionID"], requestID: input["requestID"] },
|
||||||
|
payload: { reply: input["reply"], message: input["message"] },
|
||||||
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
const adaptGroup9 = (raw: RawClient["server.permission"]) => ({
|
||||||
|
listRequests: Endpoint9_0(raw),
|
||||||
|
listSaved: Endpoint9_1(raw),
|
||||||
|
removeSaved: Endpoint9_2(raw),
|
||||||
|
create: Endpoint9_3(raw),
|
||||||
|
list: Endpoint9_4(raw),
|
||||||
|
get: Endpoint9_5(raw),
|
||||||
|
reply: Endpoint9_6(raw),
|
||||||
|
})
|
||||||
|
|
||||||
|
type Endpoint10_0Request = Parameters<RawClient["server.fs"]["fs.list"]>[0]
|
||||||
|
type Endpoint10_0Input = {
|
||||||
|
readonly location?: Endpoint10_0Request["query"]["location"]
|
||||||
|
readonly path?: Endpoint10_0Request["query"]["path"]
|
||||||
|
}
|
||||||
|
const Endpoint10_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint10_0Input) =>
|
||||||
|
raw["fs.list"]({ query: { location: input?.["location"], path: input?.["path"] } }).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
)
|
||||||
|
|
||||||
|
type Endpoint10_1Request = Parameters<RawClient["server.fs"]["fs.find"]>[0]
|
||||||
|
type Endpoint10_1Input = {
|
||||||
|
readonly location?: Endpoint10_1Request["query"]["location"]
|
||||||
|
readonly query: Endpoint10_1Request["query"]["query"]
|
||||||
|
readonly type?: Endpoint10_1Request["query"]["type"]
|
||||||
|
readonly limit?: Endpoint10_1Request["query"]["limit"]
|
||||||
|
}
|
||||||
|
const Endpoint10_1 = (raw: RawClient["server.fs"]) => (input: Endpoint10_1Input) =>
|
||||||
|
raw["fs.find"]({
|
||||||
|
query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] },
|
||||||
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
const adaptGroup10 = (raw: RawClient["server.fs"]) => ({ list: Endpoint10_0(raw), find: Endpoint10_1(raw) })
|
||||||
|
|
||||||
|
type Endpoint11_0Request = Parameters<RawClient["server.command"]["command.list"]>[0]
|
||||||
|
type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["location"] }
|
||||||
|
const Endpoint11_0 = (raw: RawClient["server.command"]) => (input?: Endpoint11_0Input) =>
|
||||||
|
raw["command.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
const adaptGroup11 = (raw: RawClient["server.command"]) => ({ list: Endpoint11_0(raw) })
|
||||||
|
|
||||||
|
type Endpoint12_0Request = Parameters<RawClient["server.skill"]["skill.list"]>[0]
|
||||||
|
type Endpoint12_0Input = { readonly location?: Endpoint12_0Request["query"]["location"] }
|
||||||
|
const Endpoint12_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint12_0Input) =>
|
||||||
|
raw["skill.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
const adaptGroup12 = (raw: RawClient["server.skill"]) => ({ list: Endpoint12_0(raw) })
|
||||||
|
|
||||||
|
const Endpoint13_0 = (raw: RawClient["server.event"]) => () =>
|
||||||
|
Stream.unwrap(
|
||||||
|
raw["event.subscribe"]({}).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const adaptGroup13 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint13_0(raw) })
|
||||||
|
|
||||||
|
type Endpoint14_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
|
||||||
|
type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] }
|
||||||
|
const Endpoint14_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint14_0Input) =>
|
||||||
|
raw["pty.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint14_1Request = Parameters<RawClient["server.pty"]["pty.create"]>[0]
|
||||||
|
type Endpoint14_1Input = {
|
||||||
|
readonly location?: Endpoint14_1Request["query"]["location"]
|
||||||
|
readonly command?: Endpoint14_1Request["payload"]["command"]
|
||||||
|
readonly args?: Endpoint14_1Request["payload"]["args"]
|
||||||
|
readonly cwd?: Endpoint14_1Request["payload"]["cwd"]
|
||||||
|
readonly title?: Endpoint14_1Request["payload"]["title"]
|
||||||
|
readonly env?: Endpoint14_1Request["payload"]["env"]
|
||||||
|
}
|
||||||
|
const Endpoint14_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint14_1Input) =>
|
||||||
|
raw["pty.create"]({
|
||||||
|
query: { location: input?.["location"] },
|
||||||
|
payload: {
|
||||||
|
command: input?.["command"],
|
||||||
|
args: input?.["args"],
|
||||||
|
cwd: input?.["cwd"],
|
||||||
|
title: input?.["title"],
|
||||||
|
env: input?.["env"],
|
||||||
|
},
|
||||||
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint14_2Request = Parameters<RawClient["server.pty"]["pty.get"]>[0]
|
||||||
|
type Endpoint14_2Input = {
|
||||||
|
readonly ptyID: Endpoint14_2Request["params"]["ptyID"]
|
||||||
|
readonly location?: Endpoint14_2Request["query"]["location"]
|
||||||
|
}
|
||||||
|
const Endpoint14_2 = (raw: RawClient["server.pty"]) => (input: Endpoint14_2Input) =>
|
||||||
|
raw["pty.get"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
)
|
||||||
|
|
||||||
|
type Endpoint14_3Request = Parameters<RawClient["server.pty"]["pty.update"]>[0]
|
||||||
|
type Endpoint14_3Input = {
|
||||||
|
readonly ptyID: Endpoint14_3Request["params"]["ptyID"]
|
||||||
|
readonly location?: Endpoint14_3Request["query"]["location"]
|
||||||
|
readonly title?: Endpoint14_3Request["payload"]["title"]
|
||||||
|
readonly size?: Endpoint14_3Request["payload"]["size"]
|
||||||
|
}
|
||||||
|
const Endpoint14_3 = (raw: RawClient["server.pty"]) => (input: Endpoint14_3Input) =>
|
||||||
|
raw["pty.update"]({
|
||||||
|
params: { ptyID: input["ptyID"] },
|
||||||
|
query: { location: input["location"] },
|
||||||
|
payload: { title: input["title"], size: input["size"] },
|
||||||
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint14_4Request = Parameters<RawClient["server.pty"]["pty.remove"]>[0]
|
||||||
|
type Endpoint14_4Input = {
|
||||||
|
readonly ptyID: Endpoint14_4Request["params"]["ptyID"]
|
||||||
|
readonly location?: Endpoint14_4Request["query"]["location"]
|
||||||
|
}
|
||||||
|
const Endpoint14_4 = (raw: RawClient["server.pty"]) => (input: Endpoint14_4Input) =>
|
||||||
|
raw["pty.remove"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
)
|
||||||
|
|
||||||
|
const adaptGroup14 = (raw: RawClient["server.pty"]) => ({
|
||||||
|
list: Endpoint14_0(raw),
|
||||||
|
create: Endpoint14_1(raw),
|
||||||
|
get: Endpoint14_2(raw),
|
||||||
|
update: Endpoint14_3(raw),
|
||||||
|
remove: Endpoint14_4(raw),
|
||||||
|
})
|
||||||
|
|
||||||
|
type Endpoint15_0Request = Parameters<RawClient["server.question"]["question.request.list"]>[0]
|
||||||
|
type Endpoint15_0Input = { readonly location?: Endpoint15_0Request["query"]["location"] }
|
||||||
|
const Endpoint15_0 = (raw: RawClient["server.question"]) => (input?: Endpoint15_0Input) =>
|
||||||
|
raw["question.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint15_1Request = Parameters<RawClient["server.question"]["session.question.list"]>[0]
|
||||||
|
type Endpoint15_1Input = { readonly sessionID: Endpoint15_1Request["params"]["sessionID"] }
|
||||||
|
const Endpoint15_1 = (raw: RawClient["server.question"]) => (input: Endpoint15_1Input) =>
|
||||||
|
raw["session.question.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
Effect.map((value) => value.data),
|
||||||
|
)
|
||||||
|
|
||||||
|
type Endpoint15_2Request = Parameters<RawClient["server.question"]["session.question.reply"]>[0]
|
||||||
|
type Endpoint15_2Input = {
|
||||||
|
readonly sessionID: Endpoint15_2Request["params"]["sessionID"]
|
||||||
|
readonly requestID: Endpoint15_2Request["params"]["requestID"]
|
||||||
|
readonly answers: Endpoint15_2Request["payload"]["answers"]
|
||||||
|
}
|
||||||
|
const Endpoint15_2 = (raw: RawClient["server.question"]) => (input: Endpoint15_2Input) =>
|
||||||
|
raw["session.question.reply"]({
|
||||||
|
params: { sessionID: input["sessionID"], requestID: input["requestID"] },
|
||||||
|
payload: { answers: input["answers"] },
|
||||||
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint15_3Request = Parameters<RawClient["server.question"]["session.question.reject"]>[0]
|
||||||
|
type Endpoint15_3Input = {
|
||||||
|
readonly sessionID: Endpoint15_3Request["params"]["sessionID"]
|
||||||
|
readonly requestID: Endpoint15_3Request["params"]["requestID"]
|
||||||
|
}
|
||||||
|
const Endpoint15_3 = (raw: RawClient["server.question"]) => (input: Endpoint15_3Input) =>
|
||||||
|
raw["session.question.reject"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
)
|
||||||
|
|
||||||
|
const adaptGroup15 = (raw: RawClient["server.question"]) => ({
|
||||||
|
listRequests: Endpoint15_0(raw),
|
||||||
|
list: Endpoint15_1(raw),
|
||||||
|
reply: Endpoint15_2(raw),
|
||||||
|
reject: Endpoint15_3(raw),
|
||||||
|
})
|
||||||
|
|
||||||
|
type Endpoint16_0Request = Parameters<RawClient["server.reference"]["reference.list"]>[0]
|
||||||
|
type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] }
|
||||||
|
const Endpoint16_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint16_0Input) =>
|
||||||
|
raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
const adaptGroup16 = (raw: RawClient["server.reference"]) => ({ list: Endpoint16_0(raw) })
|
||||||
|
|
||||||
|
type Endpoint17_0Request = Parameters<RawClient["server.projectCopy"]["projectCopy.create"]>[0]
|
||||||
|
type Endpoint17_0Input = {
|
||||||
|
readonly projectID: Endpoint17_0Request["params"]["projectID"]
|
||||||
|
readonly location?: Endpoint17_0Request["query"]["location"]
|
||||||
|
readonly strategy: Endpoint17_0Request["payload"]["strategy"]
|
||||||
|
readonly directory: Endpoint17_0Request["payload"]["directory"]
|
||||||
|
readonly name?: Endpoint17_0Request["payload"]["name"]
|
||||||
|
}
|
||||||
|
const Endpoint17_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_0Input) =>
|
||||||
|
raw["projectCopy.create"]({
|
||||||
|
params: { projectID: input["projectID"] },
|
||||||
|
query: { location: input["location"] },
|
||||||
|
payload: { strategy: input["strategy"], directory: input["directory"], name: input["name"] },
|
||||||
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint17_1Request = Parameters<RawClient["server.projectCopy"]["projectCopy.remove"]>[0]
|
||||||
|
type Endpoint17_1Input = {
|
||||||
|
readonly projectID: Endpoint17_1Request["params"]["projectID"]
|
||||||
|
readonly location?: Endpoint17_1Request["query"]["location"]
|
||||||
|
readonly directory: Endpoint17_1Request["payload"]["directory"]
|
||||||
|
readonly force: Endpoint17_1Request["payload"]["force"]
|
||||||
|
}
|
||||||
|
const Endpoint17_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_1Input) =>
|
||||||
|
raw["projectCopy.remove"]({
|
||||||
|
params: { projectID: input["projectID"] },
|
||||||
|
query: { location: input["location"] },
|
||||||
|
payload: { directory: input["directory"], force: input["force"] },
|
||||||
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint17_2Request = Parameters<RawClient["server.projectCopy"]["projectCopy.refresh"]>[0]
|
||||||
|
type Endpoint17_2Input = {
|
||||||
|
readonly projectID: Endpoint17_2Request["params"]["projectID"]
|
||||||
|
readonly location?: Endpoint17_2Request["query"]["location"]
|
||||||
|
}
|
||||||
|
const Endpoint17_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_2Input) =>
|
||||||
|
raw["projectCopy.refresh"]({
|
||||||
|
params: { projectID: input["projectID"] },
|
||||||
|
query: { location: input["location"] },
|
||||||
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
const adaptGroup17 = (raw: RawClient["server.projectCopy"]) => ({
|
||||||
|
create: Endpoint17_0(raw),
|
||||||
|
remove: Endpoint17_1(raw),
|
||||||
|
refresh: Endpoint17_2(raw),
|
||||||
|
})
|
||||||
|
|
||||||
|
const adaptClient = (raw: RawClient) => ({
|
||||||
|
health: adaptGroup0(raw["server.health"]),
|
||||||
|
location: adaptGroup1(raw["server.location"]),
|
||||||
|
agents: adaptGroup2(raw["server.agent"]),
|
||||||
|
sessions: adaptGroup3(raw["server.session"]),
|
||||||
|
messages: adaptGroup4(raw["server.message"]),
|
||||||
|
models: adaptGroup5(raw["server.model"]),
|
||||||
|
providers: adaptGroup6(raw["server.provider"]),
|
||||||
|
integrations: adaptGroup7(raw["server.integration"]),
|
||||||
|
credentials: adaptGroup8(raw["server.credential"]),
|
||||||
|
permissions: adaptGroup9(raw["server.permission"]),
|
||||||
|
files: adaptGroup10(raw["server.fs"]),
|
||||||
|
commands: adaptGroup11(raw["server.command"]),
|
||||||
|
skills: adaptGroup12(raw["server.skill"]),
|
||||||
|
events: adaptGroup13(raw["server.event"]),
|
||||||
|
ptys: adaptGroup14(raw["server.pty"]),
|
||||||
|
questions: adaptGroup15(raw["server.question"]),
|
||||||
|
references: adaptGroup16(raw["server.reference"]),
|
||||||
|
projectCopies: adaptGroup17(raw["server.projectCopy"]),
|
||||||
|
})
|
||||||
|
|
||||||
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
||||||
HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient))
|
HttpApiClient.make(ClientApi, options).pipe(Effect.map(adaptClient))
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import type {
|
import type {
|
||||||
|
HealthGetOutput,
|
||||||
|
LocationGetInput,
|
||||||
|
LocationGetOutput,
|
||||||
|
AgentsListInput,
|
||||||
|
AgentsListOutput,
|
||||||
SessionsListInput,
|
SessionsListInput,
|
||||||
SessionsListOutput,
|
SessionsListOutput,
|
||||||
SessionsCreateInput,
|
SessionsCreateInput,
|
||||||
@@ -24,12 +29,89 @@ import type {
|
|||||||
SessionsCommitOutput,
|
SessionsCommitOutput,
|
||||||
SessionsContextInput,
|
SessionsContextInput,
|
||||||
SessionsContextOutput,
|
SessionsContextOutput,
|
||||||
|
SessionsHistoryInput,
|
||||||
|
SessionsHistoryOutput,
|
||||||
SessionsEventsInput,
|
SessionsEventsInput,
|
||||||
SessionsEventsOutput,
|
SessionsEventsOutput,
|
||||||
SessionsInterruptInput,
|
SessionsInterruptInput,
|
||||||
SessionsInterruptOutput,
|
SessionsInterruptOutput,
|
||||||
SessionsMessageInput,
|
SessionsMessageInput,
|
||||||
SessionsMessageOutput,
|
SessionsMessageOutput,
|
||||||
|
MessagesListInput,
|
||||||
|
MessagesListOutput,
|
||||||
|
ModelsListInput,
|
||||||
|
ModelsListOutput,
|
||||||
|
ProvidersListInput,
|
||||||
|
ProvidersListOutput,
|
||||||
|
ProvidersGetInput,
|
||||||
|
ProvidersGetOutput,
|
||||||
|
IntegrationsListInput,
|
||||||
|
IntegrationsListOutput,
|
||||||
|
IntegrationsGetInput,
|
||||||
|
IntegrationsGetOutput,
|
||||||
|
IntegrationsConnectKeyInput,
|
||||||
|
IntegrationsConnectKeyOutput,
|
||||||
|
IntegrationsConnectOauthInput,
|
||||||
|
IntegrationsConnectOauthOutput,
|
||||||
|
IntegrationsAttemptStatusInput,
|
||||||
|
IntegrationsAttemptStatusOutput,
|
||||||
|
IntegrationsAttemptCompleteInput,
|
||||||
|
IntegrationsAttemptCompleteOutput,
|
||||||
|
IntegrationsAttemptCancelInput,
|
||||||
|
IntegrationsAttemptCancelOutput,
|
||||||
|
CredentialsUpdateInput,
|
||||||
|
CredentialsUpdateOutput,
|
||||||
|
CredentialsRemoveInput,
|
||||||
|
CredentialsRemoveOutput,
|
||||||
|
PermissionsListRequestsInput,
|
||||||
|
PermissionsListRequestsOutput,
|
||||||
|
PermissionsListSavedInput,
|
||||||
|
PermissionsListSavedOutput,
|
||||||
|
PermissionsRemoveSavedInput,
|
||||||
|
PermissionsRemoveSavedOutput,
|
||||||
|
PermissionsCreateInput,
|
||||||
|
PermissionsCreateOutput,
|
||||||
|
PermissionsListInput,
|
||||||
|
PermissionsListOutput,
|
||||||
|
PermissionsGetInput,
|
||||||
|
PermissionsGetOutput,
|
||||||
|
PermissionsReplyInput,
|
||||||
|
PermissionsReplyOutput,
|
||||||
|
FilesListInput,
|
||||||
|
FilesListOutput,
|
||||||
|
FilesFindInput,
|
||||||
|
FilesFindOutput,
|
||||||
|
CommandsListInput,
|
||||||
|
CommandsListOutput,
|
||||||
|
SkillsListInput,
|
||||||
|
SkillsListOutput,
|
||||||
|
EventsSubscribeOutput,
|
||||||
|
PtysListInput,
|
||||||
|
PtysListOutput,
|
||||||
|
PtysCreateInput,
|
||||||
|
PtysCreateOutput,
|
||||||
|
PtysGetInput,
|
||||||
|
PtysGetOutput,
|
||||||
|
PtysUpdateInput,
|
||||||
|
PtysUpdateOutput,
|
||||||
|
PtysRemoveInput,
|
||||||
|
PtysRemoveOutput,
|
||||||
|
QuestionsListRequestsInput,
|
||||||
|
QuestionsListRequestsOutput,
|
||||||
|
QuestionsListInput,
|
||||||
|
QuestionsListOutput,
|
||||||
|
QuestionsReplyInput,
|
||||||
|
QuestionsReplyOutput,
|
||||||
|
QuestionsRejectInput,
|
||||||
|
QuestionsRejectOutput,
|
||||||
|
ReferencesListInput,
|
||||||
|
ReferencesListOutput,
|
||||||
|
ProjectCopiesCreateInput,
|
||||||
|
ProjectCopiesCreateOutput,
|
||||||
|
ProjectCopiesRemoveInput,
|
||||||
|
ProjectCopiesRemoveOutput,
|
||||||
|
ProjectCopiesRefreshInput,
|
||||||
|
ProjectCopiesRefreshOutput,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import { ClientError } from "./client-error"
|
import { ClientError } from "./client-error"
|
||||||
|
|
||||||
@@ -165,6 +247,41 @@ export function make(options: ClientOptions) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
health: {
|
||||||
|
get: (requestOptions?: RequestOptions) =>
|
||||||
|
request<HealthGetOutput>(
|
||||||
|
{ method: "GET", path: `/api/health`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
get: (input?: LocationGetInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<LocationGetOutput>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/location`,
|
||||||
|
query: { location: input?.["location"] },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [401, 400],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
agents: {
|
||||||
|
list: (input?: AgentsListInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<AgentsListOutput>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/agent`,
|
||||||
|
query: { location: input?.["location"] },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [401, 400],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
},
|
||||||
sessions: {
|
sessions: {
|
||||||
list: (input?: SessionsListInput, requestOptions?: RequestOptions) =>
|
list: (input?: SessionsListInput, requestOptions?: RequestOptions) =>
|
||||||
request<SessionsListOutput>(
|
request<SessionsListOutput>(
|
||||||
@@ -172,14 +289,14 @@ export function make(options: ClientOptions) {
|
|||||||
method: "GET",
|
method: "GET",
|
||||||
path: `/api/session`,
|
path: `/api/session`,
|
||||||
query: {
|
query: {
|
||||||
workspace: input?.workspace,
|
workspace: input?.["workspace"],
|
||||||
limit: input?.limit,
|
limit: input?.["limit"],
|
||||||
order: input?.order,
|
order: input?.["order"],
|
||||||
search: input?.search,
|
search: input?.["search"],
|
||||||
directory: input?.directory,
|
directory: input?.["directory"],
|
||||||
project: input?.project,
|
project: input?.["project"],
|
||||||
subpath: input?.subpath,
|
subpath: input?.["subpath"],
|
||||||
cursor: input?.cursor,
|
cursor: input?.["cursor"],
|
||||||
},
|
},
|
||||||
successStatus: 200,
|
successStatus: 200,
|
||||||
declaredStatuses: [400, 401],
|
declaredStatuses: [400, 401],
|
||||||
@@ -192,7 +309,12 @@ export function make(options: ClientOptions) {
|
|||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: `/api/session`,
|
path: `/api/session`,
|
||||||
body: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location },
|
body: {
|
||||||
|
id: input?.["id"],
|
||||||
|
agent: input?.["agent"],
|
||||||
|
model: input?.["model"],
|
||||||
|
location: input?.["location"],
|
||||||
|
},
|
||||||
successStatus: 200,
|
successStatus: 200,
|
||||||
declaredStatuses: [401, 400],
|
declaredStatuses: [401, 400],
|
||||||
empty: false,
|
empty: false,
|
||||||
@@ -226,7 +348,7 @@ export function make(options: ClientOptions) {
|
|||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/agent`,
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/agent`,
|
||||||
body: { agent: input.agent },
|
body: { agent: input["agent"] },
|
||||||
successStatus: 204,
|
successStatus: 204,
|
||||||
declaredStatuses: [404, 400, 401],
|
declaredStatuses: [404, 400, 401],
|
||||||
empty: true,
|
empty: true,
|
||||||
@@ -238,7 +360,7 @@ export function make(options: ClientOptions) {
|
|||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/model`,
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/model`,
|
||||||
body: { model: input.model },
|
body: { model: input["model"] },
|
||||||
successStatus: 204,
|
successStatus: 204,
|
||||||
declaredStatuses: [404, 400, 401],
|
declaredStatuses: [404, 400, 401],
|
||||||
empty: true,
|
empty: true,
|
||||||
@@ -250,7 +372,7 @@ export function make(options: ClientOptions) {
|
|||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/prompt`,
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/prompt`,
|
||||||
body: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume },
|
body: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] },
|
||||||
successStatus: 200,
|
successStatus: 200,
|
||||||
declaredStatuses: [409, 404, 400, 401],
|
declaredStatuses: [409, 404, 400, 401],
|
||||||
empty: false,
|
empty: false,
|
||||||
@@ -284,7 +406,7 @@ export function make(options: ClientOptions) {
|
|||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`,
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`,
|
||||||
body: { messageID: input.messageID, files: input.files },
|
body: { messageID: input["messageID"], files: input["files"] },
|
||||||
successStatus: 200,
|
successStatus: 200,
|
||||||
declaredStatuses: [404, 500, 400, 401],
|
declaredStatuses: [404, 500, 400, 401],
|
||||||
empty: false,
|
empty: false,
|
||||||
@@ -324,12 +446,24 @@ export function make(options: ClientOptions) {
|
|||||||
},
|
},
|
||||||
requestOptions,
|
requestOptions,
|
||||||
).then((value) => value.data),
|
).then((value) => value.data),
|
||||||
|
history: (input: SessionsHistoryInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<SessionsHistoryOutput>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/history`,
|
||||||
|
query: { limit: input["limit"], after: input["after"] },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [404, 400, 401],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
events: (input: SessionsEventsInput, requestOptions?: RequestOptions): AsyncIterable<SessionsEventsOutput> =>
|
events: (input: SessionsEventsInput, requestOptions?: RequestOptions): AsyncIterable<SessionsEventsOutput> =>
|
||||||
sse<SessionsEventsOutput>(
|
sse<SessionsEventsOutput>(
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/event`,
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/event`,
|
||||||
query: { after: input.after },
|
query: { after: input["after"] },
|
||||||
successStatus: 200,
|
successStatus: 200,
|
||||||
declaredStatuses: [404, 400, 401],
|
declaredStatuses: [404, 400, 401],
|
||||||
empty: false,
|
empty: false,
|
||||||
@@ -359,6 +493,500 @@ export function make(options: ClientOptions) {
|
|||||||
requestOptions,
|
requestOptions,
|
||||||
).then((value) => value.data),
|
).then((value) => value.data),
|
||||||
},
|
},
|
||||||
|
messages: {
|
||||||
|
list: (input: MessagesListInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<MessagesListOutput>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/message`,
|
||||||
|
query: { limit: input["limit"], order: input["order"], cursor: input["cursor"] },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [400, 404, 500, 401],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
models: {
|
||||||
|
list: (input?: ModelsListInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<ModelsListOutput>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/model`,
|
||||||
|
query: { location: input?.["location"] },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [503, 401, 400],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
providers: {
|
||||||
|
list: (input?: ProvidersListInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<ProvidersListOutput>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/provider`,
|
||||||
|
query: { location: input?.["location"] },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [503, 401, 400],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
get: (input: ProvidersGetInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<ProvidersGetOutput>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/provider/${encodeURIComponent(input.providerID)}`,
|
||||||
|
query: { location: input["location"] },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [404, 503, 401, 400],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
integrations: {
|
||||||
|
list: (input?: IntegrationsListInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<IntegrationsListOutput>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/integration`,
|
||||||
|
query: { location: input?.["location"] },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [401, 400],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
get: (input: IntegrationsGetInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<IntegrationsGetOutput>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/integration/${encodeURIComponent(input.integrationID)}`,
|
||||||
|
query: { location: input["location"] },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [401, 400],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
connectKey: (input: IntegrationsConnectKeyInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<IntegrationsConnectKeyOutput>(
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`,
|
||||||
|
query: { location: input["location"] },
|
||||||
|
body: { key: input["key"], label: input["label"] },
|
||||||
|
successStatus: 204,
|
||||||
|
declaredStatuses: [400, 401],
|
||||||
|
empty: true,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
connectOauth: (input: IntegrationsConnectOauthInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<IntegrationsConnectOauthOutput>(
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth`,
|
||||||
|
query: { location: input["location"] },
|
||||||
|
body: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [400, 401],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
attemptStatus: (input: IntegrationsAttemptStatusInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<IntegrationsAttemptStatusOutput>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`,
|
||||||
|
query: { location: input["location"] },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [401, 400],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
attemptComplete: (input: IntegrationsAttemptCompleteInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<IntegrationsAttemptCompleteOutput>(
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}/complete`,
|
||||||
|
query: { location: input["location"] },
|
||||||
|
body: { code: input["code"] },
|
||||||
|
successStatus: 204,
|
||||||
|
declaredStatuses: [400, 401],
|
||||||
|
empty: true,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
attemptCancel: (input: IntegrationsAttemptCancelInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<IntegrationsAttemptCancelOutput>(
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`,
|
||||||
|
query: { location: input["location"] },
|
||||||
|
successStatus: 204,
|
||||||
|
declaredStatuses: [401, 400],
|
||||||
|
empty: true,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
credentials: {
|
||||||
|
update: (input: CredentialsUpdateInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<CredentialsUpdateOutput>(
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
path: `/api/credential/${encodeURIComponent(input.credentialID)}`,
|
||||||
|
query: { location: input["location"] },
|
||||||
|
body: { label: input["label"] },
|
||||||
|
successStatus: 204,
|
||||||
|
declaredStatuses: [401, 400],
|
||||||
|
empty: true,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
remove: (input: CredentialsRemoveInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<CredentialsRemoveOutput>(
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
path: `/api/credential/${encodeURIComponent(input.credentialID)}`,
|
||||||
|
query: { location: input["location"] },
|
||||||
|
successStatus: 204,
|
||||||
|
declaredStatuses: [401, 400],
|
||||||
|
empty: true,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
permissions: {
|
||||||
|
listRequests: (input?: PermissionsListRequestsInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<PermissionsListRequestsOutput>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/permission/request`,
|
||||||
|
query: { location: input?.["location"] },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [401, 400],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
listSaved: (input?: PermissionsListSavedInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<{ readonly data: PermissionsListSavedOutput }>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/permission/saved`,
|
||||||
|
query: { projectID: input?.["projectID"] },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [401, 400],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
).then((value) => value.data),
|
||||||
|
removeSaved: (input: PermissionsRemoveSavedInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<PermissionsRemoveSavedOutput>(
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
path: `/api/permission/saved/${encodeURIComponent(input.id)}`,
|
||||||
|
successStatus: 204,
|
||||||
|
declaredStatuses: [401, 400],
|
||||||
|
empty: true,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
create: (input: PermissionsCreateInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<{ readonly data: PermissionsCreateOutput }>(
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission`,
|
||||||
|
body: {
|
||||||
|
id: input["id"],
|
||||||
|
action: input["action"],
|
||||||
|
resources: input["resources"],
|
||||||
|
save: input["save"],
|
||||||
|
metadata: input["metadata"],
|
||||||
|
source: input["source"],
|
||||||
|
agent: input["agent"],
|
||||||
|
},
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [404, 400, 401],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
).then((value) => value.data),
|
||||||
|
list: (input: PermissionsListInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<{ readonly data: PermissionsListOutput }>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission`,
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [404, 400, 401],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
).then((value) => value.data),
|
||||||
|
get: (input: PermissionsGetInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<{ readonly data: PermissionsGetOutput }>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/${encodeURIComponent(input.requestID)}`,
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [404, 400, 401],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
).then((value) => value.data),
|
||||||
|
reply: (input: PermissionsReplyInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<PermissionsReplyOutput>(
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/${encodeURIComponent(input.requestID)}/reply`,
|
||||||
|
body: { reply: input["reply"], message: input["message"] },
|
||||||
|
successStatus: 204,
|
||||||
|
declaredStatuses: [404, 400, 401],
|
||||||
|
empty: true,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
list: (input?: FilesListInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<FilesListOutput>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/fs/list`,
|
||||||
|
query: { location: input?.["location"], path: input?.["path"] },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [401, 400],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
find: (input: FilesFindInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<FilesFindOutput>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/fs/find`,
|
||||||
|
query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [401, 400],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
commands: {
|
||||||
|
list: (input?: CommandsListInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<CommandsListOutput>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/command`,
|
||||||
|
query: { location: input?.["location"] },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [401, 400],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
skills: {
|
||||||
|
list: (input?: SkillsListInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<SkillsListOutput>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/skill`,
|
||||||
|
query: { location: input?.["location"] },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [401, 400],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
subscribe: (requestOptions?: RequestOptions): AsyncIterable<EventsSubscribeOutput> =>
|
||||||
|
sse<EventsSubscribeOutput>(
|
||||||
|
{ method: "GET", path: `/api/event`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
ptys: {
|
||||||
|
list: (input?: PtysListInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<PtysListOutput>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/pty`,
|
||||||
|
query: { location: input?.["location"] },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [401, 400],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
create: (input?: PtysCreateInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<PtysCreateOutput>(
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: `/api/pty`,
|
||||||
|
query: { location: input?.["location"] },
|
||||||
|
body: {
|
||||||
|
command: input?.["command"],
|
||||||
|
args: input?.["args"],
|
||||||
|
cwd: input?.["cwd"],
|
||||||
|
title: input?.["title"],
|
||||||
|
env: input?.["env"],
|
||||||
|
},
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [401, 400],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
get: (input: PtysGetInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<PtysGetOutput>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/pty/${encodeURIComponent(input.ptyID)}`,
|
||||||
|
query: { location: input["location"] },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [404, 401, 400],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
update: (input: PtysUpdateInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<PtysUpdateOutput>(
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
path: `/api/pty/${encodeURIComponent(input.ptyID)}`,
|
||||||
|
query: { location: input["location"] },
|
||||||
|
body: { title: input["title"], size: input["size"] },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [404, 401, 400],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
remove: (input: PtysRemoveInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<PtysRemoveOutput>(
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
path: `/api/pty/${encodeURIComponent(input.ptyID)}`,
|
||||||
|
query: { location: input["location"] },
|
||||||
|
successStatus: 204,
|
||||||
|
declaredStatuses: [404, 401, 400],
|
||||||
|
empty: true,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
questions: {
|
||||||
|
listRequests: (input?: QuestionsListRequestsInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<QuestionsListRequestsOutput>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/question/request`,
|
||||||
|
query: { location: input?.["location"] },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [401, 400],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
list: (input: QuestionsListInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<{ readonly data: QuestionsListOutput }>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/question`,
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [404, 400, 401],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
).then((value) => value.data),
|
||||||
|
reply: (input: QuestionsReplyInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<QuestionsReplyOutput>(
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reply`,
|
||||||
|
body: { answers: input["answers"] },
|
||||||
|
successStatus: 204,
|
||||||
|
declaredStatuses: [404, 400, 401],
|
||||||
|
empty: true,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
reject: (input: QuestionsRejectInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<QuestionsRejectOutput>(
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reject`,
|
||||||
|
successStatus: 204,
|
||||||
|
declaredStatuses: [404, 400, 401],
|
||||||
|
empty: true,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
references: {
|
||||||
|
list: (input?: ReferencesListInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<ReferencesListOutput>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/reference`,
|
||||||
|
query: { location: input?.["location"] },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [401, 400],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
projectCopies: {
|
||||||
|
create: (input: ProjectCopiesCreateInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<ProjectCopiesCreateOutput>(
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
|
||||||
|
query: { location: input["location"] },
|
||||||
|
body: { strategy: input["strategy"], directory: input["directory"], name: input["name"] },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [400, 401],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
remove: (input: ProjectCopiesRemoveInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<ProjectCopiesRemoveOutput>(
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
|
||||||
|
query: { location: input["location"] },
|
||||||
|
body: { directory: input["directory"], force: input["force"] },
|
||||||
|
successStatus: 204,
|
||||||
|
declaredStatuses: [400, 401],
|
||||||
|
empty: true,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
refresh: (input: ProjectCopiesRefreshInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<ProjectCopiesRefreshOutput>(
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy/refresh`,
|
||||||
|
query: { location: input["location"] },
|
||||||
|
successStatus: 204,
|
||||||
|
declaredStatuses: [400, 401],
|
||||||
|
empty: true,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1 +1,2 @@
|
|||||||
export * from "./generated/index"
|
export * from "./generated/index"
|
||||||
|
export type { EventsSubscribeOutput as OpenCodeEvent } from "./generated/types"
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ import { SessionMessage } from "@opencode-ai/schema/session-message"
|
|||||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||||
import { Api } from "@opencode-ai/server/api"
|
import { Api } from "@opencode-ai/server/api"
|
||||||
import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
|
import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
|
||||||
import { HttpApi } from "effect/unstable/httpapi"
|
import { ClientApi, endpointNames, groupNames, omitEndpoints } from "../src/contract"
|
||||||
import { SessionGroup } from "../src/contract"
|
|
||||||
|
|
||||||
test("Core and Server reuse the authoritative Schema and Protocol values", () => {
|
test("Core and Server reuse the authoritative Schema and Protocol values", () => {
|
||||||
expect(AgentV2.ID).toBe(Agent.ID)
|
expect(AgentV2.ID).toBe(Agent.ID)
|
||||||
@@ -31,17 +30,16 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () =>
|
|||||||
expect(CoreSessionMessage.Message).toBe(SessionMessage.Message)
|
expect(CoreSessionMessage.Message).toBe(SessionMessage.Message)
|
||||||
expect(CorePrompt).toBe(Prompt)
|
expect(CorePrompt).toBe(Prompt)
|
||||||
expect(Api.groups["server.session"].identifier).toBe("server.session")
|
expect(Api.groups["server.session"].identifier).toBe("server.session")
|
||||||
expect(SessionGroup.identifier).toBe(Api.groups["server.session"].identifier)
|
expect(Object.keys(ClientApi.groups)).toEqual(Object.keys(Api.groups))
|
||||||
expect(Session.ID.create()).toStartWith("ses_")
|
expect(Session.ID.create()).toStartWith("ses_")
|
||||||
expect(Project.ID.global).toBe("global")
|
expect(Project.ID.global).toBe("global")
|
||||||
expect(Provider.ID.anthropic).toBe("anthropic")
|
expect(Provider.ID.anthropic).toBe("anthropic")
|
||||||
expect(Workspace.ID.create()).toStartWith("wrk_")
|
expect(Workspace.ID.create()).toStartWith("wrk_")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("client and Server Session contracts generate identically", () => {
|
test("client and Server contracts generate identically", () => {
|
||||||
const options = { groupNames: { "server.session": "sessions" } }
|
const server = compile(Api, { groupNames, endpointNames, omitEndpoints })
|
||||||
const server = compile(HttpApi.make("server").add(Api.groups["server.session"]), options)
|
const client = compile(ClientApi, { groupNames, endpointNames, omitEndpoints })
|
||||||
const client = compile(HttpApi.make("client").add(SessionGroup), options)
|
|
||||||
|
|
||||||
expect(emitPromise(client)).toEqual(emitPromise(server))
|
expect(emitPromise(client)).toEqual(emitPromise(server))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,7 +15,53 @@ test("sessions.get returns the decoded Effect projection", async () => {
|
|||||||
expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000)
|
expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("events.subscribe exposes and decodes the native Effect event stream", async () => {
|
||||||
|
const httpClient = HttpClient.make((request) =>
|
||||||
|
Effect.succeed(
|
||||||
|
HttpClientResponse.fromWeb(
|
||||||
|
request,
|
||||||
|
new Response(
|
||||||
|
`data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` +
|
||||||
|
`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`,
|
||||||
|
{ headers: { "content-type": "text/event-stream" } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const events = await Effect.gen(function* () {
|
||||||
|
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||||
|
return yield* client.events.subscribe().pipe(Stream.runCollect)
|
||||||
|
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||||
|
|
||||||
|
expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.next.model.switched"])
|
||||||
|
const durable = events[1]
|
||||||
|
if (durable?.type !== "session.next.model.switched") throw new Error("Expected model event")
|
||||||
|
expect(DateTime.toEpochMillis(durable.data.timestamp)).toBe(1_717_171_717_000)
|
||||||
|
expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("events.subscribe terminates on Effect protocol decode failures", async () => {
|
||||||
|
const httpClient = HttpClient.make((request) =>
|
||||||
|
Effect.succeed(
|
||||||
|
HttpClientResponse.fromWeb(
|
||||||
|
request,
|
||||||
|
new Response(`data: {"type":"server.connected"}\n\n`, {
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const error = await Effect.gen(function* () {
|
||||||
|
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||||
|
return yield* client.events.subscribe().pipe(Stream.runCollect, Effect.flip)
|
||||||
|
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||||
|
|
||||||
|
expect(error._tag).toBe("ClientError")
|
||||||
|
})
|
||||||
|
|
||||||
test("session methods retain decoded Effect inputs and outputs", async () => {
|
test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||||
|
const historyQueries: Array<Record<string, string>> = []
|
||||||
|
let historyPage = 0
|
||||||
const httpClient = HttpClient.make((request) => {
|
const httpClient = HttpClient.make((request) => {
|
||||||
const url = request.url
|
const url = request.url
|
||||||
if (url.includes("/event")) {
|
if (url.includes("/event")) {
|
||||||
@@ -28,6 +74,18 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (url.includes("/history")) {
|
||||||
|
historyPage++
|
||||||
|
historyQueries.push(Object.fromEntries(request.urlParams.params))
|
||||||
|
return Effect.succeed(
|
||||||
|
HttpClientResponse.fromWeb(
|
||||||
|
request,
|
||||||
|
Response.json(
|
||||||
|
historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
if (url.includes("/prompt")) {
|
if (url.includes("/prompt")) {
|
||||||
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission)))
|
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission)))
|
||||||
}
|
}
|
||||||
@@ -72,6 +130,18 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
|||||||
yield* client.sessions.compact({ sessionID: Session.ID.make("ses_test") })
|
yield* client.sessions.compact({ sessionID: Session.ID.make("ses_test") })
|
||||||
yield* client.sessions.wait({ sessionID: Session.ID.make("ses_test") })
|
yield* client.sessions.wait({ sessionID: Session.ID.make("ses_test") })
|
||||||
const context = yield* client.sessions.context({ sessionID: Session.ID.make("ses_test") })
|
const context = yield* client.sessions.context({ sessionID: Session.ID.make("ses_test") })
|
||||||
|
const history = yield* client.sessions.history({
|
||||||
|
sessionID: Session.ID.make("ses_test"),
|
||||||
|
after: 0,
|
||||||
|
limit: 1,
|
||||||
|
})
|
||||||
|
const historyNext = history.hasMore
|
||||||
|
? yield* client.sessions.history({
|
||||||
|
sessionID: Session.ID.make("ses_test"),
|
||||||
|
after: history.data.at(-1)?.durable?.seq,
|
||||||
|
limit: 2,
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
const events = yield* client.sessions
|
const events = yield* client.sessions
|
||||||
.events({ sessionID: Session.ID.make("ses_test"), after: 0 })
|
.events({ sessionID: Session.ID.make("ses_test"), after: 0 })
|
||||||
.pipe(Stream.runCollect)
|
.pipe(Stream.runCollect)
|
||||||
@@ -80,7 +150,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
|||||||
sessionID: Session.ID.make("ses_test"),
|
sessionID: Session.ID.make("ses_test"),
|
||||||
messageID: SessionMessage.ID.make("msg_model"),
|
messageID: SessionMessage.ID.make("msg_model"),
|
||||||
})
|
})
|
||||||
return { page, active, created, admitted, context, events, message }
|
return { page, active, created, admitted, context, history, historyNext, events, message }
|
||||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||||
|
|
||||||
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
|
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
|
||||||
@@ -92,10 +162,39 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
|||||||
expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype)
|
expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype)
|
||||||
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
|
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
|
||||||
expect(result.context).toEqual([])
|
expect(result.context).toEqual([])
|
||||||
|
expect(DateTime.toEpochMillis(result.history.data[0].data.timestamp)).toBe(1_717_171_717_000)
|
||||||
|
expect(result.history).toEqual(expect.objectContaining({ hasMore: true }))
|
||||||
|
expect(result.historyNext).toEqual({ data: [], hasMore: false })
|
||||||
|
expect(historyQueries[0]).toEqual({ limit: "1", after: "0" })
|
||||||
|
expect(historyQueries[1]).toEqual({ limit: "2", after: "1" })
|
||||||
expect(DateTime.toEpochMillis(result.events[0].data.timestamp)).toBe(1_717_171_717_000)
|
expect(DateTime.toEpochMillis(result.events[0].data.timestamp)).toBe(1_717_171_717_000)
|
||||||
expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" }))
|
expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" }))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("sessions.history retains the typed SessionNotFoundError", async () => {
|
||||||
|
const httpClient = HttpClient.make((request) =>
|
||||||
|
Effect.succeed(
|
||||||
|
HttpClientResponse.fromWeb(
|
||||||
|
request,
|
||||||
|
Response.json(
|
||||||
|
{ _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" },
|
||||||
|
{ status: 404 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const error = await Effect.gen(function* () {
|
||||||
|
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||||
|
return yield* client.sessions
|
||||||
|
.history({
|
||||||
|
sessionID: Session.ID.make("ses_missing"),
|
||||||
|
})
|
||||||
|
.pipe(Effect.flip)
|
||||||
|
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||||
|
|
||||||
|
expect(error._tag).toBe("SessionNotFoundError")
|
||||||
|
})
|
||||||
|
|
||||||
const session = {
|
const session = {
|
||||||
data: {
|
data: {
|
||||||
id: "ses_test",
|
id: "ses_test",
|
||||||
|
|||||||
@@ -1,5 +1,42 @@
|
|||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { isUnauthorizedError, OpenCode } from "../src"
|
import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src"
|
||||||
|
|
||||||
|
test("exposes every standard HTTP API group", () => {
|
||||||
|
const client = OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||||
|
|
||||||
|
expect(Object.keys(client)).toEqual([
|
||||||
|
"health",
|
||||||
|
"location",
|
||||||
|
"agents",
|
||||||
|
"sessions",
|
||||||
|
"messages",
|
||||||
|
"models",
|
||||||
|
"providers",
|
||||||
|
"integrations",
|
||||||
|
"credentials",
|
||||||
|
"permissions",
|
||||||
|
"files",
|
||||||
|
"commands",
|
||||||
|
"skills",
|
||||||
|
"events",
|
||||||
|
"ptys",
|
||||||
|
"questions",
|
||||||
|
"references",
|
||||||
|
"projectCopies",
|
||||||
|
])
|
||||||
|
expect(Object.keys(client.messages)).toEqual(["list"])
|
||||||
|
expect(Object.keys(client.integrations)).toEqual([
|
||||||
|
"list",
|
||||||
|
"get",
|
||||||
|
"connectKey",
|
||||||
|
"connectOauth",
|
||||||
|
"attemptStatus",
|
||||||
|
"attemptComplete",
|
||||||
|
"attemptCancel",
|
||||||
|
])
|
||||||
|
expect(Object.keys(client.files)).toEqual(["list", "find"])
|
||||||
|
expect(Object.keys(client.ptys)).toEqual(["list", "create", "get", "update", "remove"])
|
||||||
|
})
|
||||||
|
|
||||||
test("sessions.get returns the wire projection", async () => {
|
test("sessions.get returns the wire projection", async () => {
|
||||||
const client = OpenCode.make({
|
const client = OpenCode.make({
|
||||||
@@ -17,8 +54,38 @@ test("sessions.get returns the wire projection", async () => {
|
|||||||
expect(result.time.created).toBe(1_717_171_717_000)
|
expect(result.time.created).toBe(1_717_171_717_000)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("events.subscribe exposes the Promise event stream wire projection", async () => {
|
||||||
|
const client = OpenCode.make({
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
fetch: async () =>
|
||||||
|
new Response(
|
||||||
|
`: heartbeat\n\ndata: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` +
|
||||||
|
`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`,
|
||||||
|
{ headers: { "content-type": "text/event-stream" } },
|
||||||
|
),
|
||||||
|
})
|
||||||
|
const events = []
|
||||||
|
for await (const event of client.events.subscribe()) events.push(event)
|
||||||
|
|
||||||
|
expect(events).toEqual([{ id: "evt_connected", type: "server.connected", data: {} }, modelSwitchedEvent])
|
||||||
|
expect(events[1]?.type === "session.next.model.switched" && events[1].data.timestamp).toBe(1_717_171_717_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("events.subscribe terminates on malformed Promise SSE data", async () => {
|
||||||
|
const client = OpenCode.make({
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
fetch: async () => new Response("data: {not-json}\n\n", { headers: { "content-type": "text/event-stream" } }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(client.events.subscribe()[Symbol.asyncIterator]().next()).rejects.toMatchObject({
|
||||||
|
name: "ClientError",
|
||||||
|
reason: "MalformedResponse",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("session methods use the public HTTP contract", async () => {
|
test("session methods use the public HTTP contract", async () => {
|
||||||
const requests: Array<{ url: string; init?: RequestInit }> = []
|
const requests: Array<{ url: string; init?: RequestInit }> = []
|
||||||
|
let historyPage = 0
|
||||||
const client = OpenCode.make({
|
const client = OpenCode.make({
|
||||||
baseUrl: "http://localhost:3000",
|
baseUrl: "http://localhost:3000",
|
||||||
fetch: async (input, init) => {
|
fetch: async (input, init) => {
|
||||||
@@ -29,6 +96,12 @@ test("session methods use the public HTTP contract", async () => {
|
|||||||
headers: { "content-type": "text/event-stream" },
|
headers: { "content-type": "text/event-stream" },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (url.includes("/history")) {
|
||||||
|
historyPage++
|
||||||
|
return Response.json(
|
||||||
|
historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false },
|
||||||
|
)
|
||||||
|
}
|
||||||
if (url.includes("/prompt")) return Response.json(admission)
|
if (url.includes("/prompt")) return Response.json(admission)
|
||||||
if (url.includes("/context")) return Response.json({ data: [] })
|
if (url.includes("/context")) return Response.json({ data: [] })
|
||||||
if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
|
if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
|
||||||
@@ -39,7 +112,7 @@ test("session methods use the public HTTP contract", async () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const page = await client.sessions.list({ limit: "10", order: "desc" })
|
const page = await client.sessions.list({ limit: 10, order: "desc" })
|
||||||
const active = await client.sessions.active()
|
const active = await client.sessions.active()
|
||||||
const created = await client.sessions.create({ location: { directory: "/tmp/project" } })
|
const created = await client.sessions.create({ location: { directory: "/tmp/project" } })
|
||||||
await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" })
|
await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" })
|
||||||
@@ -55,8 +128,13 @@ test("session methods use the public HTTP contract", async () => {
|
|||||||
await client.sessions.compact({ sessionID: "ses_test" })
|
await client.sessions.compact({ sessionID: "ses_test" })
|
||||||
await client.sessions.wait({ sessionID: "ses_test" })
|
await client.sessions.wait({ sessionID: "ses_test" })
|
||||||
const context = await client.sessions.context({ sessionID: "ses_test" })
|
const context = await client.sessions.context({ sessionID: "ses_test" })
|
||||||
|
const history = await client.sessions.history({ sessionID: "ses_test", after: 0, limit: 1 })
|
||||||
|
const historyAfter = history.data.at(-1)?.durable?.seq
|
||||||
|
const historyNext = history.hasMore
|
||||||
|
? await client.sessions.history({ sessionID: "ses_test", after: historyAfter, limit: 2 })
|
||||||
|
: undefined
|
||||||
const events = []
|
const events = []
|
||||||
for await (const event of client.sessions.events({ sessionID: "ses_test", after: "0" })) events.push(event)
|
for await (const event of client.sessions.events({ sessionID: "ses_test", after: 0 })) events.push(event)
|
||||||
await client.sessions.interrupt({ sessionID: "ses_test" })
|
await client.sessions.interrupt({ sessionID: "ses_test" })
|
||||||
const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" })
|
const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" })
|
||||||
|
|
||||||
@@ -65,6 +143,8 @@ test("session methods use the public HTTP contract", async () => {
|
|||||||
expect(created.id).toBe("ses_test")
|
expect(created.id).toBe("ses_test")
|
||||||
expect(admitted.id).toBe("msg_test")
|
expect(admitted.id).toBe("msg_test")
|
||||||
expect(context).toEqual([])
|
expect(context).toEqual([])
|
||||||
|
expect(history).toEqual({ data: [modelSwitchedEvent], hasMore: true })
|
||||||
|
expect(historyNext).toEqual({ data: [], hasMore: false })
|
||||||
expect(events).toEqual([modelSwitchedEvent])
|
expect(events).toEqual([modelSwitchedEvent])
|
||||||
expect(message).toEqual(modelSwitchedMessage)
|
expect(message).toEqual(modelSwitchedMessage)
|
||||||
expect(requests.map((request) => [request.init?.method, request.url])).toEqual([
|
expect(requests.map((request) => [request.init?.method, request.url])).toEqual([
|
||||||
@@ -77,6 +157,8 @@ test("session methods use the public HTTP contract", async () => {
|
|||||||
["POST", "http://localhost:3000/api/session/ses_test/compact"],
|
["POST", "http://localhost:3000/api/session/ses_test/compact"],
|
||||||
["POST", "http://localhost:3000/api/session/ses_test/wait"],
|
["POST", "http://localhost:3000/api/session/ses_test/wait"],
|
||||||
["GET", "http://localhost:3000/api/session/ses_test/context"],
|
["GET", "http://localhost:3000/api/session/ses_test/context"],
|
||||||
|
["GET", "http://localhost:3000/api/session/ses_test/history?limit=1&after=0"],
|
||||||
|
["GET", "http://localhost:3000/api/session/ses_test/history?limit=2&after=1"],
|
||||||
["GET", "http://localhost:3000/api/session/ses_test/event?after=0"],
|
["GET", "http://localhost:3000/api/session/ses_test/event?after=0"],
|
||||||
["POST", "http://localhost:3000/api/session/ses_test/interrupt"],
|
["POST", "http://localhost:3000/api/session/ses_test/interrupt"],
|
||||||
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
|
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
|
||||||
@@ -104,6 +186,24 @@ test("middleware errors remain declared client errors", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("sessions.history decodes SessionNotFoundError", async () => {
|
||||||
|
const client = OpenCode.make({
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
fetch: async () =>
|
||||||
|
Response.json(
|
||||||
|
{ _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" },
|
||||||
|
{ status: 404 },
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.sessions.history({ sessionID: "ses_missing" })
|
||||||
|
throw new Error("Expected request to fail")
|
||||||
|
} catch (error) {
|
||||||
|
expect(isSessionNotFoundError(error)).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const session = {
|
const session = {
|
||||||
data: {
|
data: {
|
||||||
id: "ses_test",
|
id: "ses_test",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.17.11",
|
"version": "1.17.12",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -248,7 +248,6 @@ export const dict = {
|
|||||||
"zen.privacy.exceptionsLink": "الاستثناءات التالية",
|
"zen.privacy.exceptionsLink": "الاستثناءات التالية",
|
||||||
|
|
||||||
"go.title": "OpenCode Go | نماذج برمجة منخفضة التكلفة للجميع",
|
"go.title": "OpenCode Go | نماذج برمجة منخفضة التكلفة للجميع",
|
||||||
"go.banner.text": "MiniMax M3: حد استخدام أكبر 3 مرات لفترة محدودة",
|
|
||||||
"go.meta.description":
|
"go.meta.description":
|
||||||
"يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5.2 وGLM-5.1 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash.",
|
"يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5.2 وGLM-5.1 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash.",
|
||||||
"go.hero.title": "نماذج برمجة منخفضة التكلفة للجميع",
|
"go.hero.title": "نماذج برمجة منخفضة التكلفة للجميع",
|
||||||
@@ -369,6 +368,8 @@ export const dict = {
|
|||||||
"zen.api.error.userMonthlyLimitReached":
|
"zen.api.error.userMonthlyLimitReached":
|
||||||
"لقد وصلت إلى حد الإنفاق الشهري البالغ ${{amount}}. إدارة حدودك هنا: {{membersUrl}}",
|
"لقد وصلت إلى حد الإنفاق الشهري البالغ ${{amount}}. إدارة حدودك هنا: {{membersUrl}}",
|
||||||
"zen.api.error.modelDisabled": "النموذج معطل",
|
"zen.api.error.modelDisabled": "النموذج معطل",
|
||||||
|
"zen.api.error.regionNotAllowed":
|
||||||
|
"هذا النموذج مستضاف في الصين. إذا كنت ترغب في استخدام هذا النموذج، فعّله في إعداداتك: {{consoleGoUrl}}",
|
||||||
"zen.api.error.trialEnded":
|
"zen.api.error.trialEnded":
|
||||||
"انتهى العرض المجاني لـ {{model}}. يمكنك مواصلة استخدام النموذج بالاشتراك في OpenCode Go - {{link}}",
|
"انتهى العرض المجاني لـ {{model}}. يمكنك مواصلة استخدام النموذج بالاشتراك في OpenCode Go - {{link}}",
|
||||||
|
|
||||||
@@ -646,6 +647,9 @@ export const dict = {
|
|||||||
"workspace.lite.subscription.useBalance": "استخدم رصيدك المتوفر بعد الوصول إلى حدود الاستخدام",
|
"workspace.lite.subscription.useBalance": "استخدم رصيدك المتوفر بعد الوصول إلى حدود الاستخدام",
|
||||||
"workspace.lite.subscription.selectProvider":
|
"workspace.lite.subscription.selectProvider":
|
||||||
'اختر "OpenCode Go" كمزود في إعدادات opencode الخاصة بك لاستخدام نماذج Go.',
|
'اختر "OpenCode Go" كمزود في إعدادات opencode الخاصة بك لاستخدام نماذج Go.',
|
||||||
|
"workspace.lite.providers.title": "المزودون",
|
||||||
|
"workspace.lite.providers.description": "تحكم في المزودين المستخدمين للتوجيه.",
|
||||||
|
"workspace.lite.providers.useChina": "تفعيل النماذج المستضافة في الصين",
|
||||||
"workspace.lite.black.message":
|
"workspace.lite.black.message":
|
||||||
"أنت مشترك حاليًا في OpenCode Black أو في قائمة الانتظار. يرجى إلغاء الاشتراك أولاً إذا كنت ترغب في التبديل إلى Go.",
|
"أنت مشترك حاليًا في OpenCode Black أو في قائمة الانتظار. يرجى إلغاء الاشتراك أولاً إذا كنت ترغب في التبديل إلى Go.",
|
||||||
"workspace.lite.other.message":
|
"workspace.lite.other.message":
|
||||||
|
|||||||
@@ -252,7 +252,6 @@ export const dict = {
|
|||||||
"zen.privacy.exceptionsLink": "seguintes exceções",
|
"zen.privacy.exceptionsLink": "seguintes exceções",
|
||||||
|
|
||||||
"go.title": "OpenCode Go | Modelos de codificação de baixo custo para todos",
|
"go.title": "OpenCode Go | Modelos de codificação de baixo custo para todos",
|
||||||
"go.banner.text": "MiniMax M3 tem limite de uso 3x maior por tempo limitado",
|
|
||||||
"go.meta.description":
|
"go.meta.description":
|
||||||
"O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.",
|
"O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.",
|
||||||
"go.hero.title": "Modelos de codificação de baixo custo para todos",
|
"go.hero.title": "Modelos de codificação de baixo custo para todos",
|
||||||
@@ -377,6 +376,8 @@ export const dict = {
|
|||||||
"zen.api.error.userMonthlyLimitReached":
|
"zen.api.error.userMonthlyLimitReached":
|
||||||
"Você atingiu seu limite de gastos mensais de ${{amount}}. Gerencie seus limites aqui: {{membersUrl}}",
|
"Você atingiu seu limite de gastos mensais de ${{amount}}. Gerencie seus limites aqui: {{membersUrl}}",
|
||||||
"zen.api.error.modelDisabled": "O modelo está desabilitado",
|
"zen.api.error.modelDisabled": "O modelo está desabilitado",
|
||||||
|
"zen.api.error.regionNotAllowed":
|
||||||
|
"Este modelo está hospedado na China. Se você quiser usar este modelo, ative-o nas suas configurações: {{consoleGoUrl}}",
|
||||||
"zen.api.error.trialEnded":
|
"zen.api.error.trialEnded":
|
||||||
"A promoção gratuita do {{model}} terminou. Você pode continuar usando o modelo assinando o OpenCode Go - {{link}}",
|
"A promoção gratuita do {{model}} terminou. Você pode continuar usando o modelo assinando o OpenCode Go - {{link}}",
|
||||||
|
|
||||||
@@ -656,6 +657,9 @@ export const dict = {
|
|||||||
"workspace.lite.subscription.useBalance": "Use seu saldo disponível após atingir os limites de uso",
|
"workspace.lite.subscription.useBalance": "Use seu saldo disponível após atingir os limites de uso",
|
||||||
"workspace.lite.subscription.selectProvider":
|
"workspace.lite.subscription.selectProvider":
|
||||||
'Selecione "OpenCode Go" como provedor na sua configuração do opencode para usar os modelos Go.',
|
'Selecione "OpenCode Go" como provedor na sua configuração do opencode para usar os modelos Go.',
|
||||||
|
"workspace.lite.providers.title": "Provedores",
|
||||||
|
"workspace.lite.providers.description": "Controle quais provedores são usados para roteamento.",
|
||||||
|
"workspace.lite.providers.useChina": "Ativar modelos hospedados na China",
|
||||||
"workspace.lite.black.message":
|
"workspace.lite.black.message":
|
||||||
"Você está atualmente inscrito no OpenCode Black ou na lista de espera. Por favor, cancele a assinatura primeiro se desejar mudar para o Go.",
|
"Você está atualmente inscrito no OpenCode Black ou na lista de espera. Por favor, cancele a assinatura primeiro se desejar mudar para o Go.",
|
||||||
"workspace.lite.other.message":
|
"workspace.lite.other.message":
|
||||||
|
|||||||
@@ -250,7 +250,6 @@ export const dict = {
|
|||||||
"zen.privacy.exceptionsLink": "følgende undtagelser",
|
"zen.privacy.exceptionsLink": "følgende undtagelser",
|
||||||
|
|
||||||
"go.title": "OpenCode Go | Kodningsmodeller til lav pris for alle",
|
"go.title": "OpenCode Go | Kodningsmodeller til lav pris for alle",
|
||||||
"go.banner.text": "MiniMax M3 får tredoblet brugsgrænse i en begrænset periode",
|
|
||||||
"go.meta.description":
|
"go.meta.description":
|
||||||
"Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.",
|
"Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.",
|
||||||
"go.hero.title": "Kodningsmodeller til lav pris for alle",
|
"go.hero.title": "Kodningsmodeller til lav pris for alle",
|
||||||
@@ -373,6 +372,8 @@ export const dict = {
|
|||||||
"zen.api.error.userMonthlyLimitReached":
|
"zen.api.error.userMonthlyLimitReached":
|
||||||
"Du har nået din månedlige forbrugsgrænse på ${{amount}}. Administrer dine grænser her: {{membersUrl}}",
|
"Du har nået din månedlige forbrugsgrænse på ${{amount}}. Administrer dine grænser her: {{membersUrl}}",
|
||||||
"zen.api.error.modelDisabled": "Modellen er deaktiveret",
|
"zen.api.error.modelDisabled": "Modellen er deaktiveret",
|
||||||
|
"zen.api.error.regionNotAllowed":
|
||||||
|
"Denne model hostes i Kina. Hvis du vil bruge denne model, skal du aktivere den i dine indstillinger: {{consoleGoUrl}}",
|
||||||
"zen.api.error.trialEnded":
|
"zen.api.error.trialEnded":
|
||||||
"Den gratis kampagne for {{model}} er afsluttet. Du kan fortsætte med at bruge modellen ved at abonnere på OpenCode Go - {{link}}",
|
"Den gratis kampagne for {{model}} er afsluttet. Du kan fortsætte med at bruge modellen ved at abonnere på OpenCode Go - {{link}}",
|
||||||
|
|
||||||
@@ -652,6 +653,9 @@ export const dict = {
|
|||||||
"workspace.lite.subscription.useBalance": "Brug din tilgængelige saldo, når du har nået forbrugsgrænserne",
|
"workspace.lite.subscription.useBalance": "Brug din tilgængelige saldo, når du har nået forbrugsgrænserne",
|
||||||
"workspace.lite.subscription.selectProvider":
|
"workspace.lite.subscription.selectProvider":
|
||||||
'Vælg "OpenCode Go" som udbyder i din opencode-konfiguration for at bruge Go-modeller.',
|
'Vælg "OpenCode Go" som udbyder i din opencode-konfiguration for at bruge Go-modeller.',
|
||||||
|
"workspace.lite.providers.title": "Udbydere",
|
||||||
|
"workspace.lite.providers.description": "Styr, hvilke udbydere der bruges til routing.",
|
||||||
|
"workspace.lite.providers.useChina": "Aktivér modeller hostet i Kina",
|
||||||
"workspace.lite.black.message":
|
"workspace.lite.black.message":
|
||||||
"Du abonnerer i øjeblikket på OpenCode Black eller er på venteliste. Afmeld venligst først, hvis du vil skifte til Go.",
|
"Du abonnerer i øjeblikket på OpenCode Black eller er på venteliste. Afmeld venligst først, hvis du vil skifte til Go.",
|
||||||
"workspace.lite.other.message":
|
"workspace.lite.other.message":
|
||||||
|
|||||||
@@ -252,7 +252,6 @@ export const dict = {
|
|||||||
"zen.privacy.exceptionsLink": "folgenden Ausnahmen",
|
"zen.privacy.exceptionsLink": "folgenden Ausnahmen",
|
||||||
|
|
||||||
"go.title": "OpenCode Go | Kostengünstige Coding-Modelle für alle",
|
"go.title": "OpenCode Go | Kostengünstige Coding-Modelle für alle",
|
||||||
"go.banner.text": "MiniMax M3 erhält für begrenzte Zeit 3x Nutzungslimits",
|
|
||||||
"go.meta.description":
|
"go.meta.description":
|
||||||
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash.",
|
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash.",
|
||||||
"go.hero.title": "Kostengünstige Coding-Modelle für alle",
|
"go.hero.title": "Kostengünstige Coding-Modelle für alle",
|
||||||
@@ -376,6 +375,8 @@ export const dict = {
|
|||||||
"zen.api.error.userMonthlyLimitReached":
|
"zen.api.error.userMonthlyLimitReached":
|
||||||
"Du hast dein monatliches Ausgabenlimit von ${{amount}} erreicht. Verwalte deine Limits hier: {{membersUrl}}",
|
"Du hast dein monatliches Ausgabenlimit von ${{amount}} erreicht. Verwalte deine Limits hier: {{membersUrl}}",
|
||||||
"zen.api.error.modelDisabled": "Modell ist deaktiviert",
|
"zen.api.error.modelDisabled": "Modell ist deaktiviert",
|
||||||
|
"zen.api.error.regionNotAllowed":
|
||||||
|
"Dieses Modell wird in China gehostet. Wenn du dieses Modell verwenden möchtest, aktiviere es in deinen Einstellungen: {{consoleGoUrl}}",
|
||||||
"zen.api.error.trialEnded":
|
"zen.api.error.trialEnded":
|
||||||
"Die kostenlose Aktion für {{model}} ist beendet. Du kannst das Modell weiterhin nutzen, indem du OpenCode Go abonnierst - {{link}}",
|
"Die kostenlose Aktion für {{model}} ist beendet. Du kannst das Modell weiterhin nutzen, indem du OpenCode Go abonnierst - {{link}}",
|
||||||
|
|
||||||
@@ -655,6 +656,9 @@ export const dict = {
|
|||||||
"workspace.lite.subscription.useBalance": "Nutze dein verfügbares Guthaben, nachdem die Nutzungslimits erreicht sind",
|
"workspace.lite.subscription.useBalance": "Nutze dein verfügbares Guthaben, nachdem die Nutzungslimits erreicht sind",
|
||||||
"workspace.lite.subscription.selectProvider":
|
"workspace.lite.subscription.selectProvider":
|
||||||
'Wähle "OpenCode Go" als Anbieter in deiner opencode-Konfiguration, um Go-Modelle zu verwenden.',
|
'Wähle "OpenCode Go" als Anbieter in deiner opencode-Konfiguration, um Go-Modelle zu verwenden.',
|
||||||
|
"workspace.lite.providers.title": "Anbieter",
|
||||||
|
"workspace.lite.providers.description": "Steuere, welche Anbieter für das Routing verwendet werden.",
|
||||||
|
"workspace.lite.providers.useChina": "In China gehostete Modelle aktivieren",
|
||||||
"workspace.lite.black.message":
|
"workspace.lite.black.message":
|
||||||
"Du hast derzeit OpenCode Black abonniert oder stehst auf der Warteliste. Bitte kündige zuerst, wenn du zu Go wechseln möchtest.",
|
"Du hast derzeit OpenCode Black abonniert oder stehst auf der Warteliste. Bitte kündige zuerst, wenn du zu Go wechseln möchtest.",
|
||||||
"workspace.lite.other.message":
|
"workspace.lite.other.message":
|
||||||
|
|||||||
@@ -248,7 +248,6 @@ export const dict = {
|
|||||||
"zen.privacy.exceptionsLink": "following exceptions",
|
"zen.privacy.exceptionsLink": "following exceptions",
|
||||||
|
|
||||||
"go.title": "OpenCode Go | Low cost coding models for everyone",
|
"go.title": "OpenCode Go | Low cost coding models for everyone",
|
||||||
"go.banner.text": "MiniMax M3 gets 3× usage limits for a limited time",
|
|
||||||
"go.meta.description":
|
"go.meta.description":
|
||||||
"Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash.",
|
"Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash.",
|
||||||
"go.hero.title": "Low cost coding models for everyone",
|
"go.hero.title": "Low cost coding models for everyone",
|
||||||
@@ -370,6 +369,8 @@ export const dict = {
|
|||||||
"zen.api.error.userMonthlyLimitReached":
|
"zen.api.error.userMonthlyLimitReached":
|
||||||
"You have reached your monthly spending limit of ${{amount}}. Manage your limits here: {{membersUrl}}",
|
"You have reached your monthly spending limit of ${{amount}}. Manage your limits here: {{membersUrl}}",
|
||||||
"zen.api.error.modelDisabled": "Model is disabled",
|
"zen.api.error.modelDisabled": "Model is disabled",
|
||||||
|
"zen.api.error.regionNotAllowed":
|
||||||
|
"This model is hosted in China. If you would like to use this model, enable it in your settings: {{consoleGoUrl}}",
|
||||||
"zen.api.error.trialEnded":
|
"zen.api.error.trialEnded":
|
||||||
"Free promotion has ended for {{model}}. You can continue using the model by subscribing to OpenCode Go - {{link}}",
|
"Free promotion has ended for {{model}}. You can continue using the model by subscribing to OpenCode Go - {{link}}",
|
||||||
|
|
||||||
@@ -649,6 +650,9 @@ export const dict = {
|
|||||||
"workspace.lite.subscription.useBalance": "Use your available balance after reaching the usage limits",
|
"workspace.lite.subscription.useBalance": "Use your available balance after reaching the usage limits",
|
||||||
"workspace.lite.subscription.selectProvider":
|
"workspace.lite.subscription.selectProvider":
|
||||||
'Select "OpenCode Go" as the provider in your opencode configuration to use Go models.',
|
'Select "OpenCode Go" as the provider in your opencode configuration to use Go models.',
|
||||||
|
"workspace.lite.providers.title": "Providers",
|
||||||
|
"workspace.lite.providers.description": "Control which providers are used for routing.",
|
||||||
|
"workspace.lite.providers.useChina": "Enable models hosted in China",
|
||||||
"workspace.lite.black.message":
|
"workspace.lite.black.message":
|
||||||
"You're currently subscribed to OpenCode Black or on the waitlist. Please unsubscribe first if you'd like to switch to Go.",
|
"You're currently subscribed to OpenCode Black or on the waitlist. Please unsubscribe first if you'd like to switch to Go.",
|
||||||
"workspace.lite.other.message":
|
"workspace.lite.other.message":
|
||||||
|
|||||||
@@ -253,7 +253,6 @@ export const dict = {
|
|||||||
"zen.privacy.exceptionsLink": "siguientes excepciones",
|
"zen.privacy.exceptionsLink": "siguientes excepciones",
|
||||||
|
|
||||||
"go.title": "OpenCode Go | Modelos de programación de bajo coste para todos",
|
"go.title": "OpenCode Go | Modelos de programación de bajo coste para todos",
|
||||||
"go.banner.text": "MiniMax M3 tiene límites de uso 3x mayores por tiempo limitado",
|
|
||||||
"go.meta.description":
|
"go.meta.description":
|
||||||
"Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash.",
|
"Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash.",
|
||||||
"go.hero.title": "Modelos de programación de bajo coste para todos",
|
"go.hero.title": "Modelos de programación de bajo coste para todos",
|
||||||
@@ -377,6 +376,8 @@ export const dict = {
|
|||||||
"zen.api.error.userMonthlyLimitReached":
|
"zen.api.error.userMonthlyLimitReached":
|
||||||
"Has alcanzado tu límite de gasto mensual de ${{amount}}. Gestiona tus límites aquí: {{membersUrl}}",
|
"Has alcanzado tu límite de gasto mensual de ${{amount}}. Gestiona tus límites aquí: {{membersUrl}}",
|
||||||
"zen.api.error.modelDisabled": "El modelo está deshabilitado",
|
"zen.api.error.modelDisabled": "El modelo está deshabilitado",
|
||||||
|
"zen.api.error.regionNotAllowed":
|
||||||
|
"Este modelo está alojado en China. Si quieres usar este modelo, actívalo en tu configuración: {{consoleGoUrl}}",
|
||||||
"zen.api.error.trialEnded":
|
"zen.api.error.trialEnded":
|
||||||
"La promoción gratuita de {{model}} ha finalizado. Puedes seguir usando el modelo suscribiéndote a OpenCode Go - {{link}}",
|
"La promoción gratuita de {{model}} ha finalizado. Puedes seguir usando el modelo suscribiéndote a OpenCode Go - {{link}}",
|
||||||
|
|
||||||
@@ -656,6 +657,9 @@ export const dict = {
|
|||||||
"workspace.lite.subscription.useBalance": "Usa tu saldo disponible después de alcanzar los límites de uso",
|
"workspace.lite.subscription.useBalance": "Usa tu saldo disponible después de alcanzar los límites de uso",
|
||||||
"workspace.lite.subscription.selectProvider":
|
"workspace.lite.subscription.selectProvider":
|
||||||
'Selecciona "OpenCode Go" como proveedor en tu configuración de opencode para usar los modelos Go.',
|
'Selecciona "OpenCode Go" como proveedor en tu configuración de opencode para usar los modelos Go.',
|
||||||
|
"workspace.lite.providers.title": "Proveedores",
|
||||||
|
"workspace.lite.providers.description": "Controla qué proveedores se usan para el enrutamiento.",
|
||||||
|
"workspace.lite.providers.useChina": "Activar modelos alojados en China",
|
||||||
"workspace.lite.black.message":
|
"workspace.lite.black.message":
|
||||||
"Actualmente estás suscrito a OpenCode Black o estás en la lista de espera. Por favor, cancela la suscripción primero si deseas cambiar a Go.",
|
"Actualmente estás suscrito a OpenCode Black o estás en la lista de espera. Por favor, cancela la suscripción primero si deseas cambiar a Go.",
|
||||||
"workspace.lite.other.message":
|
"workspace.lite.other.message":
|
||||||
|
|||||||
@@ -254,7 +254,6 @@ export const dict = {
|
|||||||
"zen.privacy.exceptionsLink": "exceptions suivantes",
|
"zen.privacy.exceptionsLink": "exceptions suivantes",
|
||||||
|
|
||||||
"go.title": "OpenCode Go | Modèles de code à faible coût pour tous",
|
"go.title": "OpenCode Go | Modèles de code à faible coût pour tous",
|
||||||
"go.banner.text": "MiniMax M3 bénéficie de limites d’utilisation 3x supérieures pour une durée limitée",
|
|
||||||
"go.meta.description":
|
"go.meta.description":
|
||||||
"Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash.",
|
"Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash.",
|
||||||
"go.hero.title": "Modèles de code à faible coût pour tous",
|
"go.hero.title": "Modèles de code à faible coût pour tous",
|
||||||
@@ -377,6 +376,8 @@ export const dict = {
|
|||||||
"zen.api.error.userMonthlyLimitReached":
|
"zen.api.error.userMonthlyLimitReached":
|
||||||
"Vous avez atteint votre limite de dépense mensuelle de {{amount}} $. Gérez vos limites ici : {{membersUrl}}",
|
"Vous avez atteint votre limite de dépense mensuelle de {{amount}} $. Gérez vos limites ici : {{membersUrl}}",
|
||||||
"zen.api.error.modelDisabled": "Le modèle est désactivé",
|
"zen.api.error.modelDisabled": "Le modèle est désactivé",
|
||||||
|
"zen.api.error.regionNotAllowed":
|
||||||
|
"Ce modèle est hébergé en Chine. Si vous souhaitez utiliser ce modèle, activez-le dans vos paramètres : {{consoleGoUrl}}",
|
||||||
"zen.api.error.trialEnded":
|
"zen.api.error.trialEnded":
|
||||||
"La promotion gratuite de {{model}} est terminée. Vous pouvez continuer à utiliser le modèle en vous abonnant à OpenCode Go - {{link}}",
|
"La promotion gratuite de {{model}} est terminée. Vous pouvez continuer à utiliser le modèle en vous abonnant à OpenCode Go - {{link}}",
|
||||||
|
|
||||||
@@ -662,6 +663,9 @@ export const dict = {
|
|||||||
"Utilisez votre solde disponible après avoir atteint les limites d'utilisation",
|
"Utilisez votre solde disponible après avoir atteint les limites d'utilisation",
|
||||||
"workspace.lite.subscription.selectProvider":
|
"workspace.lite.subscription.selectProvider":
|
||||||
'Sélectionnez "OpenCode Go" comme fournisseur dans votre configuration opencode pour utiliser les modèles Go.',
|
'Sélectionnez "OpenCode Go" comme fournisseur dans votre configuration opencode pour utiliser les modèles Go.',
|
||||||
|
"workspace.lite.providers.title": "Fournisseurs",
|
||||||
|
"workspace.lite.providers.description": "Contrôlez les fournisseurs utilisés pour le routage.",
|
||||||
|
"workspace.lite.providers.useChina": "Activer les modèles hébergés en Chine",
|
||||||
"workspace.lite.black.message":
|
"workspace.lite.black.message":
|
||||||
"Vous êtes actuellement abonné à OpenCode Black ou sur liste d'attente. Veuillez d'abord vous désabonner si vous souhaitez passer à Go.",
|
"Vous êtes actuellement abonné à OpenCode Black ou sur liste d'attente. Veuillez d'abord vous désabonner si vous souhaitez passer à Go.",
|
||||||
"workspace.lite.other.message":
|
"workspace.lite.other.message":
|
||||||
|
|||||||
@@ -250,7 +250,6 @@ export const dict = {
|
|||||||
"zen.privacy.exceptionsLink": "seguenti eccezioni",
|
"zen.privacy.exceptionsLink": "seguenti eccezioni",
|
||||||
|
|
||||||
"go.title": "OpenCode Go | Modelli di coding a basso costo per tutti",
|
"go.title": "OpenCode Go | Modelli di coding a basso costo per tutti",
|
||||||
"go.banner.text": "MiniMax M3 offre limiti di utilizzo 3x superiori per un periodo limitato",
|
|
||||||
"go.meta.description":
|
"go.meta.description":
|
||||||
"Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.",
|
"Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.",
|
||||||
"go.hero.title": "Modelli di coding a basso costo per tutti",
|
"go.hero.title": "Modelli di coding a basso costo per tutti",
|
||||||
@@ -373,6 +372,8 @@ export const dict = {
|
|||||||
"zen.api.error.userMonthlyLimitReached":
|
"zen.api.error.userMonthlyLimitReached":
|
||||||
"Hai raggiunto il tuo limite di spesa mensile di ${{amount}}. Gestisci i tuoi limiti qui: {{membersUrl}}",
|
"Hai raggiunto il tuo limite di spesa mensile di ${{amount}}. Gestisci i tuoi limiti qui: {{membersUrl}}",
|
||||||
"zen.api.error.modelDisabled": "Il modello è disabilitato",
|
"zen.api.error.modelDisabled": "Il modello è disabilitato",
|
||||||
|
"zen.api.error.regionNotAllowed":
|
||||||
|
"Questo modello è ospitato in Cina. Se vuoi usare questo modello, abilitalo nelle tue impostazioni: {{consoleGoUrl}}",
|
||||||
"zen.api.error.trialEnded":
|
"zen.api.error.trialEnded":
|
||||||
"La promozione gratuita di {{model}} è terminata. Puoi continuare a usare il modello abbonandoti a OpenCode Go - {{link}}",
|
"La promozione gratuita di {{model}} è terminata. Puoi continuare a usare il modello abbonandoti a OpenCode Go - {{link}}",
|
||||||
|
|
||||||
@@ -654,6 +655,9 @@ export const dict = {
|
|||||||
"workspace.lite.subscription.useBalance": "Usa il tuo saldo disponibile dopo aver raggiunto i limiti di utilizzo",
|
"workspace.lite.subscription.useBalance": "Usa il tuo saldo disponibile dopo aver raggiunto i limiti di utilizzo",
|
||||||
"workspace.lite.subscription.selectProvider":
|
"workspace.lite.subscription.selectProvider":
|
||||||
'Seleziona "OpenCode Go" come provider nella tua configurazione opencode per utilizzare i modelli Go.',
|
'Seleziona "OpenCode Go" come provider nella tua configurazione opencode per utilizzare i modelli Go.',
|
||||||
|
"workspace.lite.providers.title": "Provider",
|
||||||
|
"workspace.lite.providers.description": "Controlla quali provider vengono usati per il routing.",
|
||||||
|
"workspace.lite.providers.useChina": "Abilita modelli ospitati in Cina",
|
||||||
"workspace.lite.black.message":
|
"workspace.lite.black.message":
|
||||||
"Attualmente sei abbonato a OpenCode Black o sei in lista d'attesa. Annulla l'iscrizione prima se desideri passare a Go.",
|
"Attualmente sei abbonato a OpenCode Black o sei in lista d'attesa. Annulla l'iscrizione prima se desideri passare a Go.",
|
||||||
"workspace.lite.other.message":
|
"workspace.lite.other.message":
|
||||||
|
|||||||
@@ -249,7 +249,6 @@ export const dict = {
|
|||||||
"zen.privacy.exceptionsLink": "以下の例外",
|
"zen.privacy.exceptionsLink": "以下の例外",
|
||||||
|
|
||||||
"go.title": "OpenCode Go | すべての人のための低価格なコーディングモデル",
|
"go.title": "OpenCode Go | すべての人のための低価格なコーディングモデル",
|
||||||
"go.banner.text": "MiniMax M3の利用上限が期間限定で3倍に",
|
|
||||||
"go.meta.description":
|
"go.meta.description":
|
||||||
"Goは最初の月$5、その後$10/月で、GLM-5.2、GLM-5.1、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashに対して5時間のゆとりあるリクエスト上限があります。",
|
"Goは最初の月$5、その後$10/月で、GLM-5.2、GLM-5.1、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashに対して5時間のゆとりあるリクエスト上限があります。",
|
||||||
"go.hero.title": "すべての人のための低価格なコーディングモデル",
|
"go.hero.title": "すべての人のための低価格なコーディングモデル",
|
||||||
@@ -374,6 +373,8 @@ export const dict = {
|
|||||||
"zen.api.error.userMonthlyLimitReached":
|
"zen.api.error.userMonthlyLimitReached":
|
||||||
"月額の利用上限 ${{amount}} に達しました。こちらから上限を管理してください: {{membersUrl}}",
|
"月額の利用上限 ${{amount}} に達しました。こちらから上限を管理してください: {{membersUrl}}",
|
||||||
"zen.api.error.modelDisabled": "モデルが無効です",
|
"zen.api.error.modelDisabled": "モデルが無効です",
|
||||||
|
"zen.api.error.regionNotAllowed":
|
||||||
|
"このモデルは中国でホストされています。このモデルを使用したい場合は、設定で有効にしてください: {{consoleGoUrl}}",
|
||||||
"zen.api.error.trialEnded":
|
"zen.api.error.trialEnded":
|
||||||
"{{model}} の無料プロモーションは終了しました。OpenCode Go を購読するとモデルを引き続き使用できます - {{link}}",
|
"{{model}} の無料プロモーションは終了しました。OpenCode Go を購読するとモデルを引き続き使用できます - {{link}}",
|
||||||
|
|
||||||
@@ -654,6 +655,9 @@ export const dict = {
|
|||||||
"workspace.lite.subscription.useBalance": "利用限度額に達したら利用可能な残高を使用する",
|
"workspace.lite.subscription.useBalance": "利用限度額に達したら利用可能な残高を使用する",
|
||||||
"workspace.lite.subscription.selectProvider":
|
"workspace.lite.subscription.selectProvider":
|
||||||
"Go モデルを使用するには、opencode の設定で「OpenCode Go」をプロバイダーとして選択してください。",
|
"Go モデルを使用するには、opencode の設定で「OpenCode Go」をプロバイダーとして選択してください。",
|
||||||
|
"workspace.lite.providers.title": "プロバイダー",
|
||||||
|
"workspace.lite.providers.description": "ルーティングに使用するプロバイダーを管理します。",
|
||||||
|
"workspace.lite.providers.useChina": "中国でホストされているモデルを有効にする",
|
||||||
"workspace.lite.black.message":
|
"workspace.lite.black.message":
|
||||||
"現在 OpenCode Black を購読中、またはウェイティングリストに登録されています。Go に切り替える場合は、先に登録を解除してください。",
|
"現在 OpenCode Black を購読中、またはウェイティングリストに登録されています。Go に切り替える場合は、先に登録を解除してください。",
|
||||||
"workspace.lite.other.message":
|
"workspace.lite.other.message":
|
||||||
|
|||||||
@@ -246,7 +246,6 @@ export const dict = {
|
|||||||
"zen.privacy.exceptionsLink": "다음 예외",
|
"zen.privacy.exceptionsLink": "다음 예외",
|
||||||
|
|
||||||
"go.title": "OpenCode Go | 모두를 위한 저비용 코딩 모델",
|
"go.title": "OpenCode Go | 모두를 위한 저비용 코딩 모델",
|
||||||
"go.banner.text": "MiniMax M3 사용 한도가 한시적으로 3배 확대됩니다",
|
|
||||||
"go.meta.description":
|
"go.meta.description":
|
||||||
"Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash에 대해 넉넉한 5시간 요청 한도를 제공합니다.",
|
"Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash에 대해 넉넉한 5시간 요청 한도를 제공합니다.",
|
||||||
"go.hero.title": "모두를 위한 저비용 코딩 모델",
|
"go.hero.title": "모두를 위한 저비용 코딩 모델",
|
||||||
@@ -368,6 +367,8 @@ export const dict = {
|
|||||||
"zen.api.error.userMonthlyLimitReached":
|
"zen.api.error.userMonthlyLimitReached":
|
||||||
"월간 지출 한도인 ${{amount}}에 도달했습니다. 한도 관리를 여기서 하세요: {{membersUrl}}",
|
"월간 지출 한도인 ${{amount}}에 도달했습니다. 한도 관리를 여기서 하세요: {{membersUrl}}",
|
||||||
"zen.api.error.modelDisabled": "모델이 비활성화되었습니다",
|
"zen.api.error.modelDisabled": "모델이 비활성화되었습니다",
|
||||||
|
"zen.api.error.regionNotAllowed":
|
||||||
|
"이 모델은 중국에서 호스팅됩니다. 이 모델을 사용하려면 설정에서 활성화하세요: {{consoleGoUrl}}",
|
||||||
"zen.api.error.trialEnded":
|
"zen.api.error.trialEnded":
|
||||||
"{{model}}의 무료 프로모션이 종료되었습니다. OpenCode Go를 구독하면 모델을 계속 사용할 수 있습니다 - {{link}}",
|
"{{model}}의 무료 프로모션이 종료되었습니다. OpenCode Go를 구독하면 모델을 계속 사용할 수 있습니다 - {{link}}",
|
||||||
|
|
||||||
@@ -646,6 +647,9 @@ export const dict = {
|
|||||||
"workspace.lite.subscription.useBalance": "사용 한도 도달 후에는 보유 잔액 사용",
|
"workspace.lite.subscription.useBalance": "사용 한도 도달 후에는 보유 잔액 사용",
|
||||||
"workspace.lite.subscription.selectProvider":
|
"workspace.lite.subscription.selectProvider":
|
||||||
'Go 모델을 사용하려면 opencode 설정에서 "OpenCode Go"를 공급자로 선택하세요.',
|
'Go 모델을 사용하려면 opencode 설정에서 "OpenCode Go"를 공급자로 선택하세요.',
|
||||||
|
"workspace.lite.providers.title": "공급자",
|
||||||
|
"workspace.lite.providers.description": "라우팅에 사용할 공급자를 제어합니다.",
|
||||||
|
"workspace.lite.providers.useChina": "중국에서 호스팅되는 모델 활성화",
|
||||||
"workspace.lite.black.message":
|
"workspace.lite.black.message":
|
||||||
"현재 OpenCode Black을 구독 중이거나 대기 명단에 등록되어 있습니다. Go로 전환하려면 먼저 구독을 취소해 주세요.",
|
"현재 OpenCode Black을 구독 중이거나 대기 명단에 등록되어 있습니다. Go로 전환하려면 먼저 구독을 취소해 주세요.",
|
||||||
"workspace.lite.other.message":
|
"workspace.lite.other.message":
|
||||||
|
|||||||
@@ -250,7 +250,6 @@ export const dict = {
|
|||||||
"zen.privacy.exceptionsLink": "følgende unntak",
|
"zen.privacy.exceptionsLink": "følgende unntak",
|
||||||
|
|
||||||
"go.title": "OpenCode Go | Rimelige kodemodeller for alle",
|
"go.title": "OpenCode Go | Rimelige kodemodeller for alle",
|
||||||
"go.banner.text": "MiniMax M3 får 3x bruksgrense i en begrenset periode",
|
|
||||||
"go.meta.description":
|
"go.meta.description":
|
||||||
"Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.",
|
"Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.",
|
||||||
"go.hero.title": "Rimelige kodemodeller for alle",
|
"go.hero.title": "Rimelige kodemodeller for alle",
|
||||||
@@ -374,6 +373,8 @@ export const dict = {
|
|||||||
"zen.api.error.userMonthlyLimitReached":
|
"zen.api.error.userMonthlyLimitReached":
|
||||||
"Du har nådd din månedlige utgiftsgrense på ${{amount}}. Administrer grensene dine her: {{membersUrl}}",
|
"Du har nådd din månedlige utgiftsgrense på ${{amount}}. Administrer grensene dine her: {{membersUrl}}",
|
||||||
"zen.api.error.modelDisabled": "Modellen er deaktivert",
|
"zen.api.error.modelDisabled": "Modellen er deaktivert",
|
||||||
|
"zen.api.error.regionNotAllowed":
|
||||||
|
"Denne modellen hostes i Kina. Hvis du vil bruke denne modellen, aktiver den i innstillingene dine: {{consoleGoUrl}}",
|
||||||
"zen.api.error.trialEnded":
|
"zen.api.error.trialEnded":
|
||||||
"Den gratis kampanjen for {{model}} er avsluttet. Du kan fortsette å bruke modellen ved å abonnere på OpenCode Go - {{link}}",
|
"Den gratis kampanjen for {{model}} er avsluttet. Du kan fortsette å bruke modellen ved å abonnere på OpenCode Go - {{link}}",
|
||||||
|
|
||||||
@@ -653,6 +654,9 @@ export const dict = {
|
|||||||
"workspace.lite.subscription.useBalance": "Bruk din tilgjengelige saldo etter å ha nådd bruksgrensene",
|
"workspace.lite.subscription.useBalance": "Bruk din tilgjengelige saldo etter å ha nådd bruksgrensene",
|
||||||
"workspace.lite.subscription.selectProvider":
|
"workspace.lite.subscription.selectProvider":
|
||||||
'Velg "OpenCode Go" som leverandør i opencode-konfigurasjonen din for å bruke Go-modeller.',
|
'Velg "OpenCode Go" som leverandør i opencode-konfigurasjonen din for å bruke Go-modeller.',
|
||||||
|
"workspace.lite.providers.title": "Leverandører",
|
||||||
|
"workspace.lite.providers.description": "Kontroller hvilke leverandører som brukes til ruting.",
|
||||||
|
"workspace.lite.providers.useChina": "Aktiver modeller hostet i Kina",
|
||||||
"workspace.lite.black.message":
|
"workspace.lite.black.message":
|
||||||
"Du abonnerer for øyeblikket på OpenCode Black eller står på venteliste. Vennligst avslutt abonnementet først hvis du vil bytte til Go.",
|
"Du abonnerer for øyeblikket på OpenCode Black eller står på venteliste. Vennligst avslutt abonnementet først hvis du vil bytte til Go.",
|
||||||
"workspace.lite.other.message":
|
"workspace.lite.other.message":
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user