Compare commits
258 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e484273233 | |||
| d5014dd2de | |||
| 6e9f0e6e44 | |||
| 02696a7a5e | |||
| 3641fec04f | |||
| da6626b4c1 | |||
| 458ec7b372 | |||
| d46c02ba73 | |||
| caedf36844 | |||
| 4c6e2a92ce | |||
| bf58fae51f | |||
| 32af121433 | |||
| 044ca11790 | |||
| 50348b3f9c | |||
| d357fa57fb | |||
| 7665d61947 | |||
| efd85bd9f4 | |||
| eb3476660f | |||
| 7d48a22a63 | |||
| 379aab876f | |||
| 1031bf841f | |||
| 3f0fc22b6e | |||
| 2409c7a3d5 | |||
| 92a50f2397 | |||
| ac7ee99a24 | |||
| 379adee35c | |||
| 2ef1a5991c | |||
| 83c638eaac | |||
| cb93114424 | |||
| 04d236ceed | |||
| 5455fed78e | |||
| 6dba0cc498 | |||
| c07ac0db2f | |||
| 20b090e493 | |||
| cb54824f2f | |||
| 3adfb970bf | |||
| 5ecd19db9f | |||
| 299daa2815 | |||
| 7457139849 | |||
| 373cd08b98 | |||
| f52424e05f | |||
| 4a42caef2c | |||
| 7d2618637f | |||
| fbb95a6ee3 | |||
| 40233595b0 | |||
| 403164a6ac | |||
| 1fb2ecc721 | |||
| 27c9b130c7 | |||
| 5fecf7ae9e | |||
| a0d64b12c9 | |||
| d6b897188a | |||
| 39dfbb53d0 | |||
| a39db9c6f6 | |||
| 6ca60d9204 | |||
| b3934992d1 | |||
| 4de7e90d8d | |||
| 917eca4f55 | |||
| 3cf71808c4 | |||
| f266e829cf | |||
| 3df5556223 | |||
| fc29f99190 | |||
| beb586a383 | |||
| 5668d36477 | |||
| 3706b33ee0 | |||
| 85e8ac3b36 | |||
| 91ca75c4dc | |||
| 262ef4fdc2 | |||
| 74e5644646 | |||
| 7de5aa08ff | |||
| 9495e3e903 | |||
| 1e73b76ea6 | |||
| 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 |
@@ -8,6 +8,13 @@ inputs:
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
# node-gyp@latest (invoked via bunx for native install scripts) requires Node >=22;
|
||||
# some runner images ship an older system Node on PATH
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Get baseline download URL
|
||||
id: bun-url
|
||||
shell: bash
|
||||
|
||||
@@ -34,10 +34,48 @@ jobs:
|
||||
|
||||
const now = Date.now();
|
||||
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) {
|
||||
const isPR = !!item.pull_request;
|
||||
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({
|
||||
owner: context.repo.owner,
|
||||
|
||||
@@ -17,12 +17,31 @@ jobs:
|
||||
with:
|
||||
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
|
||||
if: steps.author.outputs.skip != 'true'
|
||||
|
||||
- name: Install opencode
|
||||
if: steps.author.outputs.skip != 'true'
|
||||
run: curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
- name: Check duplicates and compliance
|
||||
if: steps.author.outputs.skip != 'true'
|
||||
env:
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -38,6 +57,7 @@ jobs:
|
||||
opencode run -m opencode/claude-sonnet-4-6 "A new issue has been created:
|
||||
|
||||
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 }}.
|
||||
|
||||
@@ -49,6 +69,8 @@ jobs:
|
||||
|
||||
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:
|
||||
|
||||
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:
|
||||
|
||||
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 -->
|
||||
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:
|
||||
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
|
||||
if: steps.author.outputs.skip != 'true'
|
||||
|
||||
- name: Install opencode
|
||||
if: steps.author.outputs.skip != 'true'
|
||||
run: curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
- name: Recheck compliance
|
||||
if: steps.author.outputs.skip != 'true'
|
||||
env:
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -148,9 +189,12 @@ jobs:
|
||||
}
|
||||
run: |
|
||||
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 }}.
|
||||
|
||||
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.
|
||||
|
||||
This project has three issue templates that every issue MUST use one of:
|
||||
|
||||
@@ -16,13 +16,32 @@ jobs:
|
||||
with:
|
||||
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
|
||||
if: steps.author.outputs.skip != 'true'
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Install opencode
|
||||
if: steps.author.outputs.skip != 'true'
|
||||
run: curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
- name: Triage issue
|
||||
if: steps.author.outputs.skip != 'true'
|
||||
env:
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
"@dnd-kit/dom": "0.5.0",
|
||||
@@ -37,6 +37,7 @@
|
||||
"@dnd-kit/solid": "0.5.0",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -83,6 +84,7 @@
|
||||
"@types/luxon": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||
@@ -91,7 +93,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"bin": {
|
||||
"lildax": "./bin/lildax.cjs",
|
||||
},
|
||||
@@ -137,9 +139,23 @@
|
||||
"effect",
|
||||
],
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode-ai/codemode",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -175,7 +191,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -202,7 +218,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -224,7 +240,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -248,7 +264,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode-ai/console-support",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode-ai/console-core": "workspace:*",
|
||||
@@ -268,7 +284,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -316,10 +332,11 @@
|
||||
"ai-gateway-provider": "3.1.2",
|
||||
"bun-pty": "0.4.8",
|
||||
"cross-spawn": "catalog:",
|
||||
"diff": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "6.9.3",
|
||||
"gitlab-ai-provider": "6.10.0",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
@@ -332,7 +349,7 @@
|
||||
"npm-package-arg": "13.0.2",
|
||||
"semver": "^7.6.3",
|
||||
"turndown": "7.2.0",
|
||||
"venice-ai-sdk-provider": "2.0.2",
|
||||
"venice-ai-sdk-provider": "2.1.1",
|
||||
"which": "6.0.1",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"zod": "catalog:",
|
||||
@@ -361,7 +378,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"effect": "catalog:",
|
||||
@@ -415,7 +432,7 @@
|
||||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -429,7 +446,7 @@
|
||||
},
|
||||
"packages/effect-sqlite-node": {
|
||||
"name": "@opencode-ai/effect-sqlite-node",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
},
|
||||
@@ -441,7 +458,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -473,7 +490,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -489,7 +506,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "4.0.0-beta.83",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.83",
|
||||
@@ -520,7 +537,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
@@ -539,7 +556,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -551,7 +568,7 @@
|
||||
"@ai-sdk/amazon-bedrock": "4.0.112",
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/azure": "3.0.49",
|
||||
"@ai-sdk/cerebras": "2.0.41",
|
||||
"@ai-sdk/cerebras": "2.0.60",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/deepinfra": "2.0.41",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -576,6 +593,7 @@
|
||||
"@octokit/graphql": "9.0.2",
|
||||
"@octokit/rest": "catalog:",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/llm": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
@@ -611,7 +629,7 @@
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "6.9.3",
|
||||
"gitlab-ai-provider": "6.10.0",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
@@ -635,7 +653,7 @@
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"turndown": "7.2.0",
|
||||
"ulid": "catalog:",
|
||||
"venice-ai-sdk-provider": "2.0.2",
|
||||
"venice-ai-sdk-provider": "2.1.1",
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"ws": "8.21.0",
|
||||
@@ -669,7 +687,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -745,7 +763,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -760,7 +778,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode-ai/server",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
@@ -775,7 +793,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode-ai/session-ui",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -819,7 +837,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -832,7 +850,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode-ai/stats-app",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@opencode-ai/stats-core": "workspace:*",
|
||||
@@ -865,7 +883,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode-ai/stats-core",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -884,7 +902,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode-ai/stats-server",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -925,7 +943,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode-ai/tui",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
@@ -952,7 +970,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -990,6 +1008,7 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||
@@ -1002,7 +1021,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1048,6 +1067,7 @@
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.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",
|
||||
"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",
|
||||
"@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",
|
||||
@@ -1919,6 +1939,8 @@
|
||||
|
||||
"@opencode-ai/client": ["@opencode-ai/client@workspace:packages/client"],
|
||||
|
||||
"@opencode-ai/codemode": ["@opencode-ai/codemode@workspace:packages/codemode"],
|
||||
|
||||
"@opencode-ai/console-app": ["@opencode-ai/console-app@workspace:packages/console/app"],
|
||||
|
||||
"@opencode-ai/console-core": ["@opencode-ai/console-core@workspace:packages/console/core"],
|
||||
@@ -3005,7 +3027,7 @@
|
||||
|
||||
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
||||
@@ -3807,7 +3829,7 @@
|
||||
|
||||
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
||||
|
||||
"gitlab-ai-provider": ["gitlab-ai-provider@6.9.3", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-lWo6b6es5+k9iXaDIvE9ECzyK4zfEza4+dQ5FN8SJpEuVRi3ZBCpHIOTa32QoYEDCBaiPh+tcyca86PfNodmlg=="],
|
||||
"gitlab-ai-provider": ["gitlab-ai-provider@6.10.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-oWEZ06rDO6JjB7INHO882wyBAQqCZVHiDHwCs5M+VPmdDj8TzhGXcYesA2CcV5RoI5lfHLKwGp5uKFB62VWpqw=="],
|
||||
|
||||
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
|
||||
|
||||
@@ -5315,6 +5337,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||
@@ -5423,7 +5447,7 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -5685,6 +5709,8 @@
|
||||
|
||||
"@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="],
|
||||
|
||||
"@astrojs/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
|
||||
"@astrojs/sitemap/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
@@ -5895,6 +5921,8 @@
|
||||
|
||||
"@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
|
||||
|
||||
"@mdx-js/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
@@ -5977,6 +6005,8 @@
|
||||
|
||||
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||
|
||||
"@opencode-ai/core/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="],
|
||||
|
||||
"@opencode-ai/desktop/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
|
||||
@@ -5987,6 +6017,8 @@
|
||||
|
||||
"@opencode-ai/llm/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
|
||||
|
||||
"@opencode-ai/script/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
|
||||
|
||||
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
|
||||
@@ -6141,6 +6173,8 @@
|
||||
|
||||
"astro/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="],
|
||||
|
||||
"astro/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"astro/common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="],
|
||||
|
||||
"astro/diff": ["diff@5.2.2", "", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="],
|
||||
@@ -6231,6 +6265,8 @@
|
||||
|
||||
"engine.io-client/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="],
|
||||
|
||||
"esast-util-from-js/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"esbuild-plugin-copy/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"esbuild-plugin-copy/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
@@ -6295,6 +6331,8 @@
|
||||
|
||||
"md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="],
|
||||
|
||||
"micromark-extension-mdxjs/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"miniflare/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],
|
||||
@@ -6327,6 +6365,8 @@
|
||||
|
||||
"nypm/tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
|
||||
|
||||
"opencode/@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.60", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.54", "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rnok3cThg6awBwaDSyiZpgRpbV7pqxGYrA89LODCo5cuEHeP2h0AM0lLHP7zIkclAdXfOm4wldKi/S2T/DGCOw=="],
|
||||
|
||||
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
|
||||
|
||||
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||
@@ -6335,6 +6375,8 @@
|
||||
|
||||
"opencode/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="],
|
||||
|
||||
"opencode/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
|
||||
|
||||
"openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
|
||||
@@ -6419,6 +6461,8 @@
|
||||
|
||||
"tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||
|
||||
"terser/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||
|
||||
"thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="],
|
||||
@@ -6437,20 +6481,24 @@
|
||||
|
||||
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
|
||||
|
||||
"unplugin/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"unplugin/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
|
||||
"unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"vite-plugin-dynamic-import/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
|
||||
|
||||
"vitest/@vitest/expect": ["@vitest/expect@4.1.7", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w=="],
|
||||
@@ -6869,6 +6917,8 @@
|
||||
|
||||
"@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@storybook/csf-plugin/unplugin/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
||||
@@ -7035,6 +7085,12 @@
|
||||
|
||||
"opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
|
||||
|
||||
"opencode/@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.54", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OyXt0zK8y2/ZIyWlbxTv2r1M7AK227S+Gl4BYOEF42q0wz1n5m4fwR8L4Fy/MQ4Ho6xje47MPsFcRdIqIyP6Rw=="],
|
||||
|
||||
"opencode/@ai-sdk/cerebras/@ai-sdk/provider": ["@ai-sdk/provider@3.0.12", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-sj9DWTJ2Ze0WR9qsiOPqoqzNx3OxL6iMxHImbhvoe9qOspekbzxNDMiJ4TIGfYHYh9w4OmBjz3prvqhzTi96+Q=="],
|
||||
|
||||
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "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-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="],
|
||||
|
||||
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
@@ -7067,6 +7123,10 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"vitest/@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
@@ -7363,6 +7423,8 @@
|
||||
|
||||
"js-beautify/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||
|
||||
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-OiWvZ57vuyHwiIKNtW1n1KX+MLmOXVG3x4fLKvUoGQw=",
|
||||
"aarch64-linux": "sha256-RnPLxVEg/UsL5IeIFWmXMSLUOG6rVrajYxhyDYj1vTA=",
|
||||
"aarch64-darwin": "sha256-KPIgcBA0pTFBPrCTSZgIbvEorbtWcMgXvyX9bFAypVs=",
|
||||
"x86_64-darwin": "sha256-6jVU7/uVId0VD24MVQ8s8Ill5b6PsKdlBgHg+oceKRg="
|
||||
"x86_64-linux": "sha256-/Tyscbq9D9pDm18icK0CBsuZPduQmMKuauPnYbae+X8=",
|
||||
"aarch64-linux": "sha256-foak7M2pny+8U5rkYpIl8tjYk5vgYkVtcZsnJN3bkV4=",
|
||||
"aarch64-darwin": "sha256-JxiN/6nF+jvLIfX/B8HEZZmp0WujTEjY8pvMva1pQy4=",
|
||||
"x86_64-darwin": "sha256-6Lt7RgGSqt0z3Lc9XbfHX/pPPlLjMnrGJUD8eqyexnk="
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -153,6 +153,7 @@
|
||||
"@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",
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Timeline Layout Continuity
|
||||
|
||||
Run from `packages/app`:
|
||||
|
||||
```sh
|
||||
bun run test:stability
|
||||
```
|
||||
|
||||
The suite runs a production build in one Chromium worker. Selected scenarios use deterministic 4x CPU stress after application readiness. This is a stress profile, not emulation of a specific device.
|
||||
|
||||
## What It Proves
|
||||
|
||||
The continuity probe samples DOM-derived layout and visibility state across browser render opportunities. Tests declare explicit contracts such as:
|
||||
|
||||
- Preserve a visible semantic anchor while the user is away from the bottom.
|
||||
- Preserve end anchoring while active content grows or new content appears.
|
||||
- Keep adjacent visible rows ordered without material overlap.
|
||||
- Keep user-selected disclosure state through updates and virtualization.
|
||||
- Avoid a sampled blank interval while one visible surface replaces another.
|
||||
- Preserve logical row and control identity where local state or focus depends on it.
|
||||
- Keep keyboard, wheel, and nested-scroll ownership consistent during remeasurement.
|
||||
|
||||
The suite exercises real browser reducer, projection, component, virtualizer, layout, focus, and interaction code. The backend and event producer are controlled fixtures.
|
||||
|
||||
## What It Does Not Prove
|
||||
|
||||
The pass/fail oracle does not inspect every compositor-presented pixel. A sample taken after `requestAnimationFrame` is a DOM/layout observation, not proof that every sampled state was displayed or that every displayed frame was sampled.
|
||||
|
||||
The suite does not provide complete coverage for:
|
||||
|
||||
- Compositor-only or raster-only glitches.
|
||||
- Color, contrast, canvas, WebGL, masks, irregular clips, or arbitrary occlusion.
|
||||
- Physical display refresh rates, native OS scaling, or a named low-end device.
|
||||
- TCP packetization, proxy buffering, or the complete real server/provider pipeline.
|
||||
|
||||
Playwright video, trace, screenshots, and observation JSON are diagnostic evidence. They are not pixel baselines and do not participate in normal pass/fail decisions.
|
||||
|
||||
For optional before/violation/after screenshots, set `OPENCODE_STABILITY_CAPTURE=1`. Capture is opt-in because compositor readback can perturb timing.
|
||||
|
||||
## Test Layers
|
||||
|
||||
- **Projection:** admitted rows, grouping, labels, and final visible states.
|
||||
- **Local state:** disclosure state, identity, duplicate delivery, and virtualization restoration.
|
||||
- **Interaction:** wheel, keyboard, nested scrolling, actionability, and focus behavior.
|
||||
- **Layout continuity:** anchoring, adjacency, responsive reflow, and visible surface handoffs.
|
||||
- **Reducer hardening:** validly shaped but intentionally reordered, duplicated, removed, or replaced events.
|
||||
- **Oracle contract:** pure analyzer and browser sampler calibration tests.
|
||||
|
||||
Production-lifecycle fixtures should model states emitted by the current producer. Impossible or reordered sequences belong in reducer-hardening tests and must not be described as normal provider behavior.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
Failures retain:
|
||||
|
||||
- `video.webm`
|
||||
- `trace.zip`
|
||||
- failure screenshot
|
||||
- sampled DOM/layout trace JSON
|
||||
- event markers and summarized violations
|
||||
|
||||
The analyzer records both unclipped layout bounds and ancestor-clipped visible intersections. Scrollbar and raw `scrollTop` changes alone do not fail continuity checks; user-visible semantic anchor movement does.
|
||||
@@ -0,0 +1,250 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
shell,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
waitForVisualSettle,
|
||||
type TimelineMessage,
|
||||
} from "./fixture"
|
||||
|
||||
test.describe("timeline adverse visual stability", () => {
|
||||
test("does not pull a scrolled-away user while an active shell grows", async ({ page }, testInfo) => {
|
||||
const activeShellID = "prt_adverse_01_shell"
|
||||
const messages = [
|
||||
...history(24),
|
||||
userMessage(),
|
||||
assistantMessage([shell(activeShellID, "running")], { completed: false }),
|
||||
]
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages,
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
eventRetry: 30,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", {
|
||||
has: page.locator('[data-timeline-row="AssistantPart"]'),
|
||||
})
|
||||
await scroller.evaluate((element) => {
|
||||
element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -450 }))
|
||||
element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - 450)
|
||||
})
|
||||
await page.waitForTimeout(150)
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeGreaterThan(100)
|
||||
const anchor = await scroller.evaluate((element) => {
|
||||
const view = element.getBoundingClientRect()
|
||||
return [...element.querySelectorAll<HTMLElement>("[data-timeline-key]")].find((row) => {
|
||||
const rect = row.getBoundingClientRect()
|
||||
return rect.top >= view.top + 40 && rect.bottom <= view.bottom - 40
|
||||
})?.dataset.timelineKey
|
||||
})
|
||||
expect(anchor).toBeTruthy()
|
||||
await waitForVisualSettle(page, [`[data-timeline-key="${anchor}"]`])
|
||||
|
||||
const regions = defineVisualRegions({
|
||||
anchor: { selector: `[data-timeline-key="${anchor}"]` },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(shell(activeShellID, "running", lines(1))), 180)
|
||||
await timeline.send(partUpdated(shell(activeShellID, "running", lines(10))), 90)
|
||||
await timeline.send(partUpdated(shell(activeShellID, "running", lines(50))), 350)
|
||||
await timeline.send(partUpdated(shell(activeShellID, "completed", lines(50))), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"scrolled-away-shell",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["anchor"] },
|
||||
{ type: "unique", regions: ["anchor"] },
|
||||
{ type: "stable", regions: ["anchor"] },
|
||||
{ type: "fixed", regions: ["anchor"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
test("preserves an explicit shell state across virtualization", async ({ page }) => {
|
||||
const targetID = "prt_virtual_shell"
|
||||
const messages = [
|
||||
userMessage(undefined, { id: "msg_0000_virtual_user", created: 1700000000000 }),
|
||||
assistantMessage([shell(targetID, "completed", lines(20))], {
|
||||
id: "msg_0001_virtual_assistant",
|
||||
parentID: "msg_0000_virtual_user",
|
||||
created: 1700000001000,
|
||||
}),
|
||||
...history(35, 10),
|
||||
]
|
||||
await setupTimeline(page, { messages, settings: { shellToolPartsExpanded: false } })
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate((element) => {
|
||||
element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1_000 }))
|
||||
element.scrollTop = 0
|
||||
})
|
||||
await page.waitForTimeout(300)
|
||||
const trigger = page.locator(`[data-timeline-part-id="${targetID}"] [data-slot="collapsible-trigger"]`)
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
|
||||
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
||||
await expect(page.locator(`[data-timeline-part-id="${targetID}"]`)).toHaveCount(0)
|
||||
await scroller.evaluate((element) => (element.scrollTop = 0))
|
||||
await expect(trigger).toBeVisible()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
})
|
||||
|
||||
test("keeps narrow viewport rows ordered during long shell growth", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_narrow_01_shell"
|
||||
const followingID = "prt_narrow_02_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[shell(shellID, "running"), textPart(followingID, "A narrow following row that wraps across lines.")],
|
||||
{
|
||||
completed: false,
|
||||
},
|
||||
),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
viewport: { width: 430, height: 800 },
|
||||
cpuRate: 4,
|
||||
})
|
||||
await waitForVisualSettle(page, [
|
||||
`[data-timeline-part-id="${shellID}"]`,
|
||||
`[data-timeline-part-id="${followingID}"]`,
|
||||
])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(shell(shellID, "running", wideLines(10))), 100)
|
||||
await timeline.send(partUpdated(shell(shellID, "running", wideLines(50))), 300)
|
||||
await timeline.send(partUpdated(shell(shellID, "completed", wideLines(50))), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"narrow-shell",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["shell", "following"] },
|
||||
{ type: "unique", regions: ["shell", "following"] },
|
||||
{ type: "stable", regions: ["shell", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["shell", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("keeps visible rows ordered while resizing desktop to narrow and back", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_resize_01_shell"
|
||||
const contextIDs = ["prt_resize_02_read", "prt_resize_03_glob"]
|
||||
const followingID = "prt_resize_04_following"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
shell(shellID, "completed", wideLines(15)),
|
||||
toolPart(contextIDs[0]!, "read", "completed", { filePath: "src/a.ts" }),
|
||||
toolPart(contextIDs[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
|
||||
textPart(followingID, "Following responsive timeline content that wraps on narrow screens."),
|
||||
]),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
const group = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
context: { selector: group, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await page.setViewportSize({ width: 430, height: 800 })
|
||||
await page.waitForTimeout(500)
|
||||
await page.setViewportSize({ width: 900, height: 800 })
|
||||
await page.waitForTimeout(500)
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await page.waitForTimeout(500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"responsive-resize",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["shell", "context", "following"] },
|
||||
{ type: "unique", regions: ["shell", "context", "following"] },
|
||||
{ type: "stable", regions: ["shell", "context", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 4, maxReversals: 4 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "flow", regions: ["shell", "context", "following"] },
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function history(count: number, offset = 0): TimelineMessage[] {
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const value = index + offset
|
||||
const prefix = `msg_0${String(value).padStart(3, "0")}_history`
|
||||
const userID = `${prefix}_a_user`
|
||||
return [
|
||||
userMessage(undefined, { id: userID, created: 1699990000000 + value * 10_000 }),
|
||||
assistantMessage(
|
||||
[
|
||||
textPart(
|
||||
`prt_history_${String(value).padStart(3, "0")}`,
|
||||
`Historical response ${value}. ${"Stable history content. ".repeat(8)}`,
|
||||
),
|
||||
],
|
||||
{
|
||||
id: `${prefix}_b_assistant`,
|
||||
parentID: userID,
|
||||
created: 1699990001000 + value * 10_000,
|
||||
},
|
||||
),
|
||||
]
|
||||
}).flat()
|
||||
}
|
||||
|
||||
function lines(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
||||
}
|
||||
|
||||
function wideLines(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => `line ${index + 1} ${"wide-output-".repeat(20)}`).join("\n")
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantID,
|
||||
assistantMessage,
|
||||
event,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
waitForVisualSettle,
|
||||
} from "./fixture"
|
||||
|
||||
const inputs = {
|
||||
read: { filePath: "src/a.ts", offset: 0, limit: 120 },
|
||||
glob: { path: ".", pattern: "**/*.ts" },
|
||||
grep: { path: ".", pattern: "stable", include: "*.ts" },
|
||||
list: { path: "src" },
|
||||
}
|
||||
|
||||
test("appends context operations while the group is expanded", async ({ page }, testInfo) => {
|
||||
const firstID = "prt_append_01_read"
|
||||
const followingID = "prt_append_99_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([toolPart(firstID, "read", "running", inputs.read), textPart(followingID, "Following append")], {
|
||||
completed: false,
|
||||
}),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const initialGroup = `[data-timeline-part-ids="${firstID}"]`
|
||||
await page.locator(`${initialGroup} [data-slot="collapsible-trigger"]`).click()
|
||||
await waitForVisualSettle(page, [initialGroup, `[data-timeline-part-id="${followingID}"]`])
|
||||
const regions = defineVisualRegions({
|
||||
context: {
|
||||
selector: '[data-timeline-part-ids^="prt_append_01_read"]',
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(toolPart("prt_append_02_glob", "glob", "running", inputs.glob)), 180)
|
||||
await timeline.send(partUpdated(toolPart("prt_append_03_grep", "grep", "completed", inputs.grep)), 240)
|
||||
await timeline.send(partUpdated(toolPart("prt_append_04_list", "list", "completed", inputs.list)), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"context-append",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["context", "following"] },
|
||||
{ type: "unique", regions: ["context", "following"] },
|
||||
{ type: "stable", regions: ["context", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: ["following"], maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["context", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
await expect(
|
||||
page.locator(
|
||||
'[data-timeline-part-ids="prt_append_01_read,prt_append_02_glob,prt_append_03_grep,prt_append_04_list"]',
|
||||
),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.locator('[data-timeline-part-ids^="prt_append_01_read"] [data-slot="collapsible-trigger"]'),
|
||||
).toHaveAttribute("aria-expanded", "true")
|
||||
})
|
||||
|
||||
test("splits and merges context groups when a middle text part changes", async ({ page }, testInfo) => {
|
||||
const textID = "prt_split_02_text"
|
||||
const followingID = "prt_split_99_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart("prt_split_01_read", "read", "completed", inputs.read),
|
||||
textPart(textID, "Boundary"),
|
||||
toolPart("prt_split_03_glob", "glob", "completed", inputs.glob),
|
||||
textPart(followingID, "Following split groups"),
|
||||
]),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: textID }),
|
||||
500,
|
||||
)
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_split_01_read,prt_split_03_glob"]')).toBeVisible()
|
||||
await timeline.send(partUpdated(textPart(textID, "Boundary restored")), 500)
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_split_01_read"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_split_03_glob"]')).toBeVisible()
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"context-split-merge",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["following"] },
|
||||
{ type: "unique", regions: ["following"] },
|
||||
{ type: "stable", regions: ["following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 1 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("removing the first context member replaces the group once without overlapping following content", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const ids = ["prt_key_01_read", "prt_key_02_glob", "prt_key_03_grep"]
|
||||
const followingID = "prt_key_99_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(ids[0]!, "read", "completed", inputs.read),
|
||||
toolPart(ids[1]!, "glob", "completed", inputs.glob),
|
||||
toolPart(ids[2]!, "grep", "completed", inputs.grep),
|
||||
textPart(followingID, "Following replaced group"),
|
||||
]),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const original = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
|
||||
const originalRowKey = await original.evaluate((element) =>
|
||||
element.closest("[data-timeline-key]")?.getAttribute("data-timeline-key"),
|
||||
)
|
||||
await original.locator('[data-slot="collapsible-trigger"]').click()
|
||||
await expect(original.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
|
||||
const regions = defineVisualRegions({
|
||||
context: {
|
||||
selector: '[data-timeline-part-ids*="prt_key_02_glob"]',
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: ids[0] }),
|
||||
500,
|
||||
)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"context-first-remove",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["context", "following"] },
|
||||
{ type: "unique", regions: ["context", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "flow", regions: ["context", "following"] },
|
||||
]),
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"]`)).toBeVisible()
|
||||
expect(
|
||||
await page
|
||||
.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"]`)
|
||||
.evaluate((element) => element.closest("[data-timeline-key]")?.getAttribute("data-timeline-key")),
|
||||
).toBe(originalRowKey)
|
||||
await expect(
|
||||
page.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"] [data-slot="collapsible-trigger"]`),
|
||||
).toHaveAttribute("aria-expanded", "true")
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
import { test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
shell,
|
||||
textPart,
|
||||
userMessage,
|
||||
waitForVisualSettle,
|
||||
} from "./fixture"
|
||||
|
||||
// Fractional scaling exercises different browser rounding than the baseline.
|
||||
for (const deviceScaleFactor of [1, 1.25]) {
|
||||
test(`keeps shell growth ordered at device scale ${deviceScaleFactor}`, async ({ page }, testInfo) => {
|
||||
const shellID = `prt_dpr_${String(deviceScaleFactor).replace(".", "_")}_01_shell`
|
||||
const followingID = `prt_dpr_${String(deviceScaleFactor).replace(".", "_")}_02_following`
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([shell(shellID, "running"), textPart(followingID, "Following scaled shell")], {
|
||||
completed: false,
|
||||
}),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
deviceScaleFactor,
|
||||
seedHistory: true,
|
||||
})
|
||||
await waitForVisualSettle(page, [
|
||||
`[data-timeline-part-id="${shellID}"]`,
|
||||
`[data-timeline-part-id="${followingID}"]`,
|
||||
])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(shell(shellID, "running", lines(20))), 180)
|
||||
await timeline.send(partUpdated(shell(shellID, "completed", lines(20))), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(testInfo, `dpr-${deviceScaleFactor}`, trace, shellPlan(regions))
|
||||
})
|
||||
}
|
||||
|
||||
for (const reducedMotion of [true]) {
|
||||
test(`keeps shell and status transitions ordered with reduced motion ${reducedMotion}`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const shellID = `prt_motion_${reducedMotion}_01_shell`
|
||||
const followingID = `prt_motion_${reducedMotion}_02_following`
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([shell(shellID, "running"), textPart(followingID, "Following motion profile")], {
|
||||
completed: false,
|
||||
}),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
reducedMotion,
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
await waitForVisualSettle(page, [
|
||||
`[data-timeline-part-id="${shellID}"]`,
|
||||
`[data-timeline-part-id="${followingID}"]`,
|
||||
])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(shell(shellID, "completed", lines(10))), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(testInfo, `reduced-motion-${reducedMotion}`, trace, shellPlan(regions))
|
||||
})
|
||||
}
|
||||
|
||||
function lines(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
||||
}
|
||||
|
||||
function shellPlan<Regions extends ReturnType<typeof defineVisualRegions>>(
|
||||
regions: Regions & Record<"shell" | "following", { selector: string }>,
|
||||
) {
|
||||
return visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["shell", "following"] },
|
||||
{ type: "unique", regions: ["shell", "following"] },
|
||||
{ type: "stable", regions: ["shell", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["shell", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
waitForVisualSettle,
|
||||
} from "./fixture"
|
||||
|
||||
const profiles = [
|
||||
{ name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } },
|
||||
{
|
||||
name: "multi patch",
|
||||
tool: "apply_patch",
|
||||
input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] },
|
||||
},
|
||||
] as const
|
||||
|
||||
for (const profile of profiles) {
|
||||
test(`stabilizes ${profile.name} pending to completed`, async ({ page }, testInfo) => {
|
||||
const partID = `prt_file_matrix_${profiles.indexOf(profile)}`
|
||||
const followingID = `prt_file_matrix_following_${profiles.indexOf(profile)}`
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
toolPart(partID, profile.tool, "pending", profile.input),
|
||||
textPart(followingID, `Following ${profile.name}`),
|
||||
],
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
})
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${partID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
||||
const regions = defineVisualRegions({
|
||||
tool: { selector: `[data-timeline-part-id="${partID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(toolPart(partID, profile.tool, "running", profile.input)), 180)
|
||||
await timeline.send(partUpdated(completedPart(partID, profile)), 900)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
`file-${profile.name}`,
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["tool", "following"] },
|
||||
{ type: "unique", regions: ["tool", "following"] },
|
||||
{ type: "stable", regions: ["tool", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 1 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["tool", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function completedPart(partID: string, profile: (typeof profiles)[number]) {
|
||||
if (profile.tool === "edit") {
|
||||
return toolPart(partID, profile.tool, "completed", profile.input, {
|
||||
metadata: {
|
||||
filediff: {
|
||||
file: "src/edit.ts",
|
||||
additions: 50,
|
||||
deletions: 50,
|
||||
before: source(50, false),
|
||||
after: source(50, true),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
const files = [
|
||||
patchFile("src/a.ts", "update"),
|
||||
patchFile("src/b.ts", "add"),
|
||||
patchFile("src/old.ts", "delete"),
|
||||
{ ...patchFile("src/moved.ts", "move"), move: "src/new-place.ts" },
|
||||
]
|
||||
return toolPart(partID, profile.tool, "completed", profile.input, { metadata: { files } })
|
||||
}
|
||||
|
||||
function patchFile(filePath: string, type: "add" | "update" | "delete" | "move") {
|
||||
return {
|
||||
filePath,
|
||||
relativePath: filePath,
|
||||
type,
|
||||
additions: type === "delete" ? 0 : 20,
|
||||
deletions: type === "add" ? 0 : 20,
|
||||
before: type === "add" ? undefined : source(20, false),
|
||||
after: type === "delete" ? undefined : source(20, true),
|
||||
}
|
||||
}
|
||||
|
||||
function source(count: number, changed: boolean) {
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`,
|
||||
).join("")
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
waitForVisualSettle,
|
||||
} from "./fixture"
|
||||
|
||||
test("adds patch files incrementally without resetting outer expansion", async ({ page }, testInfo) => {
|
||||
const patchID = "prt_incremental_01_patch"
|
||||
const followingID = "prt_incremental_02_following"
|
||||
const first = patchFile("src/a.ts", "update")
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
toolPart(patchID, "apply_patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
|
||||
textPart(followingID, "Following incremental patch"),
|
||||
],
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
const trigger = page.locator(`[data-timeline-part-id="${patchID}"] [data-slot="collapsible-trigger"]`).first()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${patchID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
||||
const regions = defineVisualRegions({
|
||||
patch: { selector: `[data-timeline-part-id="${patchID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
const second = patchFile("src/b.ts", "add")
|
||||
const third = patchFile("src/old.ts", "delete")
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(
|
||||
patchID,
|
||||
"apply_patch",
|
||||
"running",
|
||||
{ files: [first.filePath, second.filePath] },
|
||||
{ metadata: { files: [first, second] } },
|
||||
),
|
||||
),
|
||||
240,
|
||||
)
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(
|
||||
patchID,
|
||||
"apply_patch",
|
||||
"completed",
|
||||
{ files: [first.filePath, second.filePath, third.filePath] },
|
||||
{ metadata: { files: [first, second, third] } },
|
||||
),
|
||||
),
|
||||
800,
|
||||
)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"incremental-patch",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["patch", "following"] },
|
||||
{ type: "unique", regions: ["patch", "following"] },
|
||||
{ type: "stable", regions: ["patch", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: ["following"], maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["patch", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(page.locator('[data-scope="apply-patch"] [data-type="delete"]')).toBeVisible()
|
||||
})
|
||||
|
||||
function patchFile(filePath: string, type: "add" | "update" | "delete") {
|
||||
return {
|
||||
filePath,
|
||||
relativePath: filePath,
|
||||
type,
|
||||
additions: type === "delete" ? 0 : 4,
|
||||
deletions: type === "add" ? 0 : 3,
|
||||
before: type === "add" ? undefined : source(false),
|
||||
after: type === "delete" ? undefined : source(true),
|
||||
}
|
||||
}
|
||||
|
||||
function source(changed: boolean) {
|
||||
return Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join(
|
||||
"",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
assistantMessage,
|
||||
event,
|
||||
toolPart,
|
||||
userMessage,
|
||||
validateTimelineEvent,
|
||||
validateTimelineMessages,
|
||||
type PartSeed,
|
||||
} from "./fixture"
|
||||
|
||||
describe("timeline fixture validation", () => {
|
||||
test("accepts a valid timeline", () => {
|
||||
expect(validateTimelineMessages([userMessage(), assistantMessage()])).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("rejects malformed SDK values at runtime", () => {
|
||||
expect(() =>
|
||||
assistantMessage([], {
|
||||
error: { name: "APIError", data: { message: "failed" } } as never,
|
||||
}),
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
validateTimelineEvent({
|
||||
directory: "C:/OpenCode/TimelineStability",
|
||||
payload: {
|
||||
id: "evt_invalid_status",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "ses_timeline_stability", status: { type: "retry", attempt: 1 } },
|
||||
},
|
||||
}),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
test("rejects duplicate IDs and orphan assistants", () => {
|
||||
expect(() => validateTimelineMessages([userMessage(), userMessage()])).toThrow(/duplicate message ID/)
|
||||
expect(() =>
|
||||
validateTimelineMessages([userMessage(), assistantMessage([], { parentID: "msg_missing_parent" })]),
|
||||
).toThrow(/parent user/)
|
||||
})
|
||||
|
||||
test("assigns deterministic event IDs", () => {
|
||||
const first = event("session.status", { sessionID: "ses_timeline_stability", status: { type: "busy" } })
|
||||
const second = event("session.status", { sessionID: "ses_timeline_stability", status: { type: "idle" } })
|
||||
expect(first.payload.id).toMatch(/^evt_timeline_\d{4}$/)
|
||||
expect(Number(second.payload.id.slice(-4))).toBe(Number(first.payload.id.slice(-4)) + 1)
|
||||
})
|
||||
})
|
||||
|
||||
if (false) {
|
||||
const userSeed = { id: "prt_type_user", type: "text", text: "typed" } satisfies PartSeed<"user">
|
||||
userMessage([userSeed])
|
||||
|
||||
// @ts-expect-error Tool completion fields are not valid while pending.
|
||||
toolPart("prt_invalid_pending", "bash", "pending", {}, { output: "impossible" })
|
||||
// @ts-expect-error Tool completion fields are not valid while running.
|
||||
toolPart("prt_invalid_running", "bash", "running", {}, { output: "impossible" })
|
||||
// @ts-expect-error Tool error fields are not valid after completion.
|
||||
toolPart("prt_invalid_completed", "bash", "completed", {}, { error: "impossible" })
|
||||
|
||||
assistantMessage([
|
||||
// @ts-expect-error Agent references belong to user messages, not assistant messages.
|
||||
{ id: "prt_invalid_owner", type: "agent", name: "explore", source: { value: "@explore", start: 0, end: 8 } },
|
||||
])
|
||||
|
||||
// @ts-expect-error Retry status events require message and next.
|
||||
event("session.status", { sessionID: "ses_timeline_stability", status: { type: "retry", attempt: 1 } })
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
|
||||
import { SessionV1 } from "@opencode-ai/schema/session-v1"
|
||||
import type {
|
||||
AssistantMessage,
|
||||
GlobalEvent,
|
||||
Message,
|
||||
Part,
|
||||
Session,
|
||||
SessionStatus,
|
||||
ToolPart,
|
||||
ToolState,
|
||||
UserMessage,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { expect, type Page } from "@playwright/test"
|
||||
import { Schema } from "effect"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { installSseTransport } from "../../utils/sse-transport"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
|
||||
export const directory = "C:/OpenCode/TimelineStability"
|
||||
export const projectID = "proj_timeline_stability"
|
||||
export const sessionID = "ses_timeline_stability"
|
||||
export const userID = "msg_1000_timeline_user"
|
||||
export const assistantID = "msg_1001_timeline_assistant"
|
||||
export const title = "Timeline visual stability"
|
||||
export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||
|
||||
type TimelinePayload = Extract<
|
||||
GlobalEvent["payload"],
|
||||
{
|
||||
type:
|
||||
| "message.updated"
|
||||
| "message.removed"
|
||||
| "message.part.updated"
|
||||
| "message.part.removed"
|
||||
| "message.part.delta"
|
||||
| "session.status"
|
||||
}
|
||||
>
|
||||
|
||||
type DeepReadonly<Value> = Value extends readonly unknown[]
|
||||
? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
|
||||
: Value extends object
|
||||
? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
|
||||
: Value
|
||||
|
||||
export type TimelineEvent = DeepReadonly<Omit<GlobalEvent, "payload"> & { payload: TimelinePayload }>
|
||||
export type EventPayload = TimelineEvent
|
||||
export type ToolStatus = ToolState["status"]
|
||||
export type TimelineMessage = { info: UserMessage; parts: Part[] } | { info: AssistantMessage; parts: Part[] }
|
||||
|
||||
type UserPart = Extract<Part, { type: "text" | "file" | "agent" | "subtask" }>
|
||||
type AssistantPart = Exclude<Part, { type: "agent" | "subtask" }>
|
||||
type OwnedPart<Owner extends Message["role"]> = Owner extends "user" ? UserPart : AssistantPart
|
||||
export type PartSeed<Owner extends Message["role"]> =
|
||||
OwnedPart<Owner> extends infer Candidate
|
||||
? Candidate extends Part
|
||||
? Omit<Candidate, "sessionID" | "messageID">
|
||||
: never
|
||||
: never
|
||||
|
||||
type ToolOptions<State extends ToolStatus> = State extends "pending"
|
||||
? { output?: never; title?: never; metadata?: never; error?: never }
|
||||
: State extends "running"
|
||||
? { title?: string; metadata?: Record<string, unknown>; output?: never; error?: never }
|
||||
: State extends "error"
|
||||
? { error?: string; metadata?: Record<string, unknown>; output?: never; title?: never }
|
||||
: { output?: string; title?: string; metadata?: Record<string, unknown>; error?: never }
|
||||
|
||||
const decodeOptions = { errors: "all", onExcessProperty: "error" } as const
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionV1.WithParts)
|
||||
const decodePart = Schema.decodeUnknownSync(SessionV1.Part)
|
||||
const decodeStatus = Schema.decodeUnknownSync(SessionStatusEvent.Info)
|
||||
const timelineEventSchema = Schema.Union([
|
||||
eventSchema("message.updated", SessionV1.Event.MessageUpdated.data),
|
||||
eventSchema("message.removed", SessionV1.Event.MessageRemoved.data),
|
||||
eventSchema("message.part.updated", SessionV1.Event.PartUpdated.data),
|
||||
eventSchema("message.part.removed", SessionV1.Event.PartRemoved.data),
|
||||
eventSchema("message.part.delta", SessionV1.Event.PartDelta.data),
|
||||
eventSchema("session.status", SessionStatusEvent.Status.data),
|
||||
])
|
||||
const decodeEvent = Schema.decodeUnknownSync(timelineEventSchema)
|
||||
let eventSequence = 0
|
||||
|
||||
export async function setupTimeline(
|
||||
page: Page,
|
||||
input: {
|
||||
messages?: TimelineMessage[]
|
||||
settings?: Record<string, boolean>
|
||||
sessions?: Session[]
|
||||
cpuRate?: number
|
||||
viewport?: { width: number; height: number }
|
||||
eventRetry?: number
|
||||
reducedMotion?: boolean
|
||||
locale?: string
|
||||
deviceScaleFactor?: number
|
||||
seedHistory?: boolean
|
||||
} = {},
|
||||
) {
|
||||
const sessions = input.sessions ?? [session()]
|
||||
const messages = validateTimelineMessages([
|
||||
...(input.seedHistory ? historyMessages(18) : []),
|
||||
...(input.messages ?? [userMessage(), assistantMessage()]),
|
||||
])
|
||||
const active = messages.findLast((message) => message.info.role === "assistant")
|
||||
const initialStatus = decodeStatus(
|
||||
active?.info.role === "assistant" && active.info.time.completed === undefined ? { type: "busy" } : { type: "idle" },
|
||||
decodeOptions,
|
||||
)
|
||||
const transport = await installSseTransport<EventPayload>(page, {
|
||||
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
|
||||
retry: input.eventRetry ?? 20,
|
||||
})
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: project(),
|
||||
provider: provider(),
|
||||
sessions,
|
||||
sessionStatus: { [sessionID]: initialStatus },
|
||||
pageMessages: () => ({
|
||||
items: messages,
|
||||
}),
|
||||
})
|
||||
await page.addInitScript((settings) => {
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
general: {
|
||||
editToolPartsExpanded: false,
|
||||
shellToolPartsExpanded: false,
|
||||
showReasoningSummaries: false,
|
||||
showSessionProgressBar: true,
|
||||
...settings,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}, input.settings ?? {})
|
||||
if (input.locale) {
|
||||
await page.addInitScript((locale) => {
|
||||
localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale }))
|
||||
}, input.locale)
|
||||
}
|
||||
if (input.reducedMotion) await page.emulateMedia({ reducedMotion: "reduce" })
|
||||
await page.setViewportSize(input.viewport ?? { width: 1400, height: 900 })
|
||||
if (input.deviceScaleFactor) {
|
||||
const devtools = await page.context().newCDPSession(page)
|
||||
const viewport = input.viewport ?? { width: 1400, height: 900 }
|
||||
await devtools.send("Emulation.setDeviceMetricsOverride", {
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
deviceScaleFactor: input.deviceScaleFactor,
|
||||
mobile: false,
|
||||
})
|
||||
}
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await transport.waitForConnection()
|
||||
await expectSessionTitle(page, title)
|
||||
if (input.cpuRate && input.cpuRate > 1) {
|
||||
const devtools = await page.context().newCDPSession(page)
|
||||
await devtools.send("Emulation.setCPUThrottlingRate", { rate: input.cpuRate })
|
||||
}
|
||||
|
||||
return {
|
||||
transport,
|
||||
async send(event: TimelineEvent, delay = 0) {
|
||||
const valid = validateTimelineEvent(event)
|
||||
await transport.send(valid, { marker: describeEvent(valid) })
|
||||
if (delay) await page.waitForTimeout(delay)
|
||||
},
|
||||
async sendAll(sequence: { event: TimelineEvent; delay: number }[]) {
|
||||
for (const item of sequence) {
|
||||
const valid = validateTimelineEvent(item.event)
|
||||
await transport.send(valid, { marker: describeEvent(valid) })
|
||||
await page.waitForTimeout(item.delay)
|
||||
}
|
||||
},
|
||||
async settle(frames = 3) {
|
||||
await page.evaluate(
|
||||
(frames) =>
|
||||
new Promise<void>((resolve) => {
|
||||
let remaining = frames
|
||||
const tick = () => {
|
||||
remaining--
|
||||
if (remaining <= 0) return resolve()
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
}),
|
||||
frames,
|
||||
)
|
||||
},
|
||||
async waitForPart(partID: string) {
|
||||
await expect(page.locator(`[data-timeline-part-id="${partID}"]`).first()).toBeVisible()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function describeEvent(event: EventPayload) {
|
||||
if (event.payload.type === "message.part.updated") {
|
||||
const part = event.payload.properties.part
|
||||
return [
|
||||
event.payload.type,
|
||||
part.id,
|
||||
part.type === "tool" ? part.tool : part.type,
|
||||
part.type === "tool" ? part.state.status : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(":")
|
||||
}
|
||||
if (event.payload.type === "session.status") {
|
||||
const status = event.payload.properties.status
|
||||
return [event.payload.type, status.type, status.type === "retry" ? status.attempt : undefined]
|
||||
.filter((value) => value !== undefined)
|
||||
.join(":")
|
||||
}
|
||||
return event.payload.type
|
||||
}
|
||||
|
||||
export function event<const Type extends TimelinePayload["type"]>(
|
||||
type: Type,
|
||||
properties: Extract<TimelinePayload, { type: Type }>["properties"],
|
||||
): TimelineEvent
|
||||
export function event(type: TimelinePayload["type"], properties: TimelinePayload["properties"]): TimelineEvent {
|
||||
return validateTimelineEvent({
|
||||
directory,
|
||||
payload: { id: `evt_timeline_${String(++eventSequence).padStart(4, "0")}`, type, properties },
|
||||
})
|
||||
}
|
||||
|
||||
export function validateTimelineEvent(input: unknown): TimelineEvent {
|
||||
return decodeEvent(input, decodeOptions)
|
||||
}
|
||||
|
||||
export function validateTimelineMessages(input: readonly TimelineMessage[]): TimelineMessage[] {
|
||||
input.forEach((message) => decodeMessage(message, decodeOptions))
|
||||
const messages = [...input]
|
||||
const messageIDs = new Set<string>()
|
||||
const partIDs = new Set<string>()
|
||||
const users = new Set(messages.filter((message) => message.info.role === "user").map((message) => message.info.id))
|
||||
|
||||
messages.forEach((message) => {
|
||||
if (messageIDs.has(message.info.id))
|
||||
throw new Error(`Timeline fixture has duplicate message ID: ${message.info.id}`)
|
||||
messageIDs.add(message.info.id)
|
||||
if (message.info.role === "assistant" && !users.has(message.info.parentID))
|
||||
throw new Error(`Timeline assistant ${message.info.id} must reference a parent user in the fixture`)
|
||||
message.parts.forEach((part) => {
|
||||
if (part.sessionID !== message.info.sessionID || part.messageID !== message.info.id)
|
||||
throw new Error(`Timeline part ${part.id} ownership does not match message ${message.info.id}`)
|
||||
if (message.info.role === "user" && !["text", "file", "agent", "subtask"].includes(part.type))
|
||||
throw new Error(`Timeline user message ${message.info.id} cannot own ${part.type} part ${part.id}`)
|
||||
if (message.info.role === "assistant" && ["agent", "subtask"].includes(part.type))
|
||||
throw new Error(`Timeline assistant message ${message.info.id} cannot own ${part.type} part ${part.id}`)
|
||||
if (partIDs.has(part.id)) throw new Error(`Timeline fixture has duplicate part ID: ${part.id}`)
|
||||
partIDs.add(part.id)
|
||||
})
|
||||
})
|
||||
return messages
|
||||
}
|
||||
|
||||
export async function waitForVisualSettle(page: Page, selectors: string[], stableFrames = 3) {
|
||||
await page.waitForFunction(
|
||||
({ selectors, stableFrames }) => {
|
||||
const elements = selectors.map((selector) => document.querySelector<HTMLElement>(selector))
|
||||
if (elements.some((element) => !element)) return false
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let stable = 0
|
||||
let previous = ""
|
||||
const sample = () => {
|
||||
const signature = JSON.stringify(
|
||||
elements.map((element) => {
|
||||
const rect = element!.getBoundingClientRect()
|
||||
return [Math.round(rect.top * 10), Math.round(rect.bottom * 10), Math.round(rect.height * 10)]
|
||||
}),
|
||||
)
|
||||
stable = signature === previous ? stable + 1 : 0
|
||||
previous = signature
|
||||
const ordered = elements
|
||||
.slice(1)
|
||||
.every(
|
||||
(element, index) =>
|
||||
elements[index]!.getBoundingClientRect().bottom <= element!.getBoundingClientRect().top + 0.5,
|
||||
)
|
||||
if (stable >= stableFrames && ordered) return resolve(true)
|
||||
requestAnimationFrame(sample)
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
})
|
||||
},
|
||||
{ selectors, stableFrames },
|
||||
)
|
||||
}
|
||||
|
||||
export function historyMessages(count: number): TimelineMessage[] {
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const value = String(index).padStart(4, "0")
|
||||
const historyUserID = `msg_0${value}_history_a_user`
|
||||
return [
|
||||
userMessage(undefined, { id: historyUserID, created: 1690000000000 + index * 10_000 }),
|
||||
assistantMessage(
|
||||
[
|
||||
{
|
||||
id: `prt_0${value}_history_text`,
|
||||
type: "text",
|
||||
text: `Historical response ${index}. ${"Existing session content keeps the virtual timeline realistic. ".repeat(5)}`,
|
||||
},
|
||||
],
|
||||
{
|
||||
id: `msg_0${value}_history_b_assistant`,
|
||||
parentID: historyUserID,
|
||||
created: 1690000001000 + index * 10_000,
|
||||
},
|
||||
),
|
||||
]
|
||||
}).flat()
|
||||
}
|
||||
|
||||
export function partUpdated(part: Part | PartSeed<"assistant">) {
|
||||
const owned = "messageID" in part ? part : { ...part, sessionID, messageID: assistantID }
|
||||
decodePart(owned, decodeOptions)
|
||||
return event("message.part.updated", {
|
||||
sessionID,
|
||||
part: owned,
|
||||
time: 1700000002000,
|
||||
})
|
||||
}
|
||||
|
||||
export function partDelta(partID: string, delta: string, messageID = assistantID) {
|
||||
return event("message.part.delta", { sessionID, messageID, partID, field: "text", delta })
|
||||
}
|
||||
|
||||
export function messageUpdated(info: Message) {
|
||||
return event("message.updated", { sessionID, info })
|
||||
}
|
||||
|
||||
export function status(type: SessionStatus["type"], attempt = 1) {
|
||||
return event("session.status", {
|
||||
sessionID,
|
||||
status: type === "retry" ? { type, attempt, message: "Rate limited", next: 1700000010000 } : { type },
|
||||
})
|
||||
}
|
||||
|
||||
export function userMessage(
|
||||
parts?: PartSeed<"user">[],
|
||||
input: { id?: string; summary?: UserMessage["summary"]; created?: number } = {},
|
||||
): Extract<TimelineMessage, { info: { role: "user" } }> {
|
||||
const id = input.id ?? userID
|
||||
const seeds = parts ?? [userText("Build the timeline stability matrix.", { id: `prt_${id}_text` })]
|
||||
const message = {
|
||||
info: {
|
||||
id,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: input.created ?? 1700000000000 },
|
||||
summary: input.summary ?? { diffs: [] },
|
||||
agent: "build",
|
||||
model,
|
||||
},
|
||||
parts: seeds.map((part) => ({
|
||||
...part,
|
||||
sessionID,
|
||||
messageID: id,
|
||||
})),
|
||||
} satisfies Extract<TimelineMessage, { info: { role: "user" } }>
|
||||
decodeMessage(message, decodeOptions)
|
||||
return message
|
||||
}
|
||||
|
||||
export function assistantMessage(
|
||||
parts: PartSeed<"assistant">[] = [],
|
||||
input: {
|
||||
id?: string
|
||||
parentID?: string
|
||||
completed?: boolean
|
||||
error?: AssistantMessage["error"]
|
||||
created?: number
|
||||
} = {},
|
||||
): Extract<TimelineMessage, { info: { role: "assistant" } }> {
|
||||
const id = input.id ?? assistantID
|
||||
const message = {
|
||||
info: {
|
||||
id,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: {
|
||||
created: input.created ?? 1700000001000,
|
||||
...(input.completed === false ? {} : { completed: (input.created ?? 1700000001000) + 1_000 }),
|
||||
},
|
||||
parentID: input.parentID ?? userID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: directory, root: directory },
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
variant: "max",
|
||||
...(input.error ? { error: input.error } : {}),
|
||||
},
|
||||
parts: parts.map((part) => ({ ...part, sessionID, messageID: id })),
|
||||
} satisfies Extract<TimelineMessage, { info: { role: "assistant" } }>
|
||||
decodeMessage(message, decodeOptions)
|
||||
return message
|
||||
}
|
||||
|
||||
export function userText(
|
||||
text: string,
|
||||
input: Partial<Omit<Extract<PartSeed<"user">, { type: "text" }>, "type" | "text">> = {},
|
||||
): Extract<PartSeed<"user">, { type: "text" }> {
|
||||
return { id: "prt_user_text", type: "text", text, ...input }
|
||||
}
|
||||
|
||||
export function textPart(id: string, text: string): Extract<PartSeed<"assistant">, { type: "text" }> {
|
||||
return { id, type: "text", text }
|
||||
}
|
||||
|
||||
export function reasoningPart(id: string, text: string): Extract<PartSeed<"assistant">, { type: "reasoning" }> {
|
||||
return { id, type: "reasoning", text, time: { start: 1700000001000 } }
|
||||
}
|
||||
|
||||
export function toolPart(
|
||||
id: string,
|
||||
tool: string,
|
||||
state: "pending",
|
||||
input: Record<string, unknown>,
|
||||
options?: ToolOptions<"pending">,
|
||||
): Omit<ToolPart, "sessionID" | "messageID">
|
||||
export function toolPart(
|
||||
id: string,
|
||||
tool: string,
|
||||
state: "running",
|
||||
input: Record<string, unknown>,
|
||||
options?: ToolOptions<"running">,
|
||||
): Omit<ToolPart, "sessionID" | "messageID">
|
||||
export function toolPart(
|
||||
id: string,
|
||||
tool: string,
|
||||
state: "completed",
|
||||
input: Record<string, unknown>,
|
||||
options?: ToolOptions<"completed">,
|
||||
): Omit<ToolPart, "sessionID" | "messageID">
|
||||
export function toolPart(
|
||||
id: string,
|
||||
tool: string,
|
||||
state: "error",
|
||||
input: Record<string, unknown>,
|
||||
options?: ToolOptions<"error">,
|
||||
): Omit<ToolPart, "sessionID" | "messageID">
|
||||
export function toolPart(
|
||||
id: string,
|
||||
tool: string,
|
||||
state: ToolStatus,
|
||||
input: Record<string, unknown>,
|
||||
options: ToolOptions<ToolStatus> = {},
|
||||
): Omit<ToolPart, "sessionID" | "messageID"> {
|
||||
const base = { id, type: "tool" as const, callID: `call_${id}`, tool }
|
||||
if (state === "pending") return { ...base, state: { status: state, input, raw: "" } }
|
||||
if (state === "running")
|
||||
return {
|
||||
...base,
|
||||
state: {
|
||||
status: state,
|
||||
input,
|
||||
title: options.title,
|
||||
metadata: options.metadata ?? {},
|
||||
time: { start: 1700000001000 },
|
||||
},
|
||||
}
|
||||
if (state === "error")
|
||||
return {
|
||||
...base,
|
||||
state: {
|
||||
status: state,
|
||||
input,
|
||||
error: options.error ?? "Tool failed",
|
||||
metadata: options.metadata ?? {},
|
||||
time: { start: 1700000001000, end: 1700000002000 },
|
||||
},
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
state: {
|
||||
status: state,
|
||||
input,
|
||||
output: options.output ?? "Completed",
|
||||
title: options.title ?? tool,
|
||||
metadata: options.metadata ?? {},
|
||||
time: { start: 1700000001000, end: 1700000002000 },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function shell(
|
||||
id: string,
|
||||
state: ToolStatus,
|
||||
output = "",
|
||||
command = `echo ${id}`,
|
||||
): Omit<ToolPart, "sessionID" | "messageID"> {
|
||||
if (state === "pending") return toolPart(id, "bash", state, { command })
|
||||
if (state === "running")
|
||||
return toolPart(id, "bash", state, { command }, { title: command, metadata: { command, output } })
|
||||
if (state === "error")
|
||||
return toolPart(id, "bash", state, { command }, { error: output || undefined, metadata: { command, output } })
|
||||
return toolPart(id, "bash", state, { command }, { title: command, output, metadata: { command, output } })
|
||||
}
|
||||
|
||||
export function completedAssistantInfo(info: AssistantMessage): AssistantMessage {
|
||||
return { ...info, time: { ...info.time, completed: 1700000003000 } }
|
||||
}
|
||||
|
||||
export function project() {
|
||||
return {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "timeline-stability",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function session(input: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id: sessionID,
|
||||
slug: "timeline-stability",
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
...input,
|
||||
}
|
||||
}
|
||||
|
||||
function eventSchema<
|
||||
const Type extends TimelinePayload["type"],
|
||||
const Properties extends Schema.Codec<unknown, unknown>,
|
||||
>(type: Type, properties: Properties) {
|
||||
return Schema.Struct({
|
||||
directory: Schema.String,
|
||||
project: Schema.optional(Schema.String),
|
||||
workspace: Schema.optional(Schema.String),
|
||||
payload: Schema.Struct({ id: Event.ID, type: Schema.Literal(type), properties }),
|
||||
})
|
||||
}
|
||||
|
||||
function provider() {
|
||||
return {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import { assistantMessage, setupTimeline, shell, textPart, toolPart, userMessage, waitForVisualSettle } from "./fixture"
|
||||
|
||||
test("expands and collapses a long completed shell without overlap", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_interaction_01_shell"
|
||||
const followingID = "prt_interaction_02_following"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([shell(shellID, "completed", lines(50)), textPart(followingID, "Following shell expansion")]),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: false },
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
const plan = visualPlan(regions, [
|
||||
{ type: "required", regions: ["shell", "following"] },
|
||||
{ type: "unique", regions: ["shell", "following"] },
|
||||
{ type: "stable", regions: ["shell", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["shell", "following"] },
|
||||
])
|
||||
await startVisualProbe(page, regions)
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await page.waitForTimeout(500)
|
||||
const expanded = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(testInfo, "shell-expand", expanded, plan)
|
||||
|
||||
await startVisualProbe(page, regions)
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await page.waitForTimeout(500)
|
||||
const collapsed = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(testInfo, "shell-collapse", collapsed, plan)
|
||||
})
|
||||
|
||||
test("expands and collapses a completed context group without overlap", async ({ page }, testInfo) => {
|
||||
const ids = [
|
||||
"prt_interaction_01_read",
|
||||
"prt_interaction_02_glob",
|
||||
"prt_interaction_03_grep",
|
||||
"prt_interaction_04_list",
|
||||
]
|
||||
const group = `[data-timeline-part-ids="${ids.join(",")}"]`
|
||||
const followingID = "prt_interaction_context_following"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(ids[0]!, "read", "completed", { filePath: "src/a.ts" }),
|
||||
toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
|
||||
toolPart(ids[2]!, "grep", "completed", { path: ".", pattern: "stable" }),
|
||||
toolPart(ids[3]!, "list", "completed", { path: "src" }),
|
||||
textPart(followingID, "Following context expansion"),
|
||||
]),
|
||||
],
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
const trigger = page.locator(`${group} [data-slot="collapsible-trigger"]`)
|
||||
await waitForVisualSettle(page, [group, `[data-timeline-part-id="${followingID}"]`])
|
||||
for (const [name, expanded] of [
|
||||
["context-expand", true],
|
||||
["context-collapse", false],
|
||||
["context-reexpand", true],
|
||||
] as const) {
|
||||
const regions = defineVisualRegions({
|
||||
context: { selector: group, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
|
||||
await page.waitForTimeout(500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
name,
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["context", "following"] },
|
||||
{ type: "unique", regions: ["context", "following"] },
|
||||
{ type: "stable", regions: ["context", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["context", "following"] },
|
||||
]),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test("expands and collapses an edit diff without moving twice", async ({ page }, testInfo) => {
|
||||
const editID = "prt_interaction_edit"
|
||||
const followingID = "prt_interaction_edit_following"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
editID,
|
||||
"edit",
|
||||
"completed",
|
||||
{ filePath: "src/edit.ts" },
|
||||
{
|
||||
metadata: {
|
||||
filediff: {
|
||||
file: "src/edit.ts",
|
||||
additions: 40,
|
||||
deletions: 40,
|
||||
before: source(40, false),
|
||||
after: source(40, true),
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
textPart(followingID, "Following edit expansion"),
|
||||
]),
|
||||
],
|
||||
settings: { editToolPartsExpanded: false },
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
const trigger = page.locator(`[data-timeline-part-id="${editID}"] [data-slot="collapsible-trigger"]`).first()
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${editID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
||||
const regions = defineVisualRegions({
|
||||
edit: { selector: `[data-timeline-part-id="${editID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await page.waitForTimeout(900)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"edit-expand",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["edit", "following"] },
|
||||
{ type: "unique", regions: ["edit", "following"] },
|
||||
{ type: "stable", regions: ["edit", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 1 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["edit", "following"] },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
test("shows all and expands historical diff summary without overlap", async ({ page }, testInfo) => {
|
||||
const firstUser = userMessage(undefined, {
|
||||
summary: {
|
||||
diffs: Array.from({ length: 12 }, (_, index) => ({
|
||||
file: `src/diff-${index}.ts`,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
|
||||
})),
|
||||
},
|
||||
})
|
||||
const nextUserID = "msg_2000_diff_interaction_user"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
firstUser,
|
||||
assistantMessage(),
|
||||
userMessage(undefined, { id: nextUserID, created: 1700000010000 }),
|
||||
assistantMessage([], {
|
||||
id: "msg_2001_diff_interaction_assistant",
|
||||
parentID: nextUserID,
|
||||
created: 1700000011000,
|
||||
}),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate((element) => (element.scrollTop = 0))
|
||||
const diff = page.locator('[data-timeline-row="DiffSummary"]')
|
||||
const following = page.locator(`[data-message-id="${nextUserID}"]`).first()
|
||||
await expect(diff).toBeVisible()
|
||||
const regions = defineVisualRegions({
|
||||
diff: { selector: '[data-timeline-row="DiffSummary"]' },
|
||||
following: { selector: `[data-message-id="${nextUserID}"]` },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await page.getByText(/show all/i).click()
|
||||
await page.waitForTimeout(500)
|
||||
await diff.locator('[data-slot="session-turn-diff-trigger"]').first().click()
|
||||
await page.waitForTimeout(900)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"diff-summary-expand",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["diff", "following"] },
|
||||
{ type: "unique", regions: ["diff", "following"] },
|
||||
{ type: "stable", regions: ["diff", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 1, maxReversals: 2 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "flow", regions: ["diff", "following"] },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
function lines(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
||||
}
|
||||
|
||||
function source(count: number, changed: boolean) {
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`,
|
||||
).join("")
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
mapVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantMessage,
|
||||
completedAssistantInfo,
|
||||
messageUpdated,
|
||||
partDelta,
|
||||
partUpdated,
|
||||
reasoningPart,
|
||||
setupTimeline,
|
||||
shell,
|
||||
status,
|
||||
textPart,
|
||||
userMessage,
|
||||
waitForVisualSettle,
|
||||
} from "./fixture"
|
||||
|
||||
test.describe("timeline visual lifecycle stability", () => {
|
||||
test("streams empty, short, and long parallel shells to staggered completion", async ({ page }, testInfo) => {
|
||||
test.setTimeout(180_000)
|
||||
const ids = ["prt_parallel_01_empty", "prt_parallel_02_short", "prt_parallel_03_long"] as const
|
||||
const initial = ids.map((id) => shell(id, "running"))
|
||||
const followingID = "prt_parallel_04_following"
|
||||
const assistant = assistantMessage([...initial, textPart(followingID, "Following all parallel shells.")], {
|
||||
completed: false,
|
||||
})
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistant],
|
||||
settings: { shellToolPartsExpanded: true, showReasoningSummaries: true },
|
||||
cpuRate: 4,
|
||||
eventRetry: 24,
|
||||
seedHistory: true,
|
||||
})
|
||||
await timeline.send(status("busy"), 150)
|
||||
for (const id of ids) await timeline.waitForPart(id)
|
||||
const scroller = page.locator(".scroll-view__viewport", {
|
||||
has: page.locator('[data-timeline-row="AssistantPart"]'),
|
||||
})
|
||||
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
||||
const regions = defineVisualRegions({
|
||||
prt_shell_empty: shellRegion(ids[0]),
|
||||
prt_shell_short: shellRegion(ids[1]),
|
||||
prt_shell_long: shellRegion(ids[2]),
|
||||
following: shellRegion(followingID),
|
||||
})
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${followingID}"]`])
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.sendAll([
|
||||
{ event: partUpdated(shell(ids[0]!, "completed", "")), delay: 180 },
|
||||
{ event: partUpdated(shell(ids[2]!, "running", lines(10))), delay: 70 },
|
||||
{ event: partUpdated(shell(ids[1]!, "running", lines(2))), delay: 110 },
|
||||
{ event: partUpdated(shell(ids[2]!, "running", lines(25))), delay: 80 },
|
||||
{ event: partUpdated(shell(ids[1]!, "completed", lines(2))), delay: 260 },
|
||||
{ event: partUpdated(shell(ids[2]!, "running", lines(50))), delay: 100 },
|
||||
{ event: partUpdated(shell(ids[2]!, "completed", lines(50))), delay: 450 },
|
||||
{ event: messageUpdated(completedAssistantInfo(assistant.info)), delay: 100 },
|
||||
{ event: status("idle"), delay: 700 },
|
||||
])
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"parallel-shells",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long", "following"] },
|
||||
{ type: "unique", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long"] },
|
||||
{ type: "stable", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 4 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${ids[2]}"] [data-slot="bash-pre"]`)).toContainText("line 50")
|
||||
|
||||
const short = page.locator(`[data-timeline-part-id="${ids[1]}"]`)
|
||||
await short.locator('[data-slot="collapsible-trigger"]').click()
|
||||
await expect(short.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
|
||||
await timeline.send(partUpdated(textPart("prt_late_sibling", "A later sibling rerender.")), 250)
|
||||
await expect(short.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
|
||||
test("replaces thinking with streamed reasoning and text without a blank visible turn", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const reasoningID = "prt_reasoning_visible"
|
||||
const textID = "prt_streamed_text"
|
||||
const assistant = assistantMessage([], { completed: false })
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistant],
|
||||
settings: { showReasoningSummaries: true },
|
||||
cpuRate: 4,
|
||||
})
|
||||
await timeline.send(status("busy"), 120)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
|
||||
const regions = defineVisualRegions({
|
||||
thinking: { selector: '[data-timeline-row="Thinking"]' },
|
||||
reasoning: {
|
||||
selector: `[data-timeline-part-id="${reasoningID}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
text: { selector: `[data-timeline-part-id="${textID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(reasoningPart(reasoningID, "")), 100)
|
||||
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
|
||||
await timeline.send(partUpdated(reasoningPart(reasoningID, "## Planning\n\nChecking the visible timeline.")), 160)
|
||||
await timeline.waitForPart(reasoningID)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await timeline.send(partUpdated(textPart(textID, "Starting")), 100)
|
||||
await timeline.send(partDelta(textID, " **stable"), 90)
|
||||
await timeline.send(partDelta(textID, " output** with `code` and [a link"), 130)
|
||||
await timeline.send(partDelta(textID, "](https://example.com)."), 220)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 120)
|
||||
await timeline.send(status("idle"), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"reasoning-text-handoff",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["reasoning", "text"] },
|
||||
{ type: "continuous-any", regions: ["thinking", "reasoning", "text"] },
|
||||
{ type: "unique", regions: ["reasoning", "text"] },
|
||||
{ type: "stable", regions: ["reasoning", "text"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxReversals: 4 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "flow", regions: ["reasoning", "text"] },
|
||||
]),
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${textID}"]`)).toContainText("stable output")
|
||||
})
|
||||
})
|
||||
|
||||
function lines(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
||||
}
|
||||
|
||||
function shellRegion(id: string) {
|
||||
return { selector: `[data-timeline-part-id="${id}"]`, closest: '[data-timeline-row="AssistantPart"]' }
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
analyzeVisualObservations,
|
||||
defineVisualRegions,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import { assistantMessage, setupTimeline, textPart, userMessage } from "./fixture"
|
||||
|
||||
test("detects blanking caused by ancestor opacity", async ({ page }) => {
|
||||
const partID = "prt_oracle_ancestor_opacity"
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart(partID, "Visible content")])] })
|
||||
const row = page.locator(`[data-timeline-part-id="${partID}"]`).first()
|
||||
const regions = defineVisualRegions({
|
||||
content: { selector: `[data-timeline-part-id="${partID}"]` },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await row.evaluate((element) => {
|
||||
element.parentElement!.style.opacity = "0"
|
||||
})
|
||||
await page.waitForTimeout(50)
|
||||
await row.evaluate((element) => {
|
||||
element.parentElement!.style.opacity = "1"
|
||||
})
|
||||
await page.waitForTimeout(50)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
const issues = analyzeVisualObservations(
|
||||
trace.samples,
|
||||
visualPlan(regions, [
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all" },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
]),
|
||||
)
|
||||
|
||||
expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true)
|
||||
})
|
||||
|
||||
test("detects root opacity when probing descendant opacity", async ({ page }) => {
|
||||
const partID = "prt_oracle_descendant_opacity"
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart(partID, "Visible content")])] })
|
||||
const row = page.locator(`[data-timeline-part-id="${partID}"]`).first()
|
||||
await row.evaluate((element) => {
|
||||
element.innerHTML = '<span data-probe-opacity="true">Visible content</span>'
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
content: {
|
||||
selector: `[data-timeline-part-id="${partID}"]`,
|
||||
opacitySelectors: ['[data-probe-opacity="true"]'],
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await row.evaluate((element) => {
|
||||
;(element as HTMLElement).style.opacity = "0"
|
||||
})
|
||||
await page.waitForTimeout(50)
|
||||
await row.evaluate((element) => {
|
||||
;(element as HTMLElement).style.opacity = "1"
|
||||
})
|
||||
await page.waitForTimeout(50)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
const issues = analyzeVisualObservations(
|
||||
trace.samples,
|
||||
visualPlan(regions, [
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all" },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
]),
|
||||
)
|
||||
|
||||
expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true)
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import config from "../playwright.config"
|
||||
|
||||
export default {
|
||||
...config,
|
||||
testDir: ".",
|
||||
testMatch: "**/*.spec.ts",
|
||||
outputDir: "../../test-results/timeline-stability",
|
||||
reporter: [["html", { outputFolder: "../../playwright-report/timeline-stability", open: "never" }], ["line"]],
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
use: {
|
||||
...config.use,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
shell,
|
||||
textPart,
|
||||
userMessage,
|
||||
type TimelineMessage,
|
||||
} from "./fixture"
|
||||
|
||||
test("does not reverse visible rows when the user wheels during shell remeasurement", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_wheel_01_shell"
|
||||
const followingID = "prt_wheel_02_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
...history(12),
|
||||
userMessage(),
|
||||
assistantMessage([shell(shellID, "running"), textPart(followingID, "Following wheel interaction")], {
|
||||
completed: false,
|
||||
}),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
reducedMotion: true,
|
||||
seedHistory: true,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(shell(shellID, "running", lines(30))), 80)
|
||||
await scroller.evaluate((element) =>
|
||||
element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -180 })),
|
||||
)
|
||||
await scroller.evaluate((element) => (element.scrollTop -= 180))
|
||||
await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 250)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(testInfo, "wheel-during-resize", trace, rowPairPlan(regions, 1))
|
||||
})
|
||||
|
||||
test("does not pull a keyboard-scrolled user during shell remeasurement", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_keyboard_01_shell"
|
||||
const followingID = "prt_keyboard_02_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
...history(12),
|
||||
userMessage(),
|
||||
assistantMessage([shell(shellID, "running"), textPart(followingID, "Following keyboard interaction")], {
|
||||
completed: false,
|
||||
}),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.focus()
|
||||
for (let index = 0; index < 3; index++) {
|
||||
await scroller.press("PageUp")
|
||||
await page.waitForTimeout(250)
|
||||
}
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop), {
|
||||
timeout: 20_000,
|
||||
})
|
||||
.toBeGreaterThan(80)
|
||||
await page.waitForFunction(() => {
|
||||
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
||||
element.querySelector("[data-timeline-row]"),
|
||||
)
|
||||
if (!root) return false
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const top = root.scrollTop
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve(Math.abs(root.scrollTop - top) <= 0.5)))
|
||||
})
|
||||
})
|
||||
const anchor = await scroller.evaluate((element) => {
|
||||
const view = element.getBoundingClientRect()
|
||||
return [...element.querySelectorAll<HTMLElement>("[data-timeline-key]")].find((row) => {
|
||||
const rect = row.getBoundingClientRect()
|
||||
return rect.top >= view.top + 40 && rect.bottom <= view.bottom - 40
|
||||
})?.dataset.timelineKey
|
||||
})
|
||||
expect(anchor).toBeTruthy()
|
||||
const regions = defineVisualRegions({
|
||||
anchor: { selector: `[data-timeline-key="${anchor}"]` },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 400)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(testInfo, "keyboard-during-resize", trace, anchorPlan(regions))
|
||||
})
|
||||
|
||||
test("tracks keyboard scrolling from a focused timeline descendant", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_descendant_keyboard_01_shell"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [...history(12), userMessage(), assistantMessage([shell(shellID, "completed", lines(5))])],
|
||||
settings: { shellToolPartsExpanded: false },
|
||||
cpuRate: 4,
|
||||
reducedMotion: true,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const row = page.locator(`[data-timeline-part-id="${shellID}"]`).first()
|
||||
const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
|
||||
await row.evaluate((element) => element.setAttribute("tabindex", "0"))
|
||||
await row.focus()
|
||||
for (let index = 0; index < 3; index++) {
|
||||
await row.press("PageUp")
|
||||
await page.waitForTimeout(250)
|
||||
}
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeGreaterThan(5)
|
||||
const anchor = await scroller.evaluate((element) => {
|
||||
const view = element.getBoundingClientRect()
|
||||
return [...element.querySelectorAll<HTMLElement>("[data-timeline-key]")].find((row) => {
|
||||
const rect = row.getBoundingClientRect()
|
||||
return rect.top >= view.top + 40 && rect.bottom <= view.bottom - 40
|
||||
})?.dataset.timelineKey
|
||||
})
|
||||
expect(anchor).toBeTruthy()
|
||||
const regions = defineVisualRegions({
|
||||
anchor: { selector: `[data-timeline-key="${anchor}"]` },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await trigger.click()
|
||||
await page.waitForTimeout(300)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(testInfo, "descendant-keyboard-resize", trace, anchorPlan(regions))
|
||||
})
|
||||
|
||||
test("does not claim keyboard scrolling owned by a nested scrollable", async ({ page }) => {
|
||||
const shellID = "prt_nested_keyboard_shell"
|
||||
await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([shell(shellID, "completed", lines(50))])],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
reducedMotion: true,
|
||||
seedHistory: true,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const nested = page.locator(`[data-timeline-part-id="${shellID}"] [data-scrollable]`)
|
||||
await nested.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
||||
await nested.focus()
|
||||
await page.waitForFunction(() => {
|
||||
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
||||
element.querySelector("[data-timeline-row]"),
|
||||
)
|
||||
if (!root) return false
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const top = root.scrollTop
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve(Math.abs(root.scrollTop - top) <= 0.5)))
|
||||
})
|
||||
})
|
||||
const before = await scroller.evaluate((element) => element.scrollTop)
|
||||
const nestedBefore = await nested.evaluate((element) => element.scrollTop)
|
||||
await nested.press("PageUp")
|
||||
await page.waitForTimeout(300)
|
||||
expect(await scroller.evaluate((element) => element.scrollTop)).toBe(before)
|
||||
expect(await nested.evaluate((element) => element.scrollTop)).toBeLessThan(nestedBefore)
|
||||
|
||||
await nested.evaluate((element) => (element.scrollTop = 0))
|
||||
await scroller.evaluate((element) => (element.scrollTop = Math.min(300, element.scrollHeight - element.clientHeight)))
|
||||
const boundaryBefore = await scroller.evaluate((element) => element.scrollTop)
|
||||
expect(boundaryBefore).toBeGreaterThan(0)
|
||||
await nested.press("PageUp")
|
||||
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeLessThan(boundaryBefore)
|
||||
|
||||
const nonOverflowing = page.locator(`[data-timeline-part-id="${shellID}"]`).first()
|
||||
await nonOverflowing.evaluate((element) => {
|
||||
element.setAttribute("data-scrollable", "")
|
||||
element.setAttribute("tabindex", "0")
|
||||
})
|
||||
await nonOverflowing.focus()
|
||||
const nonOverflowBefore = await scroller.evaluate((element) => element.scrollTop)
|
||||
await nonOverflowing.press("PageUp")
|
||||
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeLessThan(nonOverflowBefore)
|
||||
})
|
||||
|
||||
test("jump to latest lands on stable final rows after offscreen growth", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_jump_01_shell"
|
||||
const followingID = "prt_jump_02_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
...history(20),
|
||||
userMessage(),
|
||||
assistantMessage([shell(shellID, "running"), textPart(followingID, "Latest visible row")], { completed: false }),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate(
|
||||
(element) => (element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - 600)),
|
||||
)
|
||||
await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 300)
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await page.getByRole("button", { name: /Jump to latest/i }).click()
|
||||
await expect(page.locator(`[data-timeline-part-id="${followingID}"]`)).toBeVisible()
|
||||
await page.waitForTimeout(600)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"jump-latest",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["shell", "following"] },
|
||||
{ type: "unique", regions: ["shell", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 1 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "acquire-bottom-anchor" },
|
||||
{ type: "flow", regions: ["shell", "following"] },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
test("handles a single row taller than the viewport", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_tall_01_shell"
|
||||
const followingID = "prt_tall_02_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([shell(shellID, "running"), textPart(followingID, "After tall row")], { completed: false }),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
viewport: { width: 900, height: 360 },
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(shell(shellID, "completed", lines(100))), 700)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"taller-than-viewport",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["shell", "following"] },
|
||||
{ type: "unique", regions: ["shell", "following"] },
|
||||
{ type: "stable", regions: ["shell", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["shell", "following"] },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
function history(count: number): TimelineMessage[] {
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const prefix = `msg_${String(index).padStart(4, "0")}_scroll`
|
||||
const userID = `${prefix}_a_user`
|
||||
return [
|
||||
userMessage(undefined, { id: userID, created: 1690000000000 + index * 10_000 }),
|
||||
assistantMessage(
|
||||
[textPart(`prt_${String(index).padStart(4, "0")}_scroll`, `History ${index}. ${"content ".repeat(30)}`)],
|
||||
{
|
||||
id: `${prefix}_b_assistant`,
|
||||
parentID: userID,
|
||||
created: 1690000001000 + index * 10_000,
|
||||
},
|
||||
),
|
||||
]
|
||||
}).flat()
|
||||
}
|
||||
|
||||
function lines(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
||||
}
|
||||
|
||||
function rowPairPlan(
|
||||
regions: Record<"shell" | "following", { selector: string; closest?: string }>,
|
||||
maxPositionReversals: number,
|
||||
) {
|
||||
return visualPlan(regions, [
|
||||
{ type: "required", regions: ["shell", "following"] },
|
||||
{ type: "unique", regions: ["shell", "following"] },
|
||||
{ type: "stable", regions: ["shell", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "flow", regions: ["shell", "following"] },
|
||||
])
|
||||
}
|
||||
|
||||
function anchorPlan(regions: Record<"anchor", { selector: string; closest?: string }>) {
|
||||
return visualPlan(regions, [
|
||||
{ type: "required", regions: ["anchor"] },
|
||||
{ type: "unique", regions: ["anchor"] },
|
||||
{ type: "stable", regions: ["anchor"] },
|
||||
{ type: "fixed", regions: ["anchor"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
shell,
|
||||
textPart,
|
||||
userMessage,
|
||||
waitForVisualSettle,
|
||||
} from "./fixture"
|
||||
|
||||
const profiles = [
|
||||
{
|
||||
name: "empty running to completed",
|
||||
updates: [{ state: "completed" as const, output: "", delay: 350 }],
|
||||
},
|
||||
{
|
||||
name: "50 lines arriving incrementally",
|
||||
updates: [
|
||||
{ state: "running" as const, output: lines(1), delay: 100 },
|
||||
{ state: "running" as const, output: lines(10), delay: 160 },
|
||||
{ state: "running" as const, output: lines(25), delay: 90 },
|
||||
{ state: "running" as const, output: lines(50), delay: 220 },
|
||||
{ state: "completed" as const, output: lines(50), delay: 500 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "wide ANSI and CRLF output",
|
||||
updates: [
|
||||
{
|
||||
state: "running" as const,
|
||||
output: Array.from({ length: 20 }, (_, index) => `\u001b[32mline ${index}\u001b[0m ${"wide-".repeat(30)}`).join(
|
||||
"\r\n",
|
||||
),
|
||||
delay: 240,
|
||||
},
|
||||
{
|
||||
state: "completed" as const,
|
||||
output: Array.from({ length: 20 }, (_, index) => `line ${index} ${"wide-".repeat(30)}`).join("\n"),
|
||||
delay: 500,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const
|
||||
|
||||
for (const profile of profiles) {
|
||||
test(`keeps rows stable for shell ${profile.name}`, async ({ page }, testInfo) => {
|
||||
const shellID = `prt_matrix_${profiles.indexOf(profile)}_01_shell`
|
||||
const followingID = `prt_matrix_${profiles.indexOf(profile)}_02_following`
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([shell(shellID, "running"), textPart(followingID, "Following shell row")], {
|
||||
completed: false,
|
||||
}),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
||||
await waitForVisualSettle(page, [
|
||||
`[data-timeline-part-id="${shellID}"]`,
|
||||
`[data-timeline-part-id="${followingID}"]`,
|
||||
])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
for (const update of profile.updates) {
|
||||
await timeline.send(partUpdated(shell(shellID, update.state, update.output)), update.delay)
|
||||
}
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
`shell-${profiles.indexOf(profile)}`,
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["shell", "following"] },
|
||||
{ type: "unique", regions: ["shell", "following"] },
|
||||
{ type: "stable", regions: ["shell", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 1 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["shell", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
test("keeps following row stable when a collapsed shell receives 50 lines", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_matrix_collapsed_01_shell"
|
||||
const followingID = "prt_matrix_collapsed_02_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([shell(shellID, "running"), textPart(followingID, "Following collapsed shell")], {
|
||||
completed: false,
|
||||
}),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: false },
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 240)
|
||||
await timeline.send(partUpdated(shell(shellID, "completed", lines(50))), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"collapsed-shell",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["shell", "following"] },
|
||||
{ type: "unique", regions: ["shell", "following"] },
|
||||
{ type: "stable", regions: ["shell", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: ["following"], maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "flow", regions: ["shell", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("keeps rows stable when a running shell becomes an error", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_matrix_error_01_shell"
|
||||
const followingID = "prt_matrix_error_02_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([shell(shellID, "running", lines(10)), textPart(followingID, "Following failed shell")], {
|
||||
completed: false,
|
||||
}),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
partUpdated({
|
||||
...shell(shellID, "error"),
|
||||
state: {
|
||||
status: "error",
|
||||
input: { command: `echo ${shellID}` },
|
||||
error: "Command failed after output",
|
||||
metadata: {},
|
||||
time: { start: 1700000001000, end: 1700000002000 },
|
||||
},
|
||||
}),
|
||||
500,
|
||||
)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"shell-error",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["shell", "following"] },
|
||||
{ type: "unique", regions: ["shell", "following"] },
|
||||
{ type: "stable", regions: ["shell", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: ["following"], maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["shell", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("keeps rows stable when later text arrives before shell output", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_late_text_01_shell"
|
||||
const followingID = "prt_late_text_02_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([shell(shellID, "running")], { completed: false })],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(textPart(followingID, "Later assistant content arrived before shell output.")), 240)
|
||||
await timeline.send(partUpdated(shell(shellID, "running", lines(20))), 300)
|
||||
await timeline.send(partUpdated(shell(shellID, "completed", lines(20))), 600)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"late-text-before-shell-output",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["shell", "following"] },
|
||||
{ type: "unique", regions: ["shell", "following"] },
|
||||
{ type: "stable", regions: ["shell"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["shell", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
function lines(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
session,
|
||||
sessionID,
|
||||
setupTimeline,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "./fixture"
|
||||
|
||||
test("adds a task child-session link without replacing the task row", async ({ page }, testInfo) => {
|
||||
const taskID = "prt_task_link"
|
||||
const childID = "ses_task_child"
|
||||
const input = { description: "Inspect child", subagent_type: "explore" }
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([toolPart(taskID, "task", "running", input)], { completed: false })],
|
||||
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Inspect child" })],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
task: { selector: `[data-timeline-part-id="${taskID}"] [data-slot="collapsible-trigger"]` },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(taskID, "task", "completed", input, { metadata: { sessionId: childID } })),
|
||||
500,
|
||||
)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"task-link",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["task"] },
|
||||
{ type: "unique", regions: ["task"] },
|
||||
{ type: "stable", regions: ["task"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
]),
|
||||
)
|
||||
await expect(
|
||||
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("changes generic tool arguments without replacing the row", async ({ page }, testInfo) => {
|
||||
const toolID = "prt_generic_mutation"
|
||||
const followingID = "prt_generic_mutation_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
toolPart(toolID, "mcp_probe", "running", { target: "one", count: 1 }),
|
||||
textPart(followingID, "Following generic tool"),
|
||||
],
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
tool: { selector: `[data-timeline-part-id="${toolID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(toolID, "mcp_probe", "running", { target: "two", count: 2, mode: "deep" })),
|
||||
200,
|
||||
)
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(toolID, "mcp_probe", "completed", { target: "two", count: 2, mode: "deep" })),
|
||||
400,
|
||||
)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"generic-mutation",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["tool", "following"] },
|
||||
{ type: "unique", regions: ["tool", "following"] },
|
||||
{ type: "stable", regions: ["tool", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "flow", regions: ["tool", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,198 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantMessage,
|
||||
directory,
|
||||
partUpdated,
|
||||
session,
|
||||
sessionID,
|
||||
setupTimeline,
|
||||
status,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "./fixture"
|
||||
|
||||
test.describe("timeline tool state stability", () => {
|
||||
test("moves lightweight tools through pending, running, and completed without replacing rows", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const ids = ["webfetch", "websearch", "task", "skill", "custom"] as const
|
||||
const inputs = {
|
||||
webfetch: { url: "https://example.com/docs" },
|
||||
websearch: { query: "timeline stability" },
|
||||
task: { description: "Inspect timeline", subagent_type: "explore" },
|
||||
skill: { name: "stability" },
|
||||
custom: { target: "timeline", depth: 2 },
|
||||
}
|
||||
const names = { webfetch: "webfetch", websearch: "websearch", task: "task", skill: "skill", custom: "mcp_probe" }
|
||||
const questionID = "prt_state_question"
|
||||
const todoID = "prt_state_todo"
|
||||
const initial = [
|
||||
...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "pending", inputs[id])),
|
||||
toolPart(questionID, "question", "pending", questionInput()),
|
||||
toolPart(todoID, "todowrite", "pending", { todos: [{ content: "Hidden", status: "pending" }] }),
|
||||
textPart("prt_state_following", "Following lightweight tools"),
|
||||
]
|
||||
const childID = "ses_timeline_child"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage(initial, { completed: false })],
|
||||
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Inspect timeline" })],
|
||||
cpuRate: 4,
|
||||
})
|
||||
await timeline.send(status("busy"), 120)
|
||||
for (const id of ids) await timeline.waitForPart(`prt_state_${id}`)
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
|
||||
|
||||
const regionIDs = [
|
||||
"prt_state_webfetch",
|
||||
"prt_state_websearch",
|
||||
"prt_state_task",
|
||||
"prt_state_skill",
|
||||
"prt_state_custom",
|
||||
] as const
|
||||
const regions = defineVisualRegions({
|
||||
prt_state_webfetch: toolRegion(regionIDs[0]),
|
||||
prt_state_websearch: toolRegion(regionIDs[1]),
|
||||
prt_state_task: toolRegion(regionIDs[2]),
|
||||
prt_state_skill: toolRegion(regionIDs[3]),
|
||||
prt_state_custom: toolRegion(regionIDs[4]),
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
for (const [index, id] of ids.entries()) {
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(`prt_state_${id}`, names[id], "running", inputs[id])),
|
||||
[80, 240, 100, 360, 140][index],
|
||||
)
|
||||
}
|
||||
for (const [index, id] of ["skill", "webfetch", "custom", "task", "websearch"].entries()) {
|
||||
const key = id as (typeof ids)[number]
|
||||
const metadata = key === "task" ? { sessionId: childID } : key === "websearch" ? { provider: "exa" } : {}
|
||||
const output = key === "websearch" ? "Result https://example.com/result" : "Completed"
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(`prt_state_${key}`, names[key], "completed", inputs[key], { metadata, output })),
|
||||
[110, 70, 280, 130, 420][index],
|
||||
)
|
||||
}
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(questionID, "question", "completed", questionInput(), { metadata: { answers: [["Keep it stable"]] } }),
|
||||
),
|
||||
350,
|
||||
)
|
||||
await timeline.waitForPart(questionID)
|
||||
await timeline.send(status("idle"), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"lightweight-tools",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: regionIDs },
|
||||
{ type: "unique", regions: regionIDs },
|
||||
{ type: "stable", regions: regionIDs },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxReversals: 4 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
]),
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText("Keep it stable")
|
||||
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
|
||||
await expect(
|
||||
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
|
||||
).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
|
||||
})
|
||||
|
||||
test("keeps an expanded mixed context group stable through staggered completion and error", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const ids = ["prt_ctx_01_read", "prt_ctx_02_glob", "prt_ctx_03_grep", "prt_ctx_04_list"]
|
||||
const tools = ["read", "glob", "grep", "list"]
|
||||
const inputs = [
|
||||
{ filePath: "src/a.ts", offset: 0, limit: 120 },
|
||||
{ path: directory, pattern: "**/*.ts" },
|
||||
{ path: directory, pattern: "stability", include: "*.ts" },
|
||||
{ path: "src" },
|
||||
]
|
||||
const context = ids.map((id, index) => toolPart(id, tools[index]!, "pending", inputs[index]!))
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([...context, textPart("prt_ctx_following", "Following context")], { completed: false }),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
await timeline.send(status("busy"), 100)
|
||||
const groupSelector = `[data-timeline-part-ids="${ids.join(",")}"]`
|
||||
const group = page.locator(groupSelector)
|
||||
await expect(group).toBeVisible()
|
||||
await group.locator('[data-slot="collapsible-trigger"]').click()
|
||||
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
|
||||
|
||||
const regions = defineVisualRegions({
|
||||
status: {
|
||||
selector: `${groupSelector} [data-component="tool-status-title"]`,
|
||||
opacitySelectors: ['[data-slot="tool-status-active"]', '[data-slot="tool-status-done"]'],
|
||||
},
|
||||
context: { selector: groupSelector, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: '[data-timeline-part-id="prt_ctx_following"]',
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
for (const [index, delay] of [90, 260, 70, 380].entries()) {
|
||||
await timeline.send(partUpdated(toolPart(ids[index]!, tools[index]!, "running", inputs[index]!)), delay)
|
||||
}
|
||||
await timeline.send(partUpdated(toolPart(ids[1]!, tools[1]!, "completed", inputs[1]!)), 130)
|
||||
await timeline.send(partUpdated(toolPart(ids[3]!, tools[3]!, "completed", inputs[3]!)), 210)
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(ids[0]!, tools[0]!, "error", inputs[0]!, { error: "Read interrupted" })),
|
||||
110,
|
||||
)
|
||||
await timeline.send(partUpdated(toolPart(ids[2]!, tools[2]!, "completed", inputs[2]!)), 250)
|
||||
await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored")
|
||||
await timeline.send(status("idle"), 700)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"mixed-context",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["context", "following"] },
|
||||
{ type: "unique", regions: ["context"] },
|
||||
{ type: "stable", regions: ["context"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxReversals: 4 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "flow", regions: ["context", "following"] },
|
||||
]),
|
||||
)
|
||||
await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored")
|
||||
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
|
||||
await group.locator('[data-slot="collapsible-trigger"]').click()
|
||||
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
|
||||
await timeline.send(partUpdated(textPart("prt_ctx_late_sibling", "Later sibling content")), 200)
|
||||
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
|
||||
await group.locator('[data-slot="collapsible-trigger"]').click()
|
||||
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
|
||||
})
|
||||
})
|
||||
|
||||
function questionInput() {
|
||||
return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] }
|
||||
}
|
||||
|
||||
function toolRegion(id: string) {
|
||||
return { selector: `[data-timeline-part-id="${id}"]`, closest: '[data-timeline-row="AssistantPart"]' }
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantID,
|
||||
assistantMessage,
|
||||
completedAssistantInfo,
|
||||
event,
|
||||
messageUpdated,
|
||||
partDelta,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
shell,
|
||||
status,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "./fixture"
|
||||
|
||||
test("keeps unchanged siblings stable while a middle part is inserted and removed", async ({ page }, testInfo) => {
|
||||
const firstID = "prt_mutation_01_first"
|
||||
const middleID = "prt_mutation_02_middle"
|
||||
const lastID = "prt_mutation_03_last"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([textPart(firstID, "First stable row"), textPart(lastID, "Last stable row")], {
|
||||
completed: false,
|
||||
}),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
first: { selector: `[data-timeline-part-id="${firstID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
last: { selector: `[data-timeline-part-id="${lastID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(textPart(middleID, "Inserted middle row. ".repeat(12))), 350)
|
||||
await expect(page.locator(`[data-timeline-part-id="${middleID}"]`)).toBeVisible()
|
||||
await timeline.send(
|
||||
event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: middleID }),
|
||||
500,
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${middleID}"]`)).toHaveCount(0)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(testInfo, "middle-insert-remove", trace, stablePairPlan(regions, 1))
|
||||
})
|
||||
|
||||
test("streams text through growth, canonical replacement, and completion", async ({ page }, testInfo) => {
|
||||
const textID = "prt_text_reconcile"
|
||||
const followingID = "prt_text_reconcile_following"
|
||||
const assistant = assistantMessage([textPart(textID, "Starting"), textPart(followingID, "Following text row")], {
|
||||
completed: false,
|
||||
})
|
||||
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant], cpuRate: 4 })
|
||||
const regions = defineVisualRegions({
|
||||
text: { selector: `[data-timeline-part-id="${textID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partDelta(textID, " streamed content"), 100)
|
||||
await timeline.send(partDelta(textID, "\n\n- item one\n- item two\n- item three"), 180)
|
||||
await timeline.send(partUpdated(textPart(textID, "Canonical replacement with a shorter final paragraph.")), 200)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"text-reconcile",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["text", "following"] },
|
||||
{ type: "unique", regions: ["text", "following"] },
|
||||
{ type: "stable", regions: ["text", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 1, maxReversals: 2 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["text", "following"] },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
test("inserts a completed question between stable rows", async ({ page }, testInfo) => {
|
||||
const firstID = "prt_question_01_first"
|
||||
const questionID = "prt_question_02_hidden"
|
||||
const lastID = "prt_question_03_last"
|
||||
const input = { questions: [{ header: "Choice", question: "Keep stable?", options: [] }] }
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
textPart(firstID, "Before question"),
|
||||
toolPart(questionID, "question", "running", input),
|
||||
textPart(lastID, "After question"),
|
||||
],
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
||||
const regions = defineVisualRegions({
|
||||
first: { selector: `[data-timeline-part-id="${firstID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
last: { selector: `[data-timeline-part-id="${lastID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(questionID, "question", "completed", input, { metadata: { answers: [["Yes"]] } })),
|
||||
600,
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toBeVisible()
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(testInfo, "question-insert", trace, stablePairPlan(regions, 0))
|
||||
})
|
||||
|
||||
test("replaces thinking with an assistant error without a blank turn", async ({ page }, testInfo) => {
|
||||
const assistant = assistantMessage([], { completed: false })
|
||||
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant], cpuRate: 4 })
|
||||
await timeline.send(status("busy"), 150)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
const regions = defineVisualRegions({
|
||||
thinking: { selector: '[data-timeline-row="Thinking"]' },
|
||||
error: { selector: '[data-timeline-row="Error"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
messageUpdated({
|
||||
...assistant.info,
|
||||
error: { name: "APIError", data: { message: "Provider failed visibly", isRetryable: false } },
|
||||
}),
|
||||
500,
|
||||
)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="Error"]')).toContainText("Provider failed visibly")
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"thinking-error",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["thinking", "error"] },
|
||||
{ type: "continuous-any", regions: ["thinking", "error"] },
|
||||
{ type: "unique", regions: ["thinking", "error"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all" },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
test("updates retry attempts and long provider messages without remounting the retry row", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([], { completed: false })],
|
||||
cpuRate: 4,
|
||||
})
|
||||
await timeline.send(status("retry", 1), 120)
|
||||
await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible()
|
||||
const regions = defineVisualRegions({
|
||||
retry: { selector: '[data-timeline-row="Retry"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
event("session.status", {
|
||||
sessionID: "ses_timeline_stability",
|
||||
status: {
|
||||
type: "retry",
|
||||
attempt: 2,
|
||||
message: "A very long provider retry message ".repeat(8),
|
||||
next: Date.now() + 10_000,
|
||||
},
|
||||
}),
|
||||
300,
|
||||
)
|
||||
await timeline.send(status("retry", 3), 300)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"retry-evolution",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["retry"] },
|
||||
{ type: "unique", regions: ["retry"] },
|
||||
{ type: "stable", regions: ["retry"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("reducer-hardening: removes a historical turn one message at a time without moving a visible lower anchor twice", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const removeUserID = "msg_0500_remove_user"
|
||||
const removeAssistantID = "msg_0501_remove_assistant"
|
||||
const anchorUserID = "msg_2000_anchor_user"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(undefined, { id: removeUserID, created: 1690000000000 }),
|
||||
assistantMessage([textPart("prt_remove_text", "Removed historical content. ".repeat(15))], {
|
||||
id: removeAssistantID,
|
||||
parentID: removeUserID,
|
||||
created: 1690000001000,
|
||||
}),
|
||||
userMessage(undefined, { id: anchorUserID, created: 1700000000000 }),
|
||||
assistantMessage([textPart("prt_anchor_text", "Visible anchor response")], {
|
||||
id: "msg_2001_anchor_assistant",
|
||||
parentID: anchorUserID,
|
||||
created: 1700000001000,
|
||||
}),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
anchor: { selector: `[data-timeline-row="UserMessage"][data-message-id="${anchorUserID}"]` },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
event("message.removed", { sessionID: "ses_timeline_stability", messageID: removeAssistantID }),
|
||||
200,
|
||||
)
|
||||
await timeline.send(event("message.removed", { sessionID: "ses_timeline_stability", messageID: removeUserID }), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"historical-turn-remove",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["anchor"] },
|
||||
{ type: "unique", regions: ["anchor"] },
|
||||
{ type: "stable", regions: ["anchor"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
function stablePairPlan(
|
||||
regions: Record<"first" | "last", { selector: string; closest?: string }>,
|
||||
maxPositionReversals: number,
|
||||
) {
|
||||
return visualPlan(regions, [
|
||||
{ type: "required", regions: ["first", "last"] },
|
||||
{ type: "unique", regions: ["first", "last"] },
|
||||
{ type: "stable", regions: ["first", "last"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
])
|
||||
}
|
||||
@@ -2,7 +2,13 @@ import type { Page } from "@playwright/test"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { benchmark, expect, withBenchmarkPage } from "../benchmark"
|
||||
import { fixture } from "./session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, mockStressTimeline, stressSessionHref } from "./timeline-test-helpers"
|
||||
import {
|
||||
createReviewDiffs,
|
||||
installStressSessionTabs,
|
||||
installTimelineSettings,
|
||||
mockStressTimeline,
|
||||
stressSessionHref,
|
||||
} from "./timeline-test-helpers"
|
||||
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
|
||||
|
||||
type Result = Awaited<ReturnType<typeof measureSessionSwitch>>
|
||||
@@ -20,8 +26,41 @@ benchmark("benchmarks cold and hot session tab switching", async ({ browser, rep
|
||||
report({ results, summary: summarize(results) })
|
||||
})
|
||||
|
||||
async function trial(page: Page, mode: "cold" | "hot") {
|
||||
await mockStressTimeline(page)
|
||||
benchmark(
|
||||
"benchmarks v2 session tab switching with and without the review pane",
|
||||
async ({ browser, report }, testInfo) => {
|
||||
benchmark.setTimeout(360_000)
|
||||
const runs = Number(process.env.SESSION_TAB_SWITCH_RUNS ?? 5)
|
||||
const results = {
|
||||
closed: { cold: [] as Result[], hot: [] as Result[] },
|
||||
open: { cold: [] as Result[], hot: [] as Result[] },
|
||||
}
|
||||
for (const reviewPane of ["closed", "open"] as const) {
|
||||
for (const mode of ["cold", "hot"] as const) {
|
||||
for (let run = 0; run < runs; run++) {
|
||||
results[reviewPane][mode].push(
|
||||
await withBenchmarkPage(
|
||||
browser,
|
||||
`session-tab-switch-v2-${reviewPane}-${mode}-${run}`,
|
||||
(page) => trial(page, mode, { newLayoutDesigns: true, reviewPane }),
|
||||
testInfo,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
report({ results, summary: summarizeReviewPane(results) }, { runs, reviewDiffs: createReviewDiffs().length })
|
||||
},
|
||||
)
|
||||
|
||||
async function trial(
|
||||
page: Page,
|
||||
mode: "cold" | "hot",
|
||||
options?: { newLayoutDesigns?: boolean; reviewPane?: "closed" | "open" },
|
||||
) {
|
||||
const reviewDiffs = options?.newLayoutDesigns ? createReviewDiffs() : undefined
|
||||
await mockStressTimeline(page, { vcsDiff: reviewDiffs })
|
||||
if (options?.newLayoutDesigns) await installTimelineSettings(page)
|
||||
await installStressSessionTabs(page)
|
||||
if (mode === "hot") {
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
@@ -33,6 +72,10 @@ async function trial(page: Page, mode: "cold" | "hot") {
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
}
|
||||
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||
if (options?.reviewPane === "open") {
|
||||
await openReviewPane(page)
|
||||
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||
}
|
||||
|
||||
const destinationIDs = fixture.messages[fixture.targetID].map((message) => message.info.id)
|
||||
const sourceIDs = fixture.messages[fixture.sourceID].map((message) => message.info.id)
|
||||
@@ -70,6 +113,15 @@ function summarize(results: Record<"cold" | "hot", Result[]>) {
|
||||
)
|
||||
}
|
||||
|
||||
function summarizeReviewPane(results: Record<"closed" | "open", Record<"cold" | "hot", Result[]>>) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(results).map(([reviewPane, values]) => [
|
||||
reviewPane,
|
||||
summarize(values as Record<"cold" | "hot", Result[]>),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
async function switchSession(page: Page, sessionID: string, title: string) {
|
||||
const href = stressSessionHref(sessionID)
|
||||
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
||||
@@ -77,3 +129,16 @@ async function switchSession(page: Page, sessionID: string, title: string) {
|
||||
await tab.click()
|
||||
await expectSessionTitle(page, title)
|
||||
}
|
||||
|
||||
async function openReviewPane(page: Page) {
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
const panel = page.locator("#review-panel")
|
||||
await expect(panel).toBeVisible()
|
||||
// Text-based readiness works across review implementations; the legacy list mounts
|
||||
// diff viewers lazily while V2 mounts the active preview eagerly.
|
||||
await page.waitForFunction(() => {
|
||||
const panel = document.querySelector<HTMLElement>("#review-panel")
|
||||
const text = panel?.textContent ?? ""
|
||||
return text.includes("generated-000.ts") && text.includes("+3")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,12 @@ export type SessionSwitchSample = {
|
||||
hasVisibleRows: boolean
|
||||
last: boolean
|
||||
bottomErrorPx?: number
|
||||
review?: {
|
||||
fileHost: boolean
|
||||
fileHostReplaced: boolean
|
||||
header: string
|
||||
replacedLevels: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export function classifySessionSwitch(samples: SessionSwitchSample[]) {
|
||||
@@ -23,6 +29,10 @@ export function classifySessionSwitch(samples: SessionSwitchSample[]) {
|
||||
(sample) => sample.hasVisibleRows && sample.destination.length === 0 && sample.source.length === 0,
|
||||
).length,
|
||||
sourceSamples: samples.filter((sample) => sample.source.length > 0).length,
|
||||
reviewFileHostMissingSamples: samples.filter((sample) => sample.review && !sample.review.fileHost).length,
|
||||
reviewFileHostReplacedSamples: samples.filter((sample) => sample.review?.fileHostReplaced).length,
|
||||
reviewHeaders: [...new Set(samples.flatMap((sample) => (sample.review ? [sample.review.header] : [])))],
|
||||
reviewReplacedLevels: [...new Set(samples.flatMap((sample) => sample.review?.replacedLevels ?? []))],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,11 +16,41 @@ async function installSessionSwitchProbe(
|
||||
const samples: SessionSwitchSample[] = []
|
||||
let started: number | undefined
|
||||
let running = true
|
||||
const reviewLevels: Record<string, string> = {
|
||||
panel: "#review-panel",
|
||||
tabs: '#review-panel [data-component="tabs"]',
|
||||
body: '#review-panel [data-slot="session-review-v2-body"]',
|
||||
review: '#review-panel [data-component="session-review-v2"]',
|
||||
preview: '#review-panel [data-slot="session-review-v2-preview"]',
|
||||
scroll: '#review-panel [data-slot="session-review-v2-diff-scroll"]',
|
||||
file: '#review-panel [data-component="file"][data-mode="diff"]',
|
||||
}
|
||||
const initialReviewNodes: Record<string, Element | null> = {}
|
||||
const sample = () => {
|
||||
if (!running || started === undefined) return
|
||||
setTimeout(() => {
|
||||
if (!running || started === undefined) return
|
||||
const observedAtMs = performance.now() - started
|
||||
const reviewPanel = document.querySelector<HTMLElement>("#review-panel")
|
||||
const reviewFile = reviewPanel?.querySelector('[data-component="file"][data-mode="diff"]')
|
||||
const initialReviewFile = initialReviewNodes.file
|
||||
const replacedLevels = Object.entries(reviewLevels).flatMap(([name, selector]) => {
|
||||
const initial = initialReviewNodes[name]
|
||||
if (!initial) return []
|
||||
const current = document.querySelector(selector)
|
||||
return current && current !== initial ? [name] : []
|
||||
})
|
||||
const review = reviewPanel
|
||||
? {
|
||||
fileHost: !!reviewFile,
|
||||
fileHostReplaced: !!initialReviewFile && !!reviewFile && reviewFile !== initialReviewFile,
|
||||
header:
|
||||
reviewPanel
|
||||
.querySelector<HTMLElement>('[data-slot="session-review-v2-file-header"]')
|
||||
?.textContent?.trim() ?? "",
|
||||
replacedLevels,
|
||||
}
|
||||
: undefined
|
||||
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
||||
element.querySelector("[data-timeline-row]"),
|
||||
)
|
||||
@@ -44,9 +74,10 @@ async function installSessionSwitchProbe(
|
||||
hasVisibleRows,
|
||||
last: visible.includes(lastID),
|
||||
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
|
||||
review,
|
||||
})
|
||||
} else {
|
||||
samples.push({ observedAtMs, destination: [], source: [], hasVisibleRows: false, last: false })
|
||||
samples.push({ observedAtMs, destination: [], source: [], hasVisibleRows: false, last: false, review })
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
}, 0)
|
||||
@@ -57,6 +88,9 @@ async function installSessionSwitchProbe(
|
||||
const link = event.target instanceof Element ? event.target.closest("a") : undefined
|
||||
if (link?.getAttribute("href") !== href) return
|
||||
started = performance.now()
|
||||
for (const [name, selector] of Object.entries(reviewLevels)) {
|
||||
initialReviewNodes[name] = document.querySelector(selector)
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
},
|
||||
{ capture: true, once: true },
|
||||
|
||||
@@ -93,36 +93,53 @@ const assistantMessage = {
|
||||
parts: [editPart],
|
||||
}
|
||||
|
||||
export async function setupTimelineBenchmark(page: Page, options: { historyTurns: number; eventBatch: number }) {
|
||||
export async function setupTimelineBenchmark(
|
||||
page: Page,
|
||||
options: {
|
||||
historyTurns: number
|
||||
eventBatch: number
|
||||
newLayoutDesigns?: boolean
|
||||
vcsDiff?: unknown[]
|
||||
turnDiffs?: unknown[]
|
||||
},
|
||||
) {
|
||||
const events: EventPayload[] = []
|
||||
let eventBatch = options.eventBatch
|
||||
const currentUserMessage = options.turnDiffs
|
||||
? { ...userMessage, info: { ...userMessage.info, summary: { diffs: options.turnDiffs } } }
|
||||
: userMessage
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: project(),
|
||||
provider: provider(),
|
||||
sessions: [session()],
|
||||
vcsDiff: options.vcsDiff,
|
||||
pageMessages: () => ({
|
||||
items: [
|
||||
...Array.from({ length: options.historyTurns }, (_, index) => performanceTurn(index)).flat(),
|
||||
userMessage,
|
||||
currentUserMessage,
|
||||
assistantMessage,
|
||||
],
|
||||
}),
|
||||
events: () => events.splice(0, eventBatch),
|
||||
eventRetry: 16,
|
||||
})
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
general: {
|
||||
editToolPartsExpanded: true,
|
||||
shellToolPartsExpanded: true,
|
||||
showReasoningSummaries: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
await page.addInitScript(
|
||||
(input) => {
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
general: {
|
||||
newLayoutDesigns: input.newLayoutDesigns,
|
||||
editToolPartsExpanded: true,
|
||||
shellToolPartsExpanded: true,
|
||||
showReasoningSummaries: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
{ newLayoutDesigns: options.newLayoutDesigns ?? false },
|
||||
)
|
||||
await page.setViewportSize({ width: 1366, height: 768 })
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const text = page.locator(`[data-timeline-part-id="${textPartID}"]`).first()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { benchmark, benchmarkDiagnostics, expect } from "../benchmark"
|
||||
import {
|
||||
buildInitialStreamEvent,
|
||||
@@ -6,80 +7,300 @@ import {
|
||||
textPartID,
|
||||
} from "./session-timeline-benchmark.fixture"
|
||||
import { startTimelineProfile } from "./session-timeline-profile"
|
||||
import { createReviewDiffs } from "./timeline-test-helpers"
|
||||
import {
|
||||
collectTimelineStreamMetrics,
|
||||
installTimelineStreamProbe,
|
||||
startTimelineStreamProbe,
|
||||
} from "./session-timeline-stream-probe"
|
||||
|
||||
type TimelineStreamOptions = {
|
||||
newLayoutDesigns?: boolean
|
||||
reviewDiffs?: boolean
|
||||
reviewPane?: boolean
|
||||
}
|
||||
|
||||
type ReviewPaneSample = {
|
||||
observedAtMs: number
|
||||
panelVisible: boolean
|
||||
header: string
|
||||
diffViewers: number
|
||||
diffLines: number
|
||||
codeBlocks: number
|
||||
ready: boolean
|
||||
}
|
||||
|
||||
type ReviewPaneProbe = {
|
||||
samples: ReviewPaneSample[]
|
||||
start: () => void
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
const reviewReadyStreak = 3
|
||||
|
||||
benchmark.describe("performance: session timeline streaming", () => {
|
||||
benchmark("streams assistant text without remounting or oscillating", async ({ page, report }) => {
|
||||
benchmark.setTimeout(480_000)
|
||||
const cpuThrottle = Number(process.env.TIMELINE_CPU_THROTTLE ?? 30)
|
||||
const deltaCount = Number(process.env.TIMELINE_DELTA_COUNT ?? 160)
|
||||
const historyTurns = Number(process.env.TIMELINE_HISTORY_TURNS ?? 320)
|
||||
const eventBatch = Number(process.env.TIMELINE_EVENT_BATCH ?? 1)
|
||||
const minimal = process.env.TIMELINE_MINIMAL === "1"
|
||||
const profileCPU = process.env.TIMELINE_CPU_PROFILE === "1"
|
||||
const profileVisual = !minimal && profileCPU && process.env.TIMELINE_VISUAL_PROFILE !== "0"
|
||||
benchmark.setTimeout(Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) + 60_000)
|
||||
const result = await runTimelineStreamBenchmark(page, {})
|
||||
report(result.metrics, result.context)
|
||||
})
|
||||
|
||||
benchmark("streams assistant text in v2 with review pane closed", async ({ page, report }) => {
|
||||
benchmark.setTimeout(Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) + 60_000)
|
||||
const result = await runTimelineStreamBenchmark(page, { newLayoutDesigns: true })
|
||||
report(result.metrics, result.context)
|
||||
})
|
||||
|
||||
benchmark("streams assistant text in v2 with review diffs and pane closed", async ({ page, report }) => {
|
||||
benchmark.setTimeout(Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) + 60_000)
|
||||
const result = await runTimelineStreamBenchmark(page, { newLayoutDesigns: true, reviewDiffs: true })
|
||||
report(result.metrics, result.context)
|
||||
})
|
||||
|
||||
benchmark("streams assistant text in v2 with review pane open", async ({ page, report }) => {
|
||||
benchmark.setTimeout(Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) + 60_000)
|
||||
const result = await runTimelineStreamBenchmark(page, { newLayoutDesigns: true, reviewPane: true })
|
||||
report(result.metrics, result.context)
|
||||
})
|
||||
})
|
||||
|
||||
benchmark.describe("performance: review pane", () => {
|
||||
benchmark("loads v2 review diffs and switches active files", async ({ page, report }) => {
|
||||
benchmark.setTimeout(240_000)
|
||||
const historyTurns = Number(process.env.REVIEW_PANE_HISTORY_TURNS ?? 72)
|
||||
const diffs = createReviewDiffs()
|
||||
const fixture = await setupTimelineBenchmark(page, {
|
||||
historyTurns,
|
||||
eventBatch,
|
||||
eventBatch: 1,
|
||||
newLayoutDesigns: true,
|
||||
vcsDiff: diffs,
|
||||
})
|
||||
|
||||
fixture.transport.enqueue(buildInitialStreamEvent(deltaCount))
|
||||
const contentStart = performance.now()
|
||||
fixture.transport.enqueue(buildInitialStreamEvent(1))
|
||||
await expect(fixture.text).toBeVisible()
|
||||
await expect(fixture.text).toContainText("Implementation plan")
|
||||
const initialContentObservedMs = performance.now() - contentStart
|
||||
await fixture.scrollToBottom()
|
||||
await fixture.waitForStableGeometry()
|
||||
|
||||
const profile = await startTimelineProfile(page, { cpuThrottle, profileCPU })
|
||||
await installTimelineStreamProbe(page, { textPartID, finalIndex: deltaCount, profileVisual, minimal })
|
||||
const deltas = buildStreamDeltaEvents(deltaCount)
|
||||
await startTimelineStreamProbe(page)
|
||||
fixture.transport.enqueue(deltas)
|
||||
|
||||
await page.waitForFunction(
|
||||
(finalIndex) =>
|
||||
(
|
||||
window as Window & {
|
||||
__timelineStreamBenchmark?: { applied: { index: number }[] }
|
||||
}
|
||||
).__timelineStreamBenchmark?.applied.some((value) => value.index === finalIndex),
|
||||
deltaCount,
|
||||
{ timeout: 420_000 },
|
||||
)
|
||||
await expect(fixture.text).toContainText("benchmark-complete")
|
||||
await expect(fixture.text).toContainText("Streaming")
|
||||
await fixture.waitForStableGeometry()
|
||||
const metrics = await collectTimelineStreamMetrics(page, {
|
||||
textPartID,
|
||||
finalIndex: deltaCount,
|
||||
navigations: benchmarkDiagnostics(page).navigations,
|
||||
})
|
||||
const delivered = deltas.length - fixture.transport.pendingCount()
|
||||
await profile.stop()
|
||||
const open = await measureReviewPaneLoad(page, diffs[0]!.file)
|
||||
const switches = []
|
||||
for (const diff of diffs.slice(1, 4)) switches.push(await measureReviewNextFile(page, diff.file))
|
||||
|
||||
report(
|
||||
{
|
||||
endToEndInitialContentObservedMs: initialContentObservedMs,
|
||||
...metrics,
|
||||
deliveredDeltas: delivered,
|
||||
pendingDeltas: fixture.transport.pendingCount(),
|
||||
open,
|
||||
switches,
|
||||
},
|
||||
{
|
||||
cpuThrottle,
|
||||
profileCPU,
|
||||
profileVisual,
|
||||
minimal,
|
||||
queuedDeltas: deltas.length,
|
||||
historyTurns,
|
||||
eventBatch,
|
||||
reviewDiffs: diffs.length,
|
||||
},
|
||||
)
|
||||
|
||||
await profile.reset()
|
||||
})
|
||||
})
|
||||
|
||||
async function runTimelineStreamBenchmark(page: Page, options: TimelineStreamOptions) {
|
||||
const completionTimeoutMs = Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000)
|
||||
const cpuThrottle = Number(process.env.TIMELINE_CPU_THROTTLE ?? 30)
|
||||
const deltaCount = Number(process.env.TIMELINE_DELTA_COUNT ?? 160)
|
||||
const historyTurns = Number(process.env.TIMELINE_HISTORY_TURNS ?? 320)
|
||||
const eventBatch = Number(process.env.TIMELINE_EVENT_BATCH ?? 1)
|
||||
const minimal = process.env.TIMELINE_MINIMAL === "1"
|
||||
const profileCPU = process.env.TIMELINE_CPU_PROFILE === "1"
|
||||
const profileVisual = !minimal && profileCPU && process.env.TIMELINE_VISUAL_PROFILE !== "0"
|
||||
const diffs = options.reviewDiffs || options.reviewPane ? createReviewDiffs() : undefined
|
||||
const fixture = await setupTimelineBenchmark(page, {
|
||||
historyTurns,
|
||||
eventBatch,
|
||||
newLayoutDesigns: options.newLayoutDesigns,
|
||||
// Turn diffs exercise timeline data cost; the pane-open scenario serves the same
|
||||
// diffs through the default git mode so it works across review implementations.
|
||||
turnDiffs: options.reviewDiffs ? diffs : undefined,
|
||||
vcsDiff: options.reviewPane ? diffs : undefined,
|
||||
})
|
||||
|
||||
fixture.transport.enqueue(buildInitialStreamEvent(deltaCount))
|
||||
const contentStart = performance.now()
|
||||
await expect(fixture.text).toBeVisible()
|
||||
await expect(fixture.text).toContainText("Implementation plan")
|
||||
const initialContentObservedMs = performance.now() - contentStart
|
||||
await fixture.scrollToBottom()
|
||||
await fixture.waitForStableGeometry()
|
||||
|
||||
const reviewPane = options.reviewPane && diffs ? await measureReviewPaneLoad(page, diffs[0]!.file) : undefined
|
||||
if (reviewPane) await fixture.waitForStableGeometry()
|
||||
|
||||
const profile = await startTimelineProfile(page, { cpuThrottle, profileCPU })
|
||||
await installTimelineStreamProbe(page, { textPartID, finalIndex: deltaCount, profileVisual, minimal })
|
||||
const deltas = buildStreamDeltaEvents(deltaCount)
|
||||
await startTimelineStreamProbe(page)
|
||||
fixture.transport.enqueue(deltas)
|
||||
|
||||
await page.waitForFunction(
|
||||
(finalIndex) =>
|
||||
(
|
||||
window as Window & {
|
||||
__timelineStreamBenchmark?: { applied: { index: number }[] }
|
||||
}
|
||||
).__timelineStreamBenchmark?.applied.some((value) => value.index === finalIndex),
|
||||
deltaCount,
|
||||
{ timeout: completionTimeoutMs },
|
||||
)
|
||||
await expect(fixture.text).toContainText("benchmark-complete")
|
||||
await expect(fixture.text).toContainText("Streaming")
|
||||
await fixture.waitForStableGeometry()
|
||||
const metrics = await collectTimelineStreamMetrics(page, {
|
||||
textPartID,
|
||||
finalIndex: deltaCount,
|
||||
navigations: benchmarkDiagnostics(page).navigations,
|
||||
})
|
||||
const delivered = deltas.length - fixture.transport.pendingCount()
|
||||
await profile.stop()
|
||||
|
||||
const result = {
|
||||
metrics: {
|
||||
endToEndInitialContentObservedMs: initialContentObservedMs,
|
||||
...metrics,
|
||||
deliveredDeltas: delivered,
|
||||
pendingDeltas: fixture.transport.pendingCount(),
|
||||
reviewPane: reviewPane ?? null,
|
||||
},
|
||||
context: {
|
||||
cpuThrottle,
|
||||
profileCPU,
|
||||
profileVisual,
|
||||
minimal,
|
||||
queuedDeltas: deltas.length,
|
||||
historyTurns,
|
||||
eventBatch,
|
||||
newLayoutDesigns: options.newLayoutDesigns === true,
|
||||
reviewPane: options.reviewPane === true ? "open" : "closed",
|
||||
reviewDiffs: diffs?.length ?? 0,
|
||||
},
|
||||
}
|
||||
|
||||
await profile.reset()
|
||||
return result
|
||||
}
|
||||
|
||||
async function measureReviewPaneLoad(page: Page, file: string) {
|
||||
// Default git mode reads the mocked /vcs/diff data, so opening the pane is enough
|
||||
// and the flow works across review pane implementations.
|
||||
await installReviewPaneProbe(page, { file })
|
||||
await startReviewPaneProbe(page)
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
await expect(page.locator("#review-panel")).toBeVisible()
|
||||
return collectReviewPaneProbe(page)
|
||||
}
|
||||
|
||||
async function measureReviewNextFile(page: Page, file: string) {
|
||||
await installReviewPaneProbe(page, { file })
|
||||
await startReviewPaneProbe(page)
|
||||
await page.getByRole("button", { name: "Next file" }).click()
|
||||
return collectReviewPaneProbe(page)
|
||||
}
|
||||
|
||||
async function installReviewPaneProbe(page: Page, input: { file: string }) {
|
||||
await page.evaluate((input) => {
|
||||
const samples: ReviewPaneSample[] = []
|
||||
const basename = input.file.split(/[\\/]/).at(-1) ?? input.file
|
||||
let started: number | undefined
|
||||
let running = true
|
||||
|
||||
const paneState = () => {
|
||||
const panel = document.querySelector<HTMLElement>("#review-panel")
|
||||
const review = panel?.querySelector<HTMLElement>('[data-component="session-review-v2"]')
|
||||
const rect = (review ?? panel)?.getBoundingClientRect()
|
||||
const text = panel?.textContent ?? ""
|
||||
const previewHeader = panel?.querySelector<HTMLElement>(
|
||||
'[data-slot="session-review-v2-file-header"]',
|
||||
)?.textContent
|
||||
const header = previewHeader ?? text
|
||||
const viewers = panel ? [...panel.querySelectorAll<HTMLElement>('[data-component="file"][data-mode="diff"]')] : []
|
||||
const codeBlocks = panel?.querySelectorAll("code").length ?? 0
|
||||
const diffLines = viewers.reduce(
|
||||
(sum, viewer) =>
|
||||
sum +
|
||||
(viewer.shadowRoot?.querySelectorAll("[data-line]").length ?? viewer.querySelectorAll("[data-line]").length),
|
||||
0,
|
||||
)
|
||||
const panelVisible =
|
||||
!!panel && panel.getAttribute("aria-hidden") !== "true" && !!rect && rect.width > 0 && rect.height > 0
|
||||
return {
|
||||
panelVisible,
|
||||
header: header.slice(0, 500),
|
||||
diffViewers: viewers.length,
|
||||
diffLines,
|
||||
codeBlocks,
|
||||
ready:
|
||||
panelVisible &&
|
||||
header.includes(basename) &&
|
||||
(viewers.length > 0 || text.includes("+3") || diffLines > 0 || codeBlocks > 0),
|
||||
}
|
||||
}
|
||||
|
||||
const sample = () => {
|
||||
if (!running || started === undefined) return
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
if (!running || started === undefined) return
|
||||
samples.push({ observedAtMs: performance.now() - started, ...paneState() })
|
||||
if (performance.now() - started < 10_000) sample()
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
|
||||
;(window as Window & { __reviewPaneProbe?: ReviewPaneProbe }).__reviewPaneProbe = {
|
||||
samples,
|
||||
start: () => {
|
||||
started = performance.now()
|
||||
performance.mark("opencode.review-pane.click")
|
||||
sample()
|
||||
},
|
||||
stop: () => {
|
||||
running = false
|
||||
},
|
||||
}
|
||||
}, input)
|
||||
}
|
||||
|
||||
async function startReviewPaneProbe(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
;(window as Window & { __reviewPaneProbe?: ReviewPaneProbe }).__reviewPaneProbe!.start()
|
||||
})
|
||||
}
|
||||
|
||||
async function collectReviewPaneProbe(page: Page) {
|
||||
await page.waitForFunction((streak) => {
|
||||
const samples = (window as Window & { __reviewPaneProbe?: ReviewPaneProbe }).__reviewPaneProbe?.samples
|
||||
if (!samples) return false
|
||||
return samples.some((_, index) => {
|
||||
const stable = samples.slice(index, index + streak)
|
||||
return stable.length === streak && stable.every((sample) => sample.ready)
|
||||
})
|
||||
}, reviewReadyStreak)
|
||||
|
||||
const samples = await page.evaluate(() => {
|
||||
const probe = (window as Window & { __reviewPaneProbe?: ReviewPaneProbe }).__reviewPaneProbe!
|
||||
probe.stop()
|
||||
return probe.samples
|
||||
})
|
||||
return { summary: summarizeReviewPaneSamples(samples), samples }
|
||||
}
|
||||
|
||||
function summarizeReviewPaneSamples(samples: ReviewPaneSample[]) {
|
||||
const firstReady = samples.find((sample) => sample.ready)
|
||||
const stableIndex = samples.findIndex((_, index) => {
|
||||
const stable = samples.slice(index, index + reviewReadyStreak)
|
||||
return stable.length === reviewReadyStreak && stable.every((sample) => sample.ready)
|
||||
})
|
||||
return {
|
||||
samples: samples.length,
|
||||
firstReadyObservedMs: firstReady?.observedAtMs ?? null,
|
||||
stableReadyObservedMs: stableIndex === -1 ? null : samples[stableIndex + reviewReadyStreak - 1]!.observedAtMs,
|
||||
notReadySamples: samples.filter((sample) => !sample.ready).length,
|
||||
maxDiffViewers: Math.max(0, ...samples.map((sample) => sample.diffViewers)),
|
||||
maxDiffLines: Math.max(0, ...samples.map((sample) => sample.diffLines)),
|
||||
maxCodeBlocks: Math.max(0, ...samples.map((sample) => sample.codeBlocks)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,10 @@ export async function installTimelineSettings(page: Page) {
|
||||
|
||||
export function mockStressTimeline(
|
||||
page: Page,
|
||||
input?: { onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void },
|
||||
input?: {
|
||||
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
|
||||
vcsDiff?: unknown[]
|
||||
},
|
||||
) {
|
||||
return mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions,
|
||||
@@ -30,6 +33,7 @@ export function mockStressTimeline(
|
||||
project: fixture.project,
|
||||
pageMessages,
|
||||
onMessages: input?.onMessages,
|
||||
vcsDiff: input?.vcsDiff,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -45,7 +49,7 @@ export async function installStressSessionTabs(page: Page, input?: { draftID?: s
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:tabs",
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
...sessionIDs.map((sessionId) => ({
|
||||
type: "session",
|
||||
@@ -78,3 +82,53 @@ export function stressDraftHref(draftID: string) {
|
||||
function stressServer() {
|
||||
return `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
}
|
||||
|
||||
export function createReviewDiffs() {
|
||||
return Array.from({ length: Number(process.env.REVIEW_PANE_DIFF_COUNT ?? 72) }, (_, index) => {
|
||||
const lines = index % 3 === 0 ? 300 : index % 3 === 1 ? 120 : 38
|
||||
const file = `src/review/generated-${String(index).padStart(3, "0")}.ts`
|
||||
const before = reviewSource(index, lines)
|
||||
const after = before
|
||||
.replace(`value_${index}_4`, `updated_${index}_4`)
|
||||
.replace(
|
||||
`value_${index}_${Math.max(8, Math.floor(lines / 2))}`,
|
||||
`updated_${index}_${Math.max(8, Math.floor(lines / 2))}`,
|
||||
)
|
||||
.replace(`value_${index}_${lines - 4}`, `updated_${index}_${lines - 4}`)
|
||||
return {
|
||||
file,
|
||||
patch: reviewPatch(file, before, after),
|
||||
additions: 3,
|
||||
deletions: 3,
|
||||
status: "modified" as const,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function reviewSource(seed: number, lines: number) {
|
||||
return Array.from(
|
||||
{ length: lines },
|
||||
(_, index) => `export const value_${seed}_${index} = "${reviewWords(seed + index, index % 5 === 0 ? 180 : 42)}"`,
|
||||
).join("\n")
|
||||
}
|
||||
|
||||
function reviewPatch(file: string, before: string, after: string) {
|
||||
const beforeLines = before.split("\n")
|
||||
const afterLines = after.split("\n")
|
||||
return [
|
||||
`diff --git a/${file} b/${file}`,
|
||||
`--- a/${file}`,
|
||||
`+++ b/${file}`,
|
||||
`@@ -1,${beforeLines.length} +1,${afterLines.length} @@`,
|
||||
...beforeLines.flatMap((line, index) => {
|
||||
const next = afterLines[index]!
|
||||
if (line === next) return [` ${line}`]
|
||||
return [`-${line}`, `+${next}`]
|
||||
}),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
function reviewWords(seed: number, length: number) {
|
||||
const words = ["alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india", "juliet"]
|
||||
return Array.from({ length: Math.ceil(length / 7) }, (_, index) => words[(seed + index * 3) % words.length]).join(" ")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import {
|
||||
analyzeVisualStability,
|
||||
analyzeVisualStabilityByMarker,
|
||||
type VisualStabilityTrace,
|
||||
} from "../../utils/visual-stability"
|
||||
import { analyzeVisualObservations } from "../../utils/visual-stability/analyzer"
|
||||
import { legacyVisualPlan, visualPlan, type VisualInvariant } from "../../utils/visual-stability/invariant"
|
||||
import { defineVisualRegions, mapVisualRegions } from "../../utils/visual-stability/regions"
|
||||
|
||||
function trace(samples: VisualStabilityTrace["samples"]): VisualStabilityTrace {
|
||||
return { markers: [], samples }
|
||||
}
|
||||
|
||||
test("accepts continuous visible motion", () => {
|
||||
expect(
|
||||
analyzeVisualStability(
|
||||
trace([
|
||||
frame(0, region({ width: 80, bottom: 40 }), region({ top: 40, bottom: 60 })),
|
||||
frame(16, region({ width: 75, bottom: 45 }), region({ top: 45, bottom: 65 })),
|
||||
frame(32, region({ width: 70, bottom: 50 }), region({ top: 50, bottom: 70 })),
|
||||
]),
|
||||
{ flow: ["changing", "following"] },
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("reports repeated geometry reversals", () => {
|
||||
const issues = analyzeVisualStability(
|
||||
trace([
|
||||
frame(0, region({ width: 80 })),
|
||||
frame(16, region({ width: 60 })),
|
||||
frame(32, region({ width: 78 })),
|
||||
frame(48, region({ width: 62 })),
|
||||
]),
|
||||
)
|
||||
|
||||
expect(issues.some((issue) => issue.includes("changing width reversed 2 times"))).toBe(true)
|
||||
})
|
||||
|
||||
test("reports visible blanking, label reversal, and overlap", () => {
|
||||
const issues = analyzeVisualStability(
|
||||
trace([
|
||||
frame(0, region({ label: "Exploring", opacity: 1, bottom: 40 }), region({ top: 40, bottom: 60 })),
|
||||
frame(16, region({ label: "Explored", opacity: 0.2, bottom: 50 }), region({ top: 49, bottom: 69 })),
|
||||
frame(32, region({ label: "Exploring", opacity: 1, bottom: 50 }), region({ top: 50, bottom: 70 })),
|
||||
]),
|
||||
{ flow: ["changing", "following"] },
|
||||
)
|
||||
|
||||
expect(issues.some((issue) => issue.includes("opacity fell to 0.2"))).toBe(true)
|
||||
expect(issues.some((issue) => issue.includes("label reverted"))).toBe(true)
|
||||
expect(issues.some((issue) => issue.includes("overlapped following by 1px"))).toBe(true)
|
||||
})
|
||||
|
||||
test("reports duplicate regions and unexpected remounts", () => {
|
||||
const issues = analyzeVisualStability(
|
||||
trace([frame(0, region({ node: 1 })), frame(16, region({ node: 2, count: 2 })), frame(32, region({ node: 2 }))]),
|
||||
{ stable: ["changing"], unique: ["changing"] },
|
||||
)
|
||||
|
||||
expect(issues.some((issue) => issue.includes("changing appeared 2 times"))).toBe(true)
|
||||
expect(issues.some((issue) => issue.includes("changing remounted"))).toBe(true)
|
||||
})
|
||||
|
||||
test("reports bottom anchor loss but permits movement while scrolled away", () => {
|
||||
const anchored = analyzeVisualStability(
|
||||
trace([
|
||||
{ at: 0, regions: { changing: region() }, viewport: viewport(0) },
|
||||
{ at: 16, regions: { changing: region() }, viewport: viewport(24) },
|
||||
]),
|
||||
{ preserveBottomAnchor: true },
|
||||
)
|
||||
const away = analyzeVisualStability(
|
||||
trace([
|
||||
{ at: 0, regions: { changing: region() }, viewport: viewport(80) },
|
||||
{ at: 16, regions: { changing: region() }, viewport: viewport(104) },
|
||||
]),
|
||||
{ preserveBottomAnchor: true },
|
||||
)
|
||||
|
||||
expect(anchored.some((issue) => issue.includes("bottom anchor moved to 24px"))).toBe(true)
|
||||
expect(away).toEqual([])
|
||||
})
|
||||
|
||||
test("reports up down up movement while preserving a bottom anchor", () => {
|
||||
const issues = analyzeVisualStability(
|
||||
trace([
|
||||
{ at: 0, regions: { changing: region({ top: 200, bottom: 240 }) }, viewport: viewport(0) },
|
||||
{ at: 16, regions: { changing: region({ top: 180, bottom: 220 }) }, viewport: viewport(0) },
|
||||
{ at: 32, regions: { changing: region({ top: 196, bottom: 236 }) }, viewport: viewport(0) },
|
||||
{ at: 48, regions: { changing: region({ top: 176, bottom: 216 }) }, viewport: viewport(0) },
|
||||
]),
|
||||
{ preserveBottomAnchor: true, maxPositionReversals: 0 },
|
||||
)
|
||||
|
||||
expect(issues.some((issue) => issue.includes("changing top reversed 2 times"))).toBe(true)
|
||||
expect(issues.some((issue) => issue.includes("changing bottom reversed 2 times"))).toBe(true)
|
||||
})
|
||||
|
||||
test("accepts monotonic upward movement while preserving a bottom anchor", () => {
|
||||
expect(
|
||||
analyzeVisualStability(
|
||||
trace([
|
||||
{ at: 0, regions: { changing: region({ top: 200, bottom: 240 }) }, viewport: viewport(0) },
|
||||
{ at: 16, regions: { changing: region({ top: 190, bottom: 230 }) }, viewport: viewport(0) },
|
||||
{ at: 32, regions: { changing: region({ top: 180, bottom: 220 }) }, viewport: viewport(0) },
|
||||
]),
|
||||
{ preserveBottomAnchor: true, maxPositionReversals: 0 },
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("ignores overlap entirely outside the clipped timeline viewport", () => {
|
||||
expect(
|
||||
analyzeVisualStability(
|
||||
trace([
|
||||
{
|
||||
at: 0,
|
||||
regions: {
|
||||
changing: region({ top: -200, bottom: -100 }),
|
||||
following: region({ top: -150, bottom: -50 }),
|
||||
},
|
||||
viewport: viewport(0),
|
||||
},
|
||||
]),
|
||||
{ flow: ["changing", "following"] },
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("reports visible anchor movement while allowing virtual scrollbar movement", () => {
|
||||
const issues = analyzeVisualStability(
|
||||
trace([
|
||||
{ at: 0, regions: { anchor: region({ top: 100, bottom: 120 }) }, viewport: { ...viewport(100), scrollTop: 40 } },
|
||||
{ at: 16, regions: { anchor: region({ top: 100, bottom: 120 }) }, viewport: { ...viewport(120), scrollTop: 60 } },
|
||||
]),
|
||||
{ fixed: ["anchor"] },
|
||||
)
|
||||
|
||||
expect(issues).toEqual([])
|
||||
|
||||
const moved = analyzeVisualStability(
|
||||
trace([
|
||||
{ at: 0, regions: { anchor: region({ top: 100, bottom: 120 }) }, viewport: viewport(100) },
|
||||
{ at: 16, regions: { anchor: region({ top: 112, bottom: 132 }) }, viewport: viewport(100) },
|
||||
]),
|
||||
{ fixed: ["anchor"] },
|
||||
)
|
||||
expect(moved.some((issue) => issue.includes("anchor moved 12px in the viewport"))).toBe(true)
|
||||
})
|
||||
|
||||
test("analyzes each marked event independently", () => {
|
||||
const input: VisualStabilityTrace = {
|
||||
markers: [
|
||||
{ at: 10, label: "grow" },
|
||||
{ at: 40, label: "shrink" },
|
||||
],
|
||||
samples: [
|
||||
frame(0, region({ top: 100 })),
|
||||
frame(16, region({ top: 90 })),
|
||||
frame(32, region({ top: 80 })),
|
||||
frame(48, region({ top: 90 })),
|
||||
frame(64, region({ top: 100 })),
|
||||
],
|
||||
}
|
||||
|
||||
expect(analyzeVisualStability(input, { maxPositionReversals: 0 })).toContain("changing top reversed 1 times")
|
||||
expect(
|
||||
analyzeVisualStabilityByMarker(input, {
|
||||
maxPositionReversals: 0,
|
||||
motion: ["changing"],
|
||||
aggregateMotion: false,
|
||||
}),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("reports cross-event motion reversals by default", () => {
|
||||
const input: VisualStabilityTrace = {
|
||||
markers: [
|
||||
{ at: 10, label: "up" },
|
||||
{ at: 40, label: "down" },
|
||||
],
|
||||
samples: [
|
||||
frame(0, region({ top: 100 })),
|
||||
frame(16, region({ top: 90 })),
|
||||
frame(32, region({ top: 80 })),
|
||||
frame(48, region({ top: 90 })),
|
||||
frame(64, region({ top: 100 })),
|
||||
],
|
||||
}
|
||||
|
||||
expect(analyzeVisualStabilityByMarker(input, { maxPositionReversals: 0 })).toContain("changing top reversed 1 times")
|
||||
})
|
||||
|
||||
test("reports regions rendered in the wrong flow order", () => {
|
||||
const issues = analyzeVisualStability(
|
||||
trace([frame(0, region({ top: 100, bottom: 120 }), region({ top: 60, bottom: 80 }))]),
|
||||
{ flow: ["changing", "following"] },
|
||||
)
|
||||
|
||||
expect(issues.some((issue) => issue.includes("changing rendered after following"))).toBe(true)
|
||||
})
|
||||
|
||||
test("uses painted bounds instead of clipped layout overflow", () => {
|
||||
expect(
|
||||
analyzeVisualStability(
|
||||
trace([
|
||||
frame(
|
||||
0,
|
||||
region({ top: 100, bottom: 140, height: 40, layoutTop: 100, layoutBottom: 300 }),
|
||||
region({ top: 140, bottom: 180 }),
|
||||
),
|
||||
]),
|
||||
{ flow: ["changing", "following"] },
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("does not report disappearance when a present row moves outside the viewport", () => {
|
||||
expect(
|
||||
analyzeVisualStability(
|
||||
trace([
|
||||
frame(0, region({ visible: true })),
|
||||
frame(16, region({ visible: false, inViewport: false, top: -100, bottom: -80 })),
|
||||
frame(32, region({ visible: true })),
|
||||
]),
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("reports an in-viewport transparent frame between visible frames", () => {
|
||||
const issues = analyzeVisualStability(
|
||||
trace([
|
||||
frame(0, region()),
|
||||
frame(16, region({ visible: false, opacity: 0, inViewport: true })),
|
||||
frame(32, region()),
|
||||
]),
|
||||
)
|
||||
|
||||
expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true)
|
||||
})
|
||||
|
||||
test("reports an in-viewport display-none frame between visible frames", () => {
|
||||
const issues = analyzeVisualStability(
|
||||
trace([
|
||||
frame(0, region()),
|
||||
frame(16, region({ visible: false, width: 0, height: 0, inViewport: true, cssHidden: true })),
|
||||
frame(32, region()),
|
||||
]),
|
||||
)
|
||||
|
||||
expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true)
|
||||
})
|
||||
|
||||
test("can limit motion analysis to unaffected regions", () => {
|
||||
expect(
|
||||
analyzeVisualStability(
|
||||
trace([
|
||||
frame(0, region({ height: 20 }), region()),
|
||||
frame(16, region({ height: 40 }), region()),
|
||||
frame(32, region({ height: 30 }), region()),
|
||||
]),
|
||||
{ motion: ["following"] },
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("reports a blank frame across replacement surfaces", () => {
|
||||
const issues = analyzeVisualStability(
|
||||
{
|
||||
markers: [],
|
||||
samples: [
|
||||
{ at: 0, regions: { thinking: region(), error: region({ present: false, visible: false }) } },
|
||||
{
|
||||
at: 16,
|
||||
regions: {
|
||||
thinking: region({ present: false, visible: false }),
|
||||
error: region({ present: false, visible: false }),
|
||||
},
|
||||
},
|
||||
{ at: 32, regions: { thinking: region({ present: false, visible: false }), error: region() } },
|
||||
],
|
||||
},
|
||||
{ continuousAny: [["thinking", "error"]] },
|
||||
)
|
||||
|
||||
expect(issues.some((issue) => issue.includes("thinking | error blanked"))).toBe(true)
|
||||
})
|
||||
|
||||
test("reports failure to acquire the bottom anchor", () => {
|
||||
const issues = analyzeVisualStability(
|
||||
trace([
|
||||
{ at: 0, regions: { changing: region() }, viewport: viewport(600) },
|
||||
{ at: 16, regions: { changing: region() }, viewport: viewport(120) },
|
||||
]),
|
||||
{ acquireBottomAnchor: true },
|
||||
)
|
||||
|
||||
expect(issues.some((issue) => issue.includes("did not acquire bottom anchor"))).toBe(true)
|
||||
})
|
||||
|
||||
test("reports a required region that never renders", () => {
|
||||
const issues = analyzeVisualStability(
|
||||
trace([
|
||||
{
|
||||
at: 0,
|
||||
regions: { changing: region({ present: false, visible: false }) },
|
||||
},
|
||||
]),
|
||||
{ required: ["changing"] },
|
||||
)
|
||||
|
||||
expect(issues).toContain("changing never rendered")
|
||||
})
|
||||
|
||||
test("preserves typed region names while mapping definitions", () => {
|
||||
const regions = defineVisualRegions({
|
||||
changing: { selector: "[data-changing]" },
|
||||
following: { selector: "[data-following]", closest: "[data-row]" },
|
||||
})
|
||||
const selectors = mapVisualRegions(regions, (region) => region.selector)
|
||||
|
||||
expect(selectors).toEqual({ changing: "[data-changing]", following: "[data-following]" })
|
||||
const name: keyof typeof selectors = "changing"
|
||||
expect(name).toBe("changing")
|
||||
})
|
||||
|
||||
test("evaluates the typed invariant algebra over explicit observations", () => {
|
||||
const regions = defineVisualRegions({
|
||||
changing: { selector: "[data-changing]" },
|
||||
following: { selector: "[data-following]" },
|
||||
})
|
||||
const invariants = [
|
||||
{ type: "required", regions: ["changing"] },
|
||||
{ type: "flow", regions: ["changing", "following"] },
|
||||
] satisfies VisualInvariant<keyof typeof regions>[]
|
||||
// @ts-expect-error Plans reject names that are not in the region definition.
|
||||
const invalid = { type: "required", regions: ["missing"] } satisfies VisualInvariant<keyof typeof regions>
|
||||
const plan = visualPlan(regions, invariants, { perMarker: true })
|
||||
|
||||
expect(invalid.regions).toEqual(["missing"])
|
||||
expect(plan.perMarker).toBe(true)
|
||||
expect(analyzeVisualObservations([frame(0, region({ bottom: 50 }), region({ top: 49, bottom: 69 }))], plan)).toEqual([
|
||||
"changing overlapped following by 1px at 0ms",
|
||||
])
|
||||
})
|
||||
|
||||
test("legacy plan adapter preserves analyzer messages and order", () => {
|
||||
const input = trace([
|
||||
frame(0, region({ label: "Exploring", opacity: 1, bottom: 40 }), region({ top: 40, bottom: 60 })),
|
||||
frame(16, region({ label: "Explored", opacity: 0.2, bottom: 50 }), region({ top: 49, bottom: 69 })),
|
||||
frame(32, region({ label: "Exploring", opacity: 1, bottom: 50 }), region({ top: 50, bottom: 70 })),
|
||||
])
|
||||
const options = { flow: ["changing", "following"], stable: ["changing"] }
|
||||
|
||||
expect(analyzeVisualObservations(input.samples, legacyVisualPlan(options))).toEqual(
|
||||
analyzeVisualStability(input, options),
|
||||
)
|
||||
})
|
||||
|
||||
function frame(
|
||||
at: number,
|
||||
changing: VisualStabilityTrace["samples"][number]["regions"][string],
|
||||
following?: VisualStabilityTrace["samples"][number]["regions"][string],
|
||||
) {
|
||||
return { at, regions: { changing, ...(following ? { following } : {}) } }
|
||||
}
|
||||
|
||||
function region(input: Partial<VisualStabilityTrace["samples"][number]["regions"][string]> = {}) {
|
||||
return {
|
||||
present: true,
|
||||
visible: true,
|
||||
inViewport: true,
|
||||
top: 0,
|
||||
bottom: 20,
|
||||
width: 100,
|
||||
height: 20,
|
||||
opacity: 1,
|
||||
count: 1,
|
||||
node: 1,
|
||||
label: "",
|
||||
text: "",
|
||||
layoutTop: input.top ?? 0,
|
||||
layoutBottom: input.bottom ?? 20,
|
||||
...input,
|
||||
}
|
||||
}
|
||||
|
||||
function viewport(distanceFromBottom: number) {
|
||||
return { top: 0, bottom: 400, scrollTop: 100, scrollHeight: 500, clientHeight: 400, distanceFromBottom }
|
||||
}
|
||||
@@ -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("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:tabs",
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "session", server: "http://127.0.0.1:4096", sessionId: sessionA },
|
||||
{ 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("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:tabs",
|
||||
"opencode.window.browser.dat:tabs",
|
||||
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 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 page.mouse.move(0, 0)
|
||||
await expect(control).toBeVisible()
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
|
||||
const serverA = "http://127.0.0.1:4096"
|
||||
const serverB = "http://127.0.0.1:4097"
|
||||
const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
|
||||
const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
|
||||
|
||||
test("tab busy indicator reflects the tab server's own session status", async ({ page }) => {
|
||||
await mockServers(page)
|
||||
await page.addInitScript(
|
||||
({ serverA, serverB, sessionA, sessionB }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "session", server: serverA, sessionId: sessionA },
|
||||
{ type: "session", server: serverB, sessionId: sessionB },
|
||||
]),
|
||||
)
|
||||
},
|
||||
{ serverA, serverB, sessionA: sessionA.id, sessionB: sessionB.id },
|
||||
)
|
||||
|
||||
const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}`
|
||||
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
|
||||
await page.goto(hrefA)
|
||||
await expect(page.getByText(sessionA.title).first()).toBeVisible()
|
||||
|
||||
// Session B is busy on server B while server A stays the active server, so the
|
||||
// busy indicator must come from the tab server's status, not the active server's.
|
||||
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`)
|
||||
await expect(tabB.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
|
||||
|
||||
const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`)
|
||||
await expect(tabA.locator("[data-titlebar-tab-title]")).toHaveText(sessionA.title)
|
||||
await expect(tabA.locator('[data-component="session-progress-indicator-v2"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
function session(id: string, directory: string, title: string) {
|
||||
return {
|
||||
id,
|
||||
slug: id,
|
||||
projectID: `project-${id}`,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1, updated: 1 },
|
||||
}
|
||||
}
|
||||
|
||||
async function mockServers(page: Page) {
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
|
||||
const current = url.origin === serverA ? sessionA : sessionB
|
||||
const directory = url.searchParams.get("directory")
|
||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||
if (url.pathname === "/session/status")
|
||||
return json(route, url.origin === serverB ? { [sessionB.id]: { type: "busy" } } : {})
|
||||
if (url.pathname === "/session") return json(route, [current])
|
||||
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||
if (url.pathname === "/provider")
|
||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||
if (url.pathname === "/project" || url.pathname === "/project/current") {
|
||||
const project = {
|
||||
id: current.projectID,
|
||||
worktree: current.directory,
|
||||
vcs: "git",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
}
|
||||
return json(route, url.pathname === "/project" ? [project] : project)
|
||||
}
|
||||
if (url.pathname === "/path")
|
||||
return json(route, {
|
||||
state: current.directory,
|
||||
config: current.directory,
|
||||
worktree: current.directory,
|
||||
directory: current.directory,
|
||||
home: current.directory,
|
||||
})
|
||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||
return json(route, {})
|
||||
})
|
||||
}
|
||||
|
||||
function json(route: Route, body: unknown, status = 200) {
|
||||
return route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
function sse(route: Route) {
|
||||
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/ReviewImageFlashRegression"
|
||||
const sessionID = "ses_review_image_flash_regression"
|
||||
const title = "Review image flash regression"
|
||||
const imageFile = "assets/preview.png"
|
||||
|
||||
test("clicking an image file in the v2 review pane does not blank the panel", async ({ page }) => {
|
||||
await openReview(page)
|
||||
await installReviewFlashProbe(page)
|
||||
|
||||
await page.getByRole("button", { name: /preview\.png/ }).click()
|
||||
await waitForReviewFlashProbe(page, 400)
|
||||
const trace = await collectReviewFlashProbe(page)
|
||||
const bad = trace.samples.filter((sample) => sample.blank || sample.blackCenter)
|
||||
|
||||
expect(trace.samples.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
bad,
|
||||
JSON.stringify({ bad: bad.slice(0, 8), first: trace.samples.slice(0, 8), last: trace.samples.slice(-4) }, null, 2),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
async function openReview(page: Page) {
|
||||
await page.setViewportSize({ width: 960, height: 900 })
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
})
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_review_image_flash_regression",
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "review-image-flash-regression",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: "review-image-flash-regression",
|
||||
projectID: "proj_review_image_flash_regression",
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
vcsDiff: [
|
||||
{
|
||||
file: "src/example.ts",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
patch:
|
||||
"diff --git a/src/example.ts b/src/example.ts\n--- a/src/example.ts\n+++ b/src/example.ts\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n",
|
||||
},
|
||||
{
|
||||
file: imageFile,
|
||||
patch: "",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
status: "added",
|
||||
},
|
||||
],
|
||||
fileContent: async (path) => {
|
||||
if (path !== imageFile) return undefined
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
return {
|
||||
type: "binary",
|
||||
content: "iVBORw0KGgo=",
|
||||
encoding: "base64",
|
||||
mimeType: "image/png",
|
||||
}
|
||||
},
|
||||
fileList: (path) => {
|
||||
if (!path) {
|
||||
return [
|
||||
{ name: "assets", path: "assets", absolute: `${directory}/assets`, type: "directory", ignored: false },
|
||||
{ name: "src", path: "src", absolute: `${directory}/src`, type: "directory", ignored: false },
|
||||
]
|
||||
}
|
||||
if (path === "assets") {
|
||||
return [
|
||||
{
|
||||
name: "preview.png",
|
||||
path: imageFile,
|
||||
absolute: `${directory}/${imageFile}`,
|
||||
type: "file",
|
||||
ignored: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
if (path === "src") {
|
||||
return [
|
||||
{
|
||||
name: "example.ts",
|
||||
path: "src/example.ts",
|
||||
absolute: `${directory}/src/example.ts`,
|
||||
type: "file",
|
||||
ignored: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
return []
|
||||
},
|
||||
pageMessages: () => ({
|
||||
items: [
|
||||
{
|
||||
info: {
|
||||
id: "msg_review_image_flash_regression",
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1700000000000 },
|
||||
summary: { diffs: [] },
|
||||
agent: "build",
|
||||
model: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: "prt_review_image_flash_regression",
|
||||
sessionID,
|
||||
messageID: "msg_review_image_flash_regression",
|
||||
type: "text",
|
||||
text: "Review this change.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
await expectAppVisible(page.locator('#review-panel [data-component="session-review-v2"]'))
|
||||
await expectAppVisible(page.getByRole("button", { name: /preview\.png/ }))
|
||||
}
|
||||
|
||||
async function installReviewFlashProbe(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const samples: Array<{
|
||||
observedAtMs: number
|
||||
blank: boolean
|
||||
blackCenter: boolean
|
||||
text: string
|
||||
background: string
|
||||
}> = []
|
||||
const startedAt = performance.now()
|
||||
const sample = () => {
|
||||
const panel = document.querySelector<HTMLElement>('#review-panel [data-component="session-review-v2"]')
|
||||
const rect = panel?.getBoundingClientRect()
|
||||
const center = rect
|
||||
? document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2)
|
||||
: undefined
|
||||
const background = center instanceof Element ? getComputedStyle(center).backgroundColor : ""
|
||||
samples.push({
|
||||
observedAtMs: performance.now() - startedAt,
|
||||
blank: !panel || panel.textContent?.trim().length === 0,
|
||||
blackCenter: background === "rgb(0, 0, 0)",
|
||||
text: panel?.textContent?.trim().slice(0, 80) ?? "",
|
||||
background,
|
||||
})
|
||||
if (performance.now() - startedAt < 500) requestAnimationFrame(sample)
|
||||
}
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
const target = event.target instanceof Element ? event.target : undefined
|
||||
if (!target?.closest('[data-slot="file-tree-v2-row"]')) return
|
||||
requestAnimationFrame(sample)
|
||||
},
|
||||
{ capture: true, once: true },
|
||||
)
|
||||
;(window as Window & { __reviewImageFlash?: { samples: typeof samples; startedAt: number } }).__reviewImageFlash = {
|
||||
samples,
|
||||
startedAt,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForReviewFlashProbe(page: Page, durationMs: number) {
|
||||
await page.waitForFunction((durationMs) => {
|
||||
const state = (window as Window & { __reviewImageFlash?: { samples: unknown[]; startedAt: number } })
|
||||
.__reviewImageFlash
|
||||
return !!state && state.samples.length > 0 && performance.now() - state.startedAt >= durationMs
|
||||
}, durationMs)
|
||||
}
|
||||
|
||||
async function collectReviewFlashProbe(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
return (window as Window & { __reviewImageFlash?: { samples: unknown[]; startedAt: number } }).__reviewImageFlash!
|
||||
}) as Promise<{
|
||||
startedAt: number
|
||||
samples: Array<{ observedAtMs: number; blank: boolean; blackCenter: boolean; text: string; background: string }>
|
||||
}>
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/ReviewTabSwitch"
|
||||
const projectID = "proj_review_tab_switch"
|
||||
const sessionA = "ses_review_tab_a"
|
||||
const sessionB = "ses_review_tab_b"
|
||||
const titleA = "Alpha session"
|
||||
const titleB = "Beta session"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
// Marks the review pane DOM node so a remount (fresh node) is detectable.
|
||||
const PROBE = "original"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
// The v2 review pane's diff data is workspace-scoped: switching between session
|
||||
// tabs in the same workspace must update its parameters reactively instead of
|
||||
// tearing the pane down and remounting it (which flickers).
|
||||
test("keeps the v2 review pane mounted when switching session tabs in a workspace", async ({ page }) => {
|
||||
await setup(page)
|
||||
|
||||
await page.goto(sessionHref(sessionA))
|
||||
await expectSessionTitle(page, titleA)
|
||||
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
const review = page.locator('#review-panel [data-component="session-review-v2"]')
|
||||
await expectAppVisible(review)
|
||||
await expectAppVisible(page.getByRole("button", { name: /example\.ts/ }))
|
||||
await writeProbe(page)
|
||||
|
||||
await switchTab(page, titleB)
|
||||
await expectSessionTitle(page, titleB)
|
||||
await expectAppVisible(review)
|
||||
expect(await readProbe(page)).toBe(PROBE)
|
||||
|
||||
await switchTab(page, titleA)
|
||||
await expectSessionTitle(page, titleA)
|
||||
await expectAppVisible(review)
|
||||
expect(await readProbe(page)).toBe(PROBE)
|
||||
})
|
||||
|
||||
type Probed = HTMLElement & { __e2eProbe?: string }
|
||||
|
||||
async function switchTab(page: Page, title: string) {
|
||||
await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click()
|
||||
}
|
||||
|
||||
async function writeProbe(page: Page) {
|
||||
await page.locator('#review-panel [data-component="session-review-v2"]').evaluate((el, probe) => {
|
||||
;(el as Probed).__e2eProbe = probe
|
||||
}, PROBE)
|
||||
}
|
||||
|
||||
async function readProbe(page: Page) {
|
||||
return page.locator('#review-panel [data-component="session-review-v2"]').evaluate((el) => (el as Probed).__e2eProbe)
|
||||
}
|
||||
|
||||
async function setup(page: Page) {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "review-tab-switch",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
||||
vcsDiff: [
|
||||
{
|
||||
file: "src/example.ts",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
patch:
|
||||
"diff --git a/src/example.ts b/src/example.ts\n--- a/src/example.ts\n+++ b/src/example.ts\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n",
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessions }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
|
||||
)
|
||||
},
|
||||
{ directory, server, sessions: [sessionA, sessionB] },
|
||||
)
|
||||
}
|
||||
|
||||
function session(id: string, title: string, created: number) {
|
||||
return {
|
||||
id,
|
||||
slug: id,
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created, updated: created },
|
||||
}
|
||||
}
|
||||
|
||||
function sessionHref(sessionID: string) {
|
||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||
}
|
||||
@@ -38,6 +38,29 @@ test("shows a pending question dock", async ({ page }) => {
|
||||
await expect(question.getByRole("radio", { name: /Extended/ })).toBeVisible()
|
||||
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()
|
||||
const reply = page.waitForRequest(
|
||||
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { assistantMessage, setupTimeline, shell, userMessage } from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("space activates a focused timeline button instead of scrolling", async ({ page }) => {
|
||||
const shellID = "prt_space_button_shell"
|
||||
await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([shell(shellID, "completed", lines(5))])],
|
||||
settings: { shellToolPartsExpanded: false },
|
||||
reducedMotion: true,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
|
||||
await trigger.focus()
|
||||
const before = await scroller.evaluate((element) => element.scrollTop)
|
||||
await trigger.press("Space")
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
expect(await scroller.evaluate((element) => element.scrollTop)).toBe(before)
|
||||
})
|
||||
|
||||
function lines(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||
import {
|
||||
analyzeVisualObservations,
|
||||
defineVisualRegions,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../utils/visual-stability"
|
||||
|
||||
const directory = "C:/OpenCode/ContextResizeRegression"
|
||||
const projectID = "proj_context_resize_regression"
|
||||
@@ -36,6 +43,82 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
expect(visibleOverlap).toEqual([])
|
||||
expect(samples.at(-1)?.expanded).toBe("true")
|
||||
})
|
||||
|
||||
test("paints a stable exploring to explored transition", async ({ page }) => {
|
||||
const events: { directory: string; payload: Record<string, unknown> }[] = []
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await mockServer(page, events, [
|
||||
...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(),
|
||||
...turn(10, true, "running"),
|
||||
])
|
||||
await configurePage(page)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
const devtools = await page.context().newCDPSession(page)
|
||||
await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 })
|
||||
const context = page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()
|
||||
await expectAppVisible(context)
|
||||
await expect(context.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Exploring")
|
||||
|
||||
const contextSelector = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
|
||||
const regions = defineVisualRegions({
|
||||
status: {
|
||||
selector: `${contextSelector} [data-component="tool-status-title"]`,
|
||||
opacitySelectors: ['[data-slot="tool-status-active"]', '[data-slot="tool-status-done"]'],
|
||||
},
|
||||
context: { selector: contextSelector, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingTextID}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
for (const [index, delay] of [120, 350, 80, 500].entries()) {
|
||||
events.push({
|
||||
directory,
|
||||
payload: {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: contextTool(
|
||||
contextIDs[index]!,
|
||||
id("msg_assistant", 10),
|
||||
["read", "glob", "grep", "list"][index]!,
|
||||
[
|
||||
{ filePath: "src/recent-a.ts" },
|
||||
{ path: directory, pattern: "**/*.ts" },
|
||||
{ path: directory, pattern: "Explored" },
|
||||
{ path: "src" },
|
||||
][index]!,
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
await page.waitForTimeout(delay)
|
||||
}
|
||||
|
||||
await expect(context.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored")
|
||||
await page.waitForTimeout(700)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
const labels = trace.samples
|
||||
.map((sample) => sample.regions.status?.label)
|
||||
.filter((value): value is string => !!value)
|
||||
.filter((value, index, all) => value !== all[index - 1])
|
||||
const issues = analyzeVisualObservations(
|
||||
trace.samples,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["context", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all" },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "flow", regions: ["context", "following"] },
|
||||
]),
|
||||
)
|
||||
|
||||
expect(labels).toEqual(["Exploring", "Explored"])
|
||||
expect(issues, JSON.stringify(trace.samples, null, 2)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
async function configurePage(page: Page) {
|
||||
@@ -128,7 +211,7 @@ async function sampleExpansion(page: Page) {
|
||||
)
|
||||
}
|
||||
|
||||
function turn(index: number, target: boolean): Message[] {
|
||||
function turn(index: number, target: boolean, status: "running" | "completed" = "completed"): Message[] {
|
||||
const userID = id("msg_user", index)
|
||||
const assistantID = id("msg_assistant", index)
|
||||
return [
|
||||
@@ -163,10 +246,22 @@ function turn(index: number, target: boolean): Message[] {
|
||||
},
|
||||
parts: target
|
||||
? [
|
||||
contextTool(contextIDs[0]!, assistantID, "read", { filePath: "src/recent-a.ts", offset: 0, limit: 120 }),
|
||||
contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }),
|
||||
contextTool(contextIDs[2]!, assistantID, "grep", { path: directory, pattern: "Explored", include: "*.ts" }),
|
||||
contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }),
|
||||
contextTool(
|
||||
contextIDs[0]!,
|
||||
assistantID,
|
||||
"read",
|
||||
{ filePath: "src/recent-a.ts", offset: 0, limit: 120 },
|
||||
status,
|
||||
),
|
||||
contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }, status),
|
||||
contextTool(
|
||||
contextIDs[2]!,
|
||||
assistantID,
|
||||
"grep",
|
||||
{ path: directory, pattern: "Explored", include: "*.ts" },
|
||||
status,
|
||||
),
|
||||
contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status),
|
||||
{
|
||||
id: followingTextID,
|
||||
sessionID,
|
||||
@@ -188,7 +283,13 @@ function turn(index: number, target: boolean): Message[] {
|
||||
]
|
||||
}
|
||||
|
||||
function contextTool(partID: string, messageID: string, tool: string, input: Record<string, unknown>) {
|
||||
function contextTool(
|
||||
partID: string,
|
||||
messageID: string,
|
||||
tool: string,
|
||||
input: Record<string, unknown>,
|
||||
status: "running" | "completed" = "completed",
|
||||
) {
|
||||
return {
|
||||
id: partID,
|
||||
sessionID,
|
||||
@@ -197,7 +298,7 @@ function contextTool(partID: string, messageID: string, tool: string, input: Rec
|
||||
callID: `call_${partID}`,
|
||||
tool,
|
||||
state: {
|
||||
status: "completed",
|
||||
status,
|
||||
input,
|
||||
output: `Completed ${tool}.\n${"detail line\n".repeat(8)}`,
|
||||
title: input.filePath || input.path || input.pattern || "completed",
|
||||
@@ -207,13 +308,19 @@ function contextTool(partID: string, messageID: string, tool: string, input: Rec
|
||||
}
|
||||
}
|
||||
|
||||
async function mockServer(page: Page) {
|
||||
async function mockServer(
|
||||
page: Page,
|
||||
events: { directory: string; payload: Record<string, unknown> }[] = [],
|
||||
fixtureMessages = messages,
|
||||
) {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: project(),
|
||||
provider: provider(),
|
||||
sessions: [session()],
|
||||
pageMessages: () => ({ items: messages }),
|
||||
pageMessages: () => ({ items: fixtureMessages }),
|
||||
events: () => events.splice(0, 1),
|
||||
eventRetry: 50,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("preserves a collapsed context group through count and status updates", async ({ page }) => {
|
||||
const ids = ["prt_closed_01_read", "prt_closed_02_glob"]
|
||||
const inputs = {
|
||||
read: { filePath: "src/a.ts", offset: 0, limit: 120 },
|
||||
glob: { path: ".", pattern: "**/*.ts" },
|
||||
}
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[toolPart(ids[0]!, "read", "running", inputs.read), toolPart(ids[1]!, "glob", "running", inputs.glob)],
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
})
|
||||
const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
|
||||
const trigger = group.locator('[data-slot="collapsible-trigger"]')
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await timeline.send(partUpdated(toolPart(ids[0]!, "read", "completed", inputs.read)), 100)
|
||||
await timeline.send(partUpdated(toolPart(ids[1]!, "glob", "completed", inputs.glob)), 300)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("renders completed write content", async ({ page }) => {
|
||||
const id = "prt_file_projection_write"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(id, "write", "completed", { filePath: "src/write.ts", content: "export const written = true\n" }),
|
||||
]),
|
||||
],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
})
|
||||
|
||||
await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="write-content"]`)).toBeVisible()
|
||||
})
|
||||
|
||||
test("renders a completed single-file patch", async ({ page }) => {
|
||||
const id = "prt_file_projection_single_patch"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
id,
|
||||
"apply_patch",
|
||||
"completed",
|
||||
{ files: ["src/a.ts"] },
|
||||
{
|
||||
metadata: {
|
||||
files: [
|
||||
{
|
||||
filePath: "src/a.ts",
|
||||
relativePath: "src/a.ts",
|
||||
type: "update",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
before: "export const value = 1\n",
|
||||
after: "export const value = 2\n",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
]),
|
||||
],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
})
|
||||
|
||||
await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="apply-patch-file-diff"]`)).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("updates edit diagnostics without resetting manual collapse state", async ({ page }) => {
|
||||
const editID = "prt_diagnostics_edit"
|
||||
const base = editPart(editID, [])
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([base])],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
})
|
||||
const trigger = page.locator(`[data-timeline-part-id="${editID}"] [data-slot="collapsible-trigger"]`).first()
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await timeline.send(
|
||||
partUpdated(editPart(editID, [diagnostic("First failure", 2), diagnostic("Second failure", 4)])),
|
||||
300,
|
||||
)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await timeline.send(partUpdated(editPart(editID, [])), 300)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
|
||||
test("preserves nested patch file state through outer collapse and reopen", async ({ page }) => {
|
||||
const patchID = "prt_nested_patch"
|
||||
const files = [patchFile("src/a.ts", "update"), patchFile("src/b.ts", "add"), patchFile("src/old.ts", "delete")]
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
patchID,
|
||||
"apply_patch",
|
||||
"completed",
|
||||
{ files: files.map((file) => file.filePath) },
|
||||
{ metadata: { files } },
|
||||
),
|
||||
]),
|
||||
],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
})
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${patchID}"]`)
|
||||
const outer = wrapper.locator('[data-slot="collapsible-trigger"]').first()
|
||||
const deleted = wrapper.locator('[data-scope="apply-patch"] [data-type="delete"]')
|
||||
await deleted.getByRole("button").click()
|
||||
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
|
||||
await outer.click()
|
||||
await expect(outer).toHaveAttribute("aria-expanded", "false")
|
||||
await outer.click()
|
||||
await expect(outer).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
|
||||
})
|
||||
|
||||
function patchFile(filePath: string, type: "add" | "update" | "delete") {
|
||||
return {
|
||||
filePath,
|
||||
relativePath: filePath,
|
||||
type,
|
||||
additions: type === "delete" ? 0 : 4,
|
||||
deletions: type === "add" ? 0 : 3,
|
||||
before: type === "add" ? undefined : source(false),
|
||||
after: type === "delete" ? undefined : source(true),
|
||||
}
|
||||
}
|
||||
|
||||
function editPart(id: string, diagnostics: Record<string, unknown>[]) {
|
||||
return toolPart(
|
||||
id,
|
||||
"edit",
|
||||
"completed",
|
||||
{ filePath: "src/edit.ts" },
|
||||
{
|
||||
metadata: {
|
||||
filediff: { file: "src/edit.ts", additions: 1, deletions: 1, before: source(false), after: source(true) },
|
||||
diagnostics,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function diagnostic(message: string, line: number) {
|
||||
return { message, severity: 1, range: { start: { line, character: 0 }, end: { line, character: 2 } } }
|
||||
}
|
||||
|
||||
function source(changed: boolean) {
|
||||
return Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join(
|
||||
"",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
completedAssistantInfo,
|
||||
messageUpdated,
|
||||
partUpdated,
|
||||
reasoningPart,
|
||||
setupTimeline,
|
||||
shell,
|
||||
status,
|
||||
textPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
for (const expanded of [false, true]) {
|
||||
test(`preserves shell user intent from a ${expanded ? "expanded" : "collapsed"} default`, async ({ page }) => {
|
||||
const id = `prt_shell_default_${expanded}`
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([shell(id, "completed", lines(3))])],
|
||||
settings: { shellToolPartsExpanded: expanded },
|
||||
})
|
||||
const trigger = page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
|
||||
|
||||
await timeline.send(partUpdated(shell(id, "completed", lines(6))), 180)
|
||||
await timeline.send(partUpdated(textPart(`prt_sibling_${expanded}`, "Sibling content")), 180)
|
||||
await timeline.send(status("busy"), 100)
|
||||
await timeline.send(status("idle"), 250)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
|
||||
})
|
||||
}
|
||||
|
||||
test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => {
|
||||
const reasoningID = "prt_reasoning_hidden"
|
||||
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistant],
|
||||
settings: { showReasoningSummaries: false },
|
||||
cpuRate: 4,
|
||||
})
|
||||
await timeline.send(status("busy"), 150)
|
||||
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
|
||||
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
|
||||
await timeline.send(partUpdated(shell("prt_reasoning_shell", "running")), 160)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await timeline.send(partUpdated(shell("prt_reasoning_shell", "completed", "done")), 180)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 100)
|
||||
await timeline.send(status("idle"), 300)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("moves busy through retry and recovery to final idle content", async ({ page }) => {
|
||||
const assistant = assistantMessage([], { completed: false })
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(undefined, {
|
||||
summary: {
|
||||
diffs: [
|
||||
{
|
||||
file: "src/retry.ts",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: "@@ -1 +1 @@\n-export const retry = false\n+export const retry = true",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
assistant,
|
||||
],
|
||||
})
|
||||
await timeline.send(status("busy"), 140)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
|
||||
await timeline.send(status("retry"), 180)
|
||||
await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await timeline.send(status("busy", 2), 180)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")), 140)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 100)
|
||||
await timeline.send(status("idle"), 350)
|
||||
await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
|
||||
})
|
||||
|
||||
function lines(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
|
||||
|
||||
for (const profile of [
|
||||
{ locale: "de", label: "Erkundet" },
|
||||
{ locale: "ar", label: "تم الاستكشاف" },
|
||||
] as const) {
|
||||
test(`projects translated context status in ${profile.locale}`, async ({ page }) => {
|
||||
const ids = [`prt_locale_${profile.locale}_01_read`, `prt_locale_${profile.locale}_02_glob`]
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(ids[0]!, "read", "completed", { filePath: "src/a.ts" }),
|
||||
toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
|
||||
]),
|
||||
],
|
||||
locale: profile.locale,
|
||||
})
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
|
||||
await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", profile.label)
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", profile.locale)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
setupTimeline,
|
||||
status,
|
||||
toolPart,
|
||||
userMessage,
|
||||
userText,
|
||||
type PartSeed,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test.describe("session timeline projection", () => {
|
||||
test("renders every admitted tool family and hides timeline-only exclusions", async ({ page }) => {
|
||||
const parts = [
|
||||
toolPart("prt_01_read", "read", "completed", { filePath: "src/a.ts" }),
|
||||
toolPart("prt_02_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
|
||||
toolPart("prt_03_grep", "grep", "completed", { path: ".", pattern: "value" }),
|
||||
toolPart("prt_04_list", "list", "completed", { path: "src" }),
|
||||
toolPart("prt_webfetch", "webfetch", "completed", { url: "https://example.com" }),
|
||||
toolPart(
|
||||
"prt_websearch",
|
||||
"websearch",
|
||||
"completed",
|
||||
{ query: "timeline stability" },
|
||||
{ output: "https://example.com/result" },
|
||||
),
|
||||
toolPart("prt_task", "task", "completed", { description: "Inspect timeline", subagent_type: "explore" }),
|
||||
toolPart(
|
||||
"prt_bash",
|
||||
"bash",
|
||||
"completed",
|
||||
{ command: "printf stable" },
|
||||
{ output: "stable", title: "printf stable" },
|
||||
),
|
||||
editPart("prt_edit"),
|
||||
toolPart("prt_write", "write", "completed", { filePath: "src/new.ts", content: "export const stable = true\n" }),
|
||||
patchPart("prt_patch"),
|
||||
toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }),
|
||||
toolPart(
|
||||
"prt_question",
|
||||
"question",
|
||||
"completed",
|
||||
{ questions: [{ question: "Keep stable?", header: "Stability", options: [] }] },
|
||||
{ metadata: { answers: [["Yes"]] } },
|
||||
),
|
||||
toolPart("prt_skill", "skill", "completed", { name: "stability" }),
|
||||
toolPart("prt_custom", "custom_mcp_tool", "completed", { target: "timeline", count: 2 }),
|
||||
]
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
await expect(
|
||||
page.locator('[data-timeline-part-ids="prt_01_read,prt_02_glob,prt_03_grep,prt_04_list"]'),
|
||||
).toBeVisible()
|
||||
for (const id of [
|
||||
"prt_webfetch",
|
||||
"prt_websearch",
|
||||
"prt_task",
|
||||
"prt_bash",
|
||||
"prt_edit",
|
||||
"prt_write",
|
||||
"prt_patch",
|
||||
"prt_question",
|
||||
"prt_skill",
|
||||
"prt_custom",
|
||||
]) {
|
||||
await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible()
|
||||
}
|
||||
await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
|
||||
const firstUser = userMessage(
|
||||
[
|
||||
userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", {
|
||||
id: "prt_comment",
|
||||
synthetic: true,
|
||||
metadata: {
|
||||
opencodeComment: {
|
||||
path: "src/a.ts",
|
||||
selection: { startLine: 4, startChar: 0, endLine: 8, endChar: 0 },
|
||||
comment: "Keep this stable",
|
||||
},
|
||||
},
|
||||
}),
|
||||
userText("Continue after the comment", { id: "prt_visible_user" }),
|
||||
],
|
||||
{ summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } },
|
||||
)
|
||||
const aborted = assistantMessage(
|
||||
[
|
||||
{ id: "prt_before_abort", type: "text", text: "Before interruption" },
|
||||
{ id: "prt_compaction", type: "compaction", auto: true },
|
||||
],
|
||||
{
|
||||
id: "msg_1001_assistant_aborted",
|
||||
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
|
||||
},
|
||||
)
|
||||
const failed = assistantMessage([{ id: "prt_after_abort", type: "text", text: "After interruption" }], {
|
||||
id: "msg_1002_assistant_failed",
|
||||
error: {
|
||||
name: "APIError",
|
||||
data: {
|
||||
message: JSON.stringify({ error: { type: "provider_error", message: "Visible provider failure" } }),
|
||||
isRetryable: false,
|
||||
},
|
||||
},
|
||||
created: 1700000003000,
|
||||
})
|
||||
const nextUser = userMessage([userText("Second turn", { id: "prt_second_user" })], {
|
||||
id: "msg_2000_second_user",
|
||||
created: 1700000005000,
|
||||
})
|
||||
const nextAssistant = assistantMessage([{ id: "prt_second_text", type: "text", text: "Second response" }], {
|
||||
id: "msg_2001_second_assistant",
|
||||
parentID: "msg_2000_second_user",
|
||||
created: 1700000006000,
|
||||
})
|
||||
const timeline = await setupTimeline(page, { messages: [firstUser, aborted, failed, nextUser, nextAssistant] })
|
||||
await timeline.send(status("idle"), 100)
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate((element) => (element.scrollTop = 0))
|
||||
|
||||
await expect(page.locator('[data-timeline-row="TurnDivider"]')).toHaveCount(1)
|
||||
await expect(page.getByText("Session compacted", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("Visible provider failure")).toBeVisible()
|
||||
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
||||
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test("renders comment strips and historical diff summary overflow", async ({ page }) => {
|
||||
const user = userMessage(
|
||||
[
|
||||
userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", {
|
||||
id: "prt_comment_only",
|
||||
synthetic: true,
|
||||
metadata: {
|
||||
opencodeComment: {
|
||||
path: "src/a.ts",
|
||||
selection: { startLine: 4, startChar: 0, endLine: 8, endChar: 0 },
|
||||
comment: "Keep this stable",
|
||||
},
|
||||
},
|
||||
}),
|
||||
userText("Continue after the comment", { id: "prt_comment_visible" }),
|
||||
],
|
||||
{ summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } },
|
||||
)
|
||||
const nextUser = userMessage(undefined, { id: "msg_2000_diff_next_user", created: 1700000010000 })
|
||||
const nextAssistant = assistantMessage([], {
|
||||
id: "msg_2001_diff_next_assistant",
|
||||
parentID: "msg_2000_diff_next_user",
|
||||
created: 1700000011000,
|
||||
})
|
||||
await setupTimeline(page, { messages: [user, assistantMessage(), nextUser, nextAssistant] })
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate((element) => (element.scrollTop = 0))
|
||||
|
||||
await expect(page.locator('[data-timeline-row="CommentStrip"]')).toBeVisible()
|
||||
await expect(page.getByText("Keep this stable", { exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
|
||||
await expect(page.getByText(/show all/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test("renders interruption independently when the turn is not compacted", async ({ page }) => {
|
||||
const user = userMessage()
|
||||
const before = assistantMessage([{ id: "prt_before", type: "text", text: "Before" }], {
|
||||
id: "msg_1001_before",
|
||||
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
|
||||
})
|
||||
const after = assistantMessage([{ id: "prt_after", type: "text", text: "After" }], {
|
||||
id: "msg_1002_after",
|
||||
created: 1700000003000,
|
||||
})
|
||||
await setupTimeline(page, { messages: [user, before, after] })
|
||||
|
||||
await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
|
||||
const rows = await page
|
||||
.locator('[data-timeline-row="AssistantPart"], [data-timeline-row="TurnDivider"]')
|
||||
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-timeline-row")))
|
||||
expect(rows).toEqual(["AssistantPart", "TurnDivider", "AssistantPart"])
|
||||
})
|
||||
|
||||
test("renders user image, file attachment, file reference, and agent reference", async ({ page }) => {
|
||||
const text = "Use @explore with @src/a.ts and inspect the attachments"
|
||||
const parts: PartSeed<"user">[] = [
|
||||
userText(text, { id: "prt_user_rich" }),
|
||||
{
|
||||
id: "prt_user_image",
|
||||
type: "file",
|
||||
mime: "image/png",
|
||||
filename: "pixel.png",
|
||||
url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||
},
|
||||
{
|
||||
id: "prt_user_attachment",
|
||||
type: "file",
|
||||
mime: "application/json",
|
||||
filename: "tsconfig.json",
|
||||
url: "data:application/json;base64,e30=",
|
||||
},
|
||||
{
|
||||
id: "prt_user_reference",
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
filename: "a.ts",
|
||||
url: "src/a.ts",
|
||||
source: { type: "file", path: "src/a.ts", text: { value: "@src/a.ts", start: 18, end: 27 } },
|
||||
},
|
||||
{
|
||||
id: "prt_user_agent",
|
||||
type: "agent",
|
||||
name: "explore",
|
||||
source: { value: "@explore", start: 4, end: 12 },
|
||||
},
|
||||
]
|
||||
await setupTimeline(page, { messages: [userMessage(parts), assistantMessage()] })
|
||||
|
||||
await expect(page.getByAltText("pixel.png")).toBeVisible()
|
||||
await expect(page.getByText("tsconfig.json")).toBeVisible()
|
||||
await expect(page.getByText("@src/a.ts", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("@explore", { exact: true })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
function editPart(id: string) {
|
||||
return toolPart(
|
||||
id,
|
||||
"edit",
|
||||
"completed",
|
||||
{ filePath: "src/a.ts" },
|
||||
{
|
||||
metadata: {
|
||||
filediff: {
|
||||
file: "src/a.ts",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
before: "export const value = 1\n",
|
||||
after: "export const value = 2\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function patchPart(id: string) {
|
||||
return toolPart(
|
||||
id,
|
||||
"apply_patch",
|
||||
"completed",
|
||||
{ files: ["src/a.ts", "src/b.ts"] },
|
||||
{
|
||||
metadata: {
|
||||
files: [
|
||||
patchFile("src/a.ts", "update"),
|
||||
patchFile("src/b.ts", "add"),
|
||||
patchFile("src/old.ts", "delete"),
|
||||
{ ...patchFile("src/moved.ts", "move"), move: "src/new-place.ts" },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function patchFile(filePath: string, type: "add" | "update" | "delete" | "move") {
|
||||
return {
|
||||
filePath,
|
||||
relativePath: filePath,
|
||||
type,
|
||||
additions: type === "delete" ? 0 : 1,
|
||||
deletions: type === "add" ? 0 : 1,
|
||||
before: type === "add" ? undefined : "export const before = true\n",
|
||||
after: type === "delete" ? undefined : "export const after = true\n",
|
||||
}
|
||||
}
|
||||
|
||||
function summaryDiff(index: number) {
|
||||
return {
|
||||
file: `src/diff-${index}.ts`,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
reasoningPart,
|
||||
setupTimeline,
|
||||
status,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
const profiles = [
|
||||
{ name: "summaries off no reasoning", summaries: false, reasoning: "", other: false, thinking: true, body: false },
|
||||
{
|
||||
name: "summaries off reasoning heading",
|
||||
summaries: false,
|
||||
reasoning: "## Inspecting stability",
|
||||
other: false,
|
||||
thinking: true,
|
||||
body: false,
|
||||
},
|
||||
{
|
||||
name: "summaries off with visible tool",
|
||||
summaries: false,
|
||||
reasoning: "## Inspecting stability",
|
||||
other: true,
|
||||
thinking: true,
|
||||
body: false,
|
||||
},
|
||||
{ name: "summaries on no content", summaries: true, reasoning: "", other: false, thinking: true, body: false },
|
||||
{
|
||||
name: "summaries on blank reasoning",
|
||||
summaries: true,
|
||||
reasoning: " ",
|
||||
other: false,
|
||||
thinking: true,
|
||||
body: false,
|
||||
},
|
||||
{
|
||||
name: "summaries on visible reasoning",
|
||||
summaries: true,
|
||||
reasoning: "## Inspecting stability",
|
||||
other: false,
|
||||
thinking: false,
|
||||
body: true,
|
||||
},
|
||||
{
|
||||
name: "summaries on visible tool no reasoning",
|
||||
summaries: true,
|
||||
reasoning: "",
|
||||
other: true,
|
||||
thinking: false,
|
||||
body: false,
|
||||
},
|
||||
] as const
|
||||
|
||||
for (const profile of profiles) {
|
||||
test(`projects busy reasoning profile ${profile.name}`, async ({ page }) => {
|
||||
const reasoningID = `prt_reasoning_matrix_${profiles.indexOf(profile)}`
|
||||
const parts = [
|
||||
...(profile.reasoning ? [reasoningPart(reasoningID, profile.reasoning)] : []),
|
||||
...(profile.other
|
||||
? [toolPart(`prt_reasoning_tool_${profiles.indexOf(profile)}`, "skill", "running", { name: "inspect" })]
|
||||
: []),
|
||||
]
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage(parts, { completed: false })],
|
||||
settings: { showReasoningSummaries: profile.summaries },
|
||||
})
|
||||
await timeline.send(status("busy"), 150)
|
||||
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(profile.body ? 1 : 0)
|
||||
if (!profile.summaries && profile.reasoning.trim()) {
|
||||
await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test("does not infer reasoning visibility from provider identity", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([textPart("prt_provider_text", "No reasoning payload")], { completed: false }),
|
||||
],
|
||||
settings: { showReasoningSummaries: true },
|
||||
})
|
||||
await timeline.send(status("busy"), 150)
|
||||
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-part-id="prt_provider_text"]')).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
completedAssistantInfo,
|
||||
messageUpdated,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
shell,
|
||||
status,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("groups singleton and separated context operations at correct boundaries", async ({ page }) => {
|
||||
const parts = [
|
||||
toolPart("prt_boundary_01_read", "read", "completed", { filePath: "src/a.ts" }),
|
||||
textPart("prt_boundary_02_text", "Boundary text"),
|
||||
toolPart("prt_boundary_03_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
|
||||
toolPart("prt_boundary_04_grep", "grep", "completed", { path: ".", pattern: "stable" }),
|
||||
shell("prt_boundary_05_shell", "completed", "done"),
|
||||
toolPart("prt_boundary_06_list", "list", "completed", { path: "src" }),
|
||||
]
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(5)
|
||||
})
|
||||
|
||||
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
|
||||
const textID = "prt_event_order_text"
|
||||
const assistant = assistantMessage([textPart(textID, "Partial")], { completed: false })
|
||||
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant] })
|
||||
await timeline.send(status("busy"), 100)
|
||||
await timeline.send(status("idle"), 100)
|
||||
await timeline.send(partUpdated(textPart(textID, "Final after early idle")), 120)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 250)
|
||||
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${textID}"]`)).toContainText("Final after early idle")
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("renders every tool error outcome without leaking hidden tools", async ({ page }) => {
|
||||
const ordinary = ["bash", "edit", "write", "apply_patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
|
||||
const parts = ordinary.map((tool, index) =>
|
||||
toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }),
|
||||
)
|
||||
parts.push(
|
||||
toolPart("prt_question_dismissed", "question", "error", questionInput(), {
|
||||
error: "The user dismissed this question",
|
||||
}),
|
||||
toolPart("prt_question_error", "question", "error", questionInput(), { error: "Question transport failed" }),
|
||||
toolPart("prt_todo_error", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }),
|
||||
)
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1)
|
||||
await expect(page.getByText(/dismissed/i)).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-id="prt_todo_error"]')).toHaveCount(0)
|
||||
for (let index = 0; index < ordinary.length; index++) {
|
||||
await expect(page.locator(`[data-timeline-part-id="prt_error_${index}"]`)).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test("transitions shell and question through running error outcomes", async ({ page }) => {
|
||||
const shellID = "prt_transition_error_shell"
|
||||
const questionID = "prt_transition_error_question"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
toolPart(shellID, "bash", "pending", { command: "exit 1" }),
|
||||
toolPart(questionID, "question", "pending", questionInput()),
|
||||
],
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
})
|
||||
await timeline.waitForPart(shellID)
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
||||
await timeline.send(partUpdated(toolPart(shellID, "bash", "running", { command: "exit 1" })), 120)
|
||||
await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())), 180)
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(shellID, "bash", "error", { command: "exit 1" }, { error: "Command exited 1" })),
|
||||
180,
|
||||
)
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(questionID, "question", "error", questionInput(), { error: "The user dismissed this question" }),
|
||||
),
|
||||
250,
|
||||
)
|
||||
|
||||
await expect(page.locator(`[data-timeline-part-id="${shellID}"] [data-kind="tool-error-card"]`)).toBeVisible()
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText(/dismissed/i)
|
||||
})
|
||||
|
||||
test("labels all web search provider variants", async ({ page }) => {
|
||||
const parts = [
|
||||
toolPart(
|
||||
"prt_search_parallel",
|
||||
"websearch",
|
||||
"completed",
|
||||
{ query: "parallel" },
|
||||
{ metadata: { provider: "parallel" } },
|
||||
),
|
||||
toolPart("prt_search_exa", "websearch", "completed", { query: "exa" }, { metadata: { provider: "exa" } }),
|
||||
toolPart("prt_search_generic", "websearch", "completed", { query: "generic" }),
|
||||
]
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
await expect(page.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: /^Web Search/ })).toBeVisible()
|
||||
})
|
||||
|
||||
function questionInput() {
|
||||
return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] }
|
||||
}
|
||||
|
||||
function errorInput(tool: string) {
|
||||
if (tool === "bash") return { command: "exit 1" }
|
||||
if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" }
|
||||
if (tool === "apply_patch") return { files: ["src/error.ts"] }
|
||||
if (tool === "webfetch") return { url: "https://example.com" }
|
||||
if (tool === "websearch") return { query: "failure" }
|
||||
if (tool === "task") return { description: "Fail task", subagent_type: "explore" }
|
||||
if (tool === "skill") return { name: "failure" }
|
||||
return { target: "failure" }
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("updates expanded web search links without resetting expansion", async ({ page }) => {
|
||||
const searchID = "prt_websearch_mutation"
|
||||
const input = { query: "timeline stability" }
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([toolPart(searchID, "websearch", "completed", input, { output: "https://example.com/one" })]),
|
||||
],
|
||||
})
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${searchID}"]`)
|
||||
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]')
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(searchID, "websearch", "completed", input, {
|
||||
output: "https://example.com/one\nhttps://example.com/two",
|
||||
}),
|
||||
),
|
||||
300,
|
||||
)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(wrapper.locator('a[href="https://example.com/two"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test("preserves an expanded tool error card across duplicate delivery", async ({ page }) => {
|
||||
const toolID = "prt_duplicate_error"
|
||||
const failed = toolPart(toolID, "bash", "error", { command: "exit 1" }, { error: "Command failed visibly" })
|
||||
const timeline = await setupTimeline(page, { messages: [userMessage(), assistantMessage([failed])] })
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${toolID}"]`)
|
||||
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]')
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await timeline.send(partUpdated(failed), 150)
|
||||
await timeline.send(partUpdated(failed), 250)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(wrapper).toContainText("Command failed visibly")
|
||||
})
|
||||
|
||||
test("renders multiple question answers and preserves open state on answer updates", async ({ page }) => {
|
||||
const questionID = "prt_multi_question"
|
||||
const input = {
|
||||
questions: [
|
||||
{ header: "First", question: "First choice?", options: [] },
|
||||
{ header: "Second", question: "Second choice?", options: [], multiple: true },
|
||||
],
|
||||
}
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(questionID, "question", "completed", input, { metadata: { answers: [["A"], ["B", "C"]] } }),
|
||||
]),
|
||||
],
|
||||
})
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${questionID}"]`)
|
||||
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]')
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(questionID, "question", "completed", input, { metadata: { answers: [["Updated"], ["B", "C"]] } }),
|
||||
),
|
||||
300,
|
||||
)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(wrapper).toContainText("Updated")
|
||||
await expect(wrapper).toContainText("B, C")
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
status,
|
||||
textPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("keeps one connection open while delivering multiple events", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page)
|
||||
|
||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_first", "first event")))
|
||||
const second = await timeline.transport.send(partUpdated(textPart("prt_transport_second", "second event")))
|
||||
|
||||
await timeline.waitForPart("prt_transport_first")
|
||||
await timeline.waitForPart("prt_transport_second")
|
||||
expect(first.connectionID).toBe(second.connectionID)
|
||||
expect(await timeline.transport.connections()).toHaveLength(1)
|
||||
expect(await timeline.transport.acknowledgements()).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("delivers a burst from one stream chunk", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page)
|
||||
const acknowledgements = await timeline.transport.burst([
|
||||
partUpdated(textPart("prt_transport_burst_a", "burst a")),
|
||||
partUpdated(textPart("prt_transport_burst_b", "burst b")),
|
||||
])
|
||||
|
||||
await timeline.waitForPart("prt_transport_burst_a")
|
||||
await timeline.waitForPart("prt_transport_burst_b")
|
||||
expect(acknowledgements.map((item) => item.chunkCount)).toEqual([1, 1])
|
||||
expect(new Set(acknowledgements.map((item) => item.deliveryID)).size).toBe(2)
|
||||
})
|
||||
|
||||
test("parses split JSON and a split multibyte code point", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page)
|
||||
const payload = partUpdated(textPart("prt_transport_split", "split snowman \u2603\u2603\u2603"))
|
||||
const encoded = new TextEncoder().encode(`data: ${JSON.stringify(payload)}\n\n`)
|
||||
const snowman = new TextEncoder().encode("\u2603")[0]!
|
||||
const multibyte = encoded.indexOf(snowman)
|
||||
|
||||
const acknowledgement = await timeline.transport.split(payload, [9, multibyte + 1, multibyte + 2])
|
||||
|
||||
await timeline.waitForPart("prt_transport_split")
|
||||
await expect(page.locator('[data-timeline-part-id="prt_transport_split"]')).toContainText(
|
||||
"split snowman \u2603\u2603\u2603",
|
||||
)
|
||||
expect(acknowledgement.chunkCount).toBe(4)
|
||||
})
|
||||
|
||||
test("delivers server heartbeat without mutating the timeline", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([textPart("prt_transport_steady", "steady")])],
|
||||
})
|
||||
const before = await page.locator("[data-timeline-row]").allTextContents()
|
||||
|
||||
await timeline.transport.heartbeat()
|
||||
await timeline.settle()
|
||||
|
||||
expect(await page.locator("[data-timeline-row]").allTextContents()).toEqual(before)
|
||||
expect(await timeline.transport.connections()).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("reconnects after a clean close", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
||||
const first = await timeline.transport.waitForConnection()
|
||||
|
||||
await timeline.transport.close()
|
||||
const second = await timeline.transport.waitForConnection({ after: first.id })
|
||||
await timeline.transport.send(partUpdated(textPart("prt_transport_close", "after close")))
|
||||
|
||||
await timeline.waitForPart("prt_transport_close")
|
||||
expect(second.id).toBeGreaterThan(first.id)
|
||||
expect((await timeline.transport.connections())[0]?.endedBy).toBe("close")
|
||||
})
|
||||
|
||||
test("reconnects after a stream error", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
||||
const first = await timeline.transport.waitForConnection()
|
||||
|
||||
await timeline.transport.error("contract failure")
|
||||
const second = await timeline.transport.waitForConnection({ after: first.id })
|
||||
await timeline.transport.send(status("busy"))
|
||||
|
||||
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(2)
|
||||
expect(second.id).toBeGreaterThan(first.id)
|
||||
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
|
||||
})
|
||||
|
||||
test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
||||
id: "timeline-event-7",
|
||||
})
|
||||
await timeline.waitForPart("prt_transport_id")
|
||||
|
||||
await timeline.transport.error("retry with event id")
|
||||
const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
|
||||
|
||||
expect(first.eventID).toBe("timeline-event-7")
|
||||
expect(connection.headers["last-event-id"]).toBe("timeline-event-7")
|
||||
})
|
||||
|
||||
test("passes through non-event fetches", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page)
|
||||
|
||||
const health = await page.evaluate(async () => {
|
||||
const response = await fetch("/global/health")
|
||||
return response.json()
|
||||
})
|
||||
|
||||
expect(health).toEqual({ healthy: true })
|
||||
expect(await timeline.transport.connections()).toHaveLength(1)
|
||||
})
|
||||
@@ -145,7 +145,7 @@ async function configurePage(page: Page) {
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:tabs",
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/SubagentNavigation"
|
||||
const projectID = "proj_subagent_navigation"
|
||||
const parentID = "ses_subagent_parent"
|
||||
const childID = "ses_subagent_child"
|
||||
const parentTitle = "Parent session"
|
||||
const childTitle = "Subagent child session"
|
||||
// Child session pages derive their heading from the task part that spawned them.
|
||||
const taskDescription = "Inspect child navigation"
|
||||
|
||||
type EventPayload = { directory: string; payload: Record<string, unknown> }
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test("navigates to a subagent child session missing from the session list", async ({ page }) => {
|
||||
await setup(page)
|
||||
await openChildFromParent(page)
|
||||
|
||||
await expectSessionTitle(page, taskDescription)
|
||||
await expect(page.getByRole("heading", { name: parentTitle })).toHaveCount(0)
|
||||
|
||||
const titlebarRight = page.locator("#opencode-titlebar-right")
|
||||
await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
|
||||
})
|
||||
|
||||
test("shows the not found fallback when the viewed session is deleted", async ({ page }) => {
|
||||
const events: EventPayload[] = []
|
||||
await setup(page, () => events.splice(0, 1))
|
||||
await openChildFromParent(page)
|
||||
await expectSessionTitle(page, taskDescription)
|
||||
|
||||
events.push({
|
||||
directory,
|
||||
payload: { type: "session.deleted", properties: { info: childSession() } },
|
||||
})
|
||||
|
||||
await expect(page.getByText("This session cannot be found")).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "Close Tab" })).toBeVisible()
|
||||
await expect(page.getByRole("heading", { name: taskDescription })).toHaveCount(0)
|
||||
})
|
||||
|
||||
async function setup(page: Page, events?: () => EventPayload[]) {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "subagent-navigation",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: {
|
||||
"claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } },
|
||||
},
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||
},
|
||||
sessions: [session(parentID, parentTitle, 1700000000000), childSession()],
|
||||
pageMessages: (sessionID) => ({ items: sessionID === parentID ? parentMessages() : [] }),
|
||||
events,
|
||||
eventRetry: events ? 16 : undefined,
|
||||
})
|
||||
// The child session resolves via /session/:id but is absent from the /session list,
|
||||
// matching a subagent session that has not been loaded into the list cache yet.
|
||||
await page.route(
|
||||
(url) => url.pathname === "/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify([session(parentID, parentTitle, 1700000000000)]),
|
||||
}),
|
||||
)
|
||||
await configurePage(page)
|
||||
}
|
||||
|
||||
async function openChildFromParent(page: Page) {
|
||||
await page.goto(sessionHref(parentID))
|
||||
await expectSessionTitle(page, parentTitle)
|
||||
|
||||
const card = page.locator(`a[href="${sessionHref(childID)}"]`)
|
||||
await expect(card).toBeVisible()
|
||||
await card.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/server/.+/session/${childID}$`), { timeout: 15_000 })
|
||||
}
|
||||
|
||||
function session(id: string, title: string, created: number, extra?: Record<string, unknown>) {
|
||||
return {
|
||||
id,
|
||||
slug: id,
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created, updated: created },
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
function childSession() {
|
||||
return session(childID, childTitle, 1700000001000, { parentID })
|
||||
}
|
||||
|
||||
function parentMessages() {
|
||||
const userID = "msg_user_0001"
|
||||
const assistantID = "msg_assistant_0001"
|
||||
return [
|
||||
{
|
||||
info: {
|
||||
id: userID,
|
||||
sessionID: parentID,
|
||||
role: "user",
|
||||
time: { created: 1700000000000 },
|
||||
agent: "build",
|
||||
model: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: "prt_user_text_0001",
|
||||
sessionID: parentID,
|
||||
messageID: userID,
|
||||
type: "text",
|
||||
text: "Delegate work to a subagent",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
info: {
|
||||
id: assistantID,
|
||||
sessionID: parentID,
|
||||
role: "assistant",
|
||||
time: { created: 1700000001000, completed: 1700000002000 },
|
||||
parentID: userID,
|
||||
modelID: "claude-opus-4-6",
|
||||
providerID: "opencode",
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: directory, root: directory },
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
finish: "stop",
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: "prt_tool_task_0001",
|
||||
sessionID: parentID,
|
||||
messageID: assistantID,
|
||||
type: "tool",
|
||||
callID: "call_task_0001",
|
||||
tool: "task",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { description: taskDescription, subagent_type: "explore" },
|
||||
output: "Subagent finished",
|
||||
title: taskDescription,
|
||||
metadata: { sessionId: childID },
|
||||
time: { start: 1700000001000, end: 1700000002000 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
async function configurePage(page: Page) {
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessionId }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify([{ type: "session", server, sessionId }]))
|
||||
},
|
||||
{ directory, server, sessionId: parentID },
|
||||
)
|
||||
}
|
||||
|
||||
function sessionHref(sessionID: string) {
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
|
||||
const server = "http://127.0.0.1:4096"
|
||||
const sessionA = session("ses_tab_a", "Tab A session")
|
||||
const sessionB = session("ses_tab_b", "Tab B session")
|
||||
|
||||
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
({ server, sessionA, sessionB }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "session", server, sessionId: sessionA },
|
||||
{ type: "session", server, sessionId: sessionB },
|
||||
]),
|
||||
)
|
||||
},
|
||||
{ server, sessionA: sessionA.id, sessionB: sessionB.id },
|
||||
)
|
||||
|
||||
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
|
||||
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
|
||||
await page.goto(hrefA)
|
||||
await expect(page.getByText(sessionA.title).first()).toBeVisible()
|
||||
|
||||
const linkB = page.locator(`a[data-titlebar-tab-link][href="${hrefB}"]`)
|
||||
await expect(linkB).toBeVisible()
|
||||
const box = await linkB.boundingBox()
|
||||
if (!box) throw new Error("tab link has no bounding box")
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
|
||||
await page.mouse.down()
|
||||
|
||||
// Navigation must happen on mousedown, before the button is released.
|
||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||
await page.mouse.up()
|
||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||
})
|
||||
|
||||
function session(id: string, title: string) {
|
||||
return {
|
||||
id,
|
||||
slug: id,
|
||||
projectID: "project-tabs",
|
||||
directory: "C:/tab-project",
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1, updated: 1 },
|
||||
}
|
||||
}
|
||||
|
||||
async function mockServer(page: Page) {
|
||||
const sessions = [sessionA, sessionB]
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.origin !== server) return route.fallback()
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||
if (url.pathname === "/session") return json(route, sessions)
|
||||
const byId = sessions.find((item) => url.pathname === `/session/${item.id}`)
|
||||
if (byId) return json(route, byId)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
|
||||
return json(route, {})
|
||||
if (url.pathname === "/provider")
|
||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||
if (url.pathname === "/project" || url.pathname === "/project/current") {
|
||||
const project = {
|
||||
id: sessionA.projectID,
|
||||
worktree: sessionA.directory,
|
||||
vcs: "git",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
}
|
||||
return json(route, url.pathname === "/project" ? [project] : project)
|
||||
}
|
||||
if (url.pathname === "/path")
|
||||
return json(route, {
|
||||
state: sessionA.directory,
|
||||
config: sessionA.directory,
|
||||
worktree: sessionA.directory,
|
||||
directory: sessionA.directory,
|
||||
home: sessionA.directory,
|
||||
})
|
||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||
return json(route, {})
|
||||
})
|
||||
}
|
||||
|
||||
function json(route: Route, body: unknown, status = 200) {
|
||||
return route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
function sse(route: Route) {
|
||||
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/TerminalTabSwitch"
|
||||
const projectID = "proj_terminal_tab_switch"
|
||||
const sessionA = "ses_terminal_tab_a"
|
||||
const sessionB = "ses_terminal_tab_b"
|
||||
const titleA = "Alpha session"
|
||||
const titleB = "Beta session"
|
||||
const ptyID = "pty_tab_switch"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
// Marks the terminal DOM node so a remount (fresh node) is detectable.
|
||||
const PROBE = "original"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
// Terminals are workspace-scoped: switching between session tabs in the same
|
||||
// workspace must keep the terminal mounted and its PTY connection open instead
|
||||
// of tearing it down and reconnecting.
|
||||
test("keeps the terminal session alive when switching session tabs in a workspace", async ({ page }) => {
|
||||
const connections = await setup(page)
|
||||
|
||||
await page.goto(sessionHref(sessionA))
|
||||
await expectSessionTitle(page, titleA)
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
await expect(terminal).toBeVisible()
|
||||
await expect.poll(() => connections.length).toBe(1)
|
||||
await writeProbe(page)
|
||||
|
||||
await switchTab(page, titleB)
|
||||
await expectSessionTitle(page, titleB)
|
||||
await expect(terminal).toBeVisible()
|
||||
expect(await readProbe(page)).toBe(PROBE)
|
||||
expect(connections.length).toBe(1)
|
||||
|
||||
await switchTab(page, titleA)
|
||||
await expectSessionTitle(page, titleA)
|
||||
await expect(terminal).toBeVisible()
|
||||
expect(await readProbe(page)).toBe(PROBE)
|
||||
expect(connections.length).toBe(1)
|
||||
})
|
||||
|
||||
type Probed = HTMLElement & { __e2eProbe?: string }
|
||||
|
||||
async function switchTab(page: Page, title: string) {
|
||||
await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click()
|
||||
}
|
||||
|
||||
async function writeProbe(page: Page) {
|
||||
await page.locator('[data-component="terminal"]').evaluate((el, probe) => {
|
||||
;(el as Probed).__e2eProbe = probe
|
||||
}, PROBE)
|
||||
}
|
||||
|
||||
async function readProbe(page: Page) {
|
||||
return page.locator('[data-component="terminal"]').evaluate((el) => (el as Probed).__e2eProbe)
|
||||
}
|
||||
|
||||
async function setup(page: Page) {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "terminal-tab-switch",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route("**/pty", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
|
||||
}),
|
||||
)
|
||||
await page.route(`**/pty/${ptyID}`, (route) =>
|
||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
||||
)
|
||||
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify({ ticket: "e2e-ticket" }),
|
||||
}),
|
||||
)
|
||||
const connections: string[] = []
|
||||
await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), (ws) => {
|
||||
connections.push(ws.url())
|
||||
})
|
||||
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessions }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
|
||||
)
|
||||
},
|
||||
{ directory, server, sessions: [sessionA, sessionB] },
|
||||
)
|
||||
return connections
|
||||
}
|
||||
|
||||
function session(id: string, title: string, created: number) {
|
||||
return {
|
||||
id,
|
||||
slug: id,
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created, updated: created },
|
||||
}
|
||||
}
|
||||
|
||||
function sessionHref(sessionID: string) {
|
||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||
}
|
||||
@@ -127,7 +127,7 @@ test.describe("smoke: session timeline", () => {
|
||||
await page.addInitScript(
|
||||
({ dirBase64, sourceID, targetID }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:tabs",
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(
|
||||
[sourceID, targetID].map((sessionId) => ({
|
||||
type: "session",
|
||||
@@ -253,7 +253,7 @@ test.describe("smoke: session timeline", () => {
|
||||
await page.addInitScript(
|
||||
({ dirBase64, sourceID, targetID }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:tabs",
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(
|
||||
[sourceID, targetID].map((sessionId) => ({
|
||||
type: "session",
|
||||
|
||||
@@ -5,5 +5,12 @@
|
||||
"rootDir": "..",
|
||||
"types": ["node", "bun"]
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
"include": [
|
||||
"./performance/timeline-stability/**/*.spec.ts",
|
||||
"./performance/timeline-stability/fixture.test.ts",
|
||||
"./performance/timeline-stability/fixture.ts",
|
||||
"./performance/unit/visual-stability.test.ts",
|
||||
"./regression/session-timeline-context-resize.spec.ts",
|
||||
"./utils/**/*.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Page, Route } from "@playwright/test"
|
||||
|
||||
const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
|
||||
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"])
|
||||
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp"])
|
||||
|
||||
export interface MockServerConfig {
|
||||
provider: unknown
|
||||
@@ -17,6 +17,9 @@ export interface MockServerConfig {
|
||||
todos?: (sessionID: string) => unknown[]
|
||||
permissions?: unknown[] | (() => unknown[])
|
||||
questions?: unknown[] | (() => unknown[])
|
||||
fileList?: (path: string) => unknown | Promise<unknown>
|
||||
fileContent?: (path: string) => unknown | Promise<unknown>
|
||||
sessionStatus?: unknown
|
||||
}
|
||||
|
||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
@@ -53,7 +56,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
||||
if (path === "/question")
|
||||
return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
|
||||
if (path === "/session/status") return json(route, config.sessionStatus ?? {})
|
||||
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
|
||||
if (path === "/file" && config.fileList)
|
||||
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
|
||||
if (path === "/file/content" && config.fileContent)
|
||||
return json(route, await config.fileContent(url.searchParams.get("path") ?? ""))
|
||||
if (emptyObject.has(path)) return json(route, {})
|
||||
if (emptyList.has(path)) return json(route, [])
|
||||
if (path in staticRoutes) return json(route, staticRoutes[path])
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
|
||||
export type SseConnectionRecord = {
|
||||
id: number
|
||||
url: string
|
||||
path: "/global/event" | "/event"
|
||||
headers: Record<string, string>
|
||||
openedAt: number
|
||||
endedAt?: number
|
||||
endedBy?: "close" | "disconnect" | "error" | "abort"
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type SseDeliveryAcknowledgement = {
|
||||
deliveryID: number
|
||||
connectionID: number
|
||||
bytes: number
|
||||
chunkCount: number
|
||||
deliveredAt: number
|
||||
eventID?: string
|
||||
}
|
||||
|
||||
export type SseEventOptions = {
|
||||
id?: string
|
||||
event?: string
|
||||
retry?: number
|
||||
marker?: string
|
||||
}
|
||||
|
||||
export type SseTransport<T> = {
|
||||
server: string
|
||||
waitForConnection(options?: { after?: number; timeout?: number }): Promise<SseConnectionRecord>
|
||||
send(payload: T, options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
||||
burst(payloads: readonly T[], options?: readonly SseEventOptions[]): Promise<SseDeliveryAcknowledgement[]>
|
||||
split(payload: T, cuts: readonly number[], options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
||||
heartbeat(options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
||||
writeRaw(value: string | Uint8Array, cuts?: readonly number[], marker?: string): Promise<SseDeliveryAcknowledgement>
|
||||
close(): Promise<void>
|
||||
disconnect(message?: string): Promise<void>
|
||||
error(message?: string): Promise<void>
|
||||
connections(): Promise<SseConnectionRecord[]>
|
||||
acknowledgements(): Promise<SseDeliveryAcknowledgement[]>
|
||||
}
|
||||
|
||||
type BrowserCommand<T> =
|
||||
| { type: "send"; deliveries: { payload: T; options?: SseEventOptions }[]; burst: boolean; cuts?: number[] }
|
||||
| { type: "raw"; bytes: number[]; cuts?: number[]; marker?: string }
|
||||
| { type: "end"; mode: "close" | "disconnect" | "error"; message?: string }
|
||||
| { type: "connections" }
|
||||
| { type: "acknowledgements" }
|
||||
|
||||
type BrowserTransport = Window & {
|
||||
__testSseTransport?: {
|
||||
command: (command: BrowserCommand<unknown>) => unknown
|
||||
}
|
||||
}
|
||||
|
||||
export async function installSseTransport<T>(
|
||||
page: Page,
|
||||
options: { server: string; retry?: number },
|
||||
): Promise<SseTransport<T>> {
|
||||
const server = new URL(options.server).origin
|
||||
await page.addInitScript(
|
||||
({ server, retry }) => {
|
||||
type Connection = SseConnectionRecord & { controller: ReadableStreamDefaultController<Uint8Array> }
|
||||
type ProbeWindow = Window & {
|
||||
__visualStabilityProbe?: { startedAt: number; markers: { at: number; label: string }[] }
|
||||
}
|
||||
const originalFetch = window.fetch.bind(window)
|
||||
const connections: Connection[] = []
|
||||
const acknowledgements: SseDeliveryAcknowledgement[] = []
|
||||
const encoder = new TextEncoder()
|
||||
let nextConnectionID = 0
|
||||
let nextDeliveryID = 0
|
||||
|
||||
const current = () => connections.findLast((connection) => connection.endedAt === undefined)
|
||||
const chunks = (bytes: Uint8Array, cuts?: readonly number[]) => {
|
||||
const boundaries = [...new Set(cuts ?? [])]
|
||||
.filter((cut) => Number.isInteger(cut) && cut > 0 && cut < bytes.byteLength)
|
||||
.sort((a, b) => a - b)
|
||||
return [0, ...boundaries].map((start, index) => bytes.slice(start, boundaries[index] ?? bytes.byteLength))
|
||||
}
|
||||
const marker = (label?: string) => {
|
||||
if (!label) return
|
||||
const probe = (window as ProbeWindow).__visualStabilityProbe
|
||||
if (!probe) return
|
||||
probe.markers.push({ at: performance.now() - probe.startedAt, label })
|
||||
}
|
||||
const frame = (payload: unknown, eventOptions: SseEventOptions = {}) =>
|
||||
[
|
||||
eventOptions.event === undefined ? "" : `event: ${eventOptions.event}\n`,
|
||||
eventOptions.id === undefined ? "" : `id: ${eventOptions.id}\n`,
|
||||
eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
|
||||
`data: ${JSON.stringify(payload)}\n\n`,
|
||||
].join("")
|
||||
const acknowledge = (
|
||||
connection: Connection,
|
||||
bytes: number,
|
||||
chunkCount: number,
|
||||
eventID?: string,
|
||||
): SseDeliveryAcknowledgement => {
|
||||
const acknowledgement = {
|
||||
deliveryID: ++nextDeliveryID,
|
||||
connectionID: connection.id,
|
||||
bytes,
|
||||
chunkCount,
|
||||
deliveredAt: performance.now(),
|
||||
...(eventID === undefined ? {} : { eventID }),
|
||||
}
|
||||
acknowledgements.push(acknowledgement)
|
||||
return acknowledgement
|
||||
}
|
||||
const end = (mode: "close" | "disconnect" | "error", message?: string) => {
|
||||
const connection = current()
|
||||
if (!connection) throw new Error("SSE transport has no active connection")
|
||||
connection.endedAt = performance.now()
|
||||
connection.endedBy = mode
|
||||
if (message) connection.error = message
|
||||
if (mode === "close") {
|
||||
connection.controller.close()
|
||||
return
|
||||
}
|
||||
const error = new DOMException(
|
||||
message ?? "SSE connection disconnected",
|
||||
mode === "error" ? "Error" : "NetworkError",
|
||||
)
|
||||
connection.controller.error(error)
|
||||
}
|
||||
|
||||
const command = (input: BrowserCommand<unknown>) => {
|
||||
if (input.type === "connections")
|
||||
return connections.map(({ controller: _controller, ...connection }) => connection)
|
||||
if (input.type === "acknowledgements") return acknowledgements
|
||||
if (input.type === "end") return end(input.mode, input.message)
|
||||
const connection = current()
|
||||
if (!connection) throw new Error("SSE transport has no active connection")
|
||||
if (input.type === "raw") {
|
||||
marker(input.marker)
|
||||
const output = chunks(new Uint8Array(input.bytes), input.cuts)
|
||||
output.forEach((chunk) => connection.controller.enqueue(chunk))
|
||||
return acknowledge(connection, input.bytes.length, output.length)
|
||||
}
|
||||
const encoded = input.deliveries.map((delivery) => ({
|
||||
delivery,
|
||||
bytes: encoder.encode(frame(delivery.payload, delivery.options)),
|
||||
}))
|
||||
encoded.forEach((item) => marker(item.delivery.options?.marker))
|
||||
if (input.burst) {
|
||||
const bytes = encoder.encode(
|
||||
encoded.map((item) => frame(item.delivery.payload, item.delivery.options)).join(""),
|
||||
)
|
||||
connection.controller.enqueue(bytes)
|
||||
return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
|
||||
}
|
||||
const output = chunks(encoded[0]!.bytes, input.cuts)
|
||||
output.forEach((chunk) => connection.controller.enqueue(chunk))
|
||||
return acknowledge(connection, encoded[0]!.bytes.byteLength, output.length, encoded[0]!.delivery.options?.id)
|
||||
}
|
||||
|
||||
;(window as BrowserTransport).__testSseTransport = { command }
|
||||
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init)
|
||||
const url = new URL(request.url)
|
||||
if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
|
||||
return originalFetch(input, init)
|
||||
|
||||
const id = ++nextConnectionID
|
||||
const record = {
|
||||
id,
|
||||
url: url.href,
|
||||
path: url.pathname,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
openedAt: performance.now(),
|
||||
} as Connection
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
record.controller = controller
|
||||
connections.push(record)
|
||||
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
|
||||
request.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
if (record.endedAt !== undefined) return
|
||||
record.endedAt = performance.now()
|
||||
record.endedBy = "abort"
|
||||
controller.error(request.signal.reason ?? new DOMException("The operation was aborted", "AbortError"))
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
},
|
||||
cancel() {
|
||||
if (record.endedAt !== undefined) return
|
||||
record.endedAt = performance.now()
|
||||
record.endedBy = "disconnect"
|
||||
},
|
||||
})
|
||||
return Promise.resolve(
|
||||
new Response(stream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"cache-control": "no-cache",
|
||||
"content-type": "text/event-stream",
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
Object.defineProperty(window, "fetch", { configurable: true, writable: true, value: fetch })
|
||||
},
|
||||
{ server, retry: options.retry },
|
||||
)
|
||||
|
||||
const command = <Result>(input: BrowserCommand<T>) =>
|
||||
page.evaluate((input) => {
|
||||
const transport = (window as BrowserTransport).__testSseTransport
|
||||
if (!transport) throw new Error("SSE transport was not installed before page load")
|
||||
return transport.command(input as BrowserCommand<unknown>)
|
||||
}, input) as Promise<Result>
|
||||
|
||||
return {
|
||||
server,
|
||||
async waitForConnection(input = {}) {
|
||||
await page.waitForFunction(
|
||||
(after) => {
|
||||
const transport = (window as BrowserTransport).__testSseTransport
|
||||
const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined
|
||||
return connections?.some((connection) => connection.id > after)
|
||||
},
|
||||
input.after ?? 0,
|
||||
{ timeout: input.timeout },
|
||||
)
|
||||
return (await command<SseConnectionRecord[]>({ type: "connections" })).findLast(
|
||||
(connection) => connection.id > (input.after ?? 0),
|
||||
)!
|
||||
},
|
||||
send(payload, eventOptions) {
|
||||
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false })
|
||||
},
|
||||
burst(payloads, eventOptions = []) {
|
||||
return command({
|
||||
type: "send",
|
||||
deliveries: payloads.map((payload, index) => ({ payload, options: eventOptions[index] })),
|
||||
burst: true,
|
||||
})
|
||||
},
|
||||
split(payload, cuts, eventOptions) {
|
||||
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false, cuts: [...cuts] })
|
||||
},
|
||||
heartbeat(eventOptions) {
|
||||
return command({
|
||||
type: "send",
|
||||
deliveries: [
|
||||
{
|
||||
payload: { directory: "global", payload: { type: "server.heartbeat", properties: {} } } as T,
|
||||
options: eventOptions,
|
||||
},
|
||||
],
|
||||
burst: false,
|
||||
})
|
||||
},
|
||||
writeRaw(value, cuts, marker) {
|
||||
return command({
|
||||
type: "raw",
|
||||
bytes: Array.from(typeof value === "string" ? new TextEncoder().encode(value) : value),
|
||||
cuts: cuts ? [...cuts] : undefined,
|
||||
marker,
|
||||
})
|
||||
},
|
||||
close() {
|
||||
return command({ type: "end", mode: "close" })
|
||||
},
|
||||
disconnect(message) {
|
||||
return command({ type: "end", mode: "disconnect", message })
|
||||
},
|
||||
error(message) {
|
||||
return command({ type: "end", mode: "error", message })
|
||||
},
|
||||
connections() {
|
||||
return command({ type: "connections" })
|
||||
},
|
||||
acknowledgements() {
|
||||
return command({ type: "acknowledgements" })
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Page, TestInfo } from "@playwright/test"
|
||||
import { analyzeVisualObservations, analyzeVisualTraceByMarker } from "./visual-stability/analyzer"
|
||||
import { legacyVisualPlan, type LegacyVisualStabilityOptions } from "./visual-stability/invariant"
|
||||
import type { CapturedFrame, VisualStabilityTrace } from "./visual-stability/model"
|
||||
import { markVisualProbe, startVisualProbe, stopVisualProbe } from "./visual-stability/probe"
|
||||
import type { VisualRegionDefinition } from "./visual-stability/regions"
|
||||
import { reportVisualStability } from "./visual-stability/reporter"
|
||||
|
||||
export * from "./visual-stability/index"
|
||||
|
||||
const capturedFrames = Symbol("capturedFrames")
|
||||
|
||||
export async function startVisualStabilityProbe(page: Page, regions: Record<string, VisualRegionDefinition>) {
|
||||
await startVisualProbe(page, regions)
|
||||
}
|
||||
|
||||
export async function stopVisualStabilityProbe(page: Page) {
|
||||
const result = await stopVisualProbe(page)
|
||||
const trace: VisualStabilityTrace = { markers: result.markers, samples: result.samples }
|
||||
Object.defineProperty(trace, capturedFrames, { value: result.frames })
|
||||
return trace
|
||||
}
|
||||
|
||||
export async function markVisualStability(page: Page, label: string) {
|
||||
await markVisualProbe(page, label)
|
||||
}
|
||||
|
||||
export function analyzeVisualStability(trace: VisualStabilityTrace, options: LegacyVisualStabilityOptions = {}) {
|
||||
return analyzeVisualObservations(trace.samples, legacyVisualPlan(options))
|
||||
}
|
||||
|
||||
export function analyzeVisualStabilityByMarker(
|
||||
trace: VisualStabilityTrace,
|
||||
options: LegacyVisualStabilityOptions = {},
|
||||
) {
|
||||
return analyzeVisualTraceByMarker(trace, legacyVisualPlan(options))
|
||||
}
|
||||
|
||||
export async function expectVisualStability(
|
||||
testInfo: TestInfo,
|
||||
name: string,
|
||||
trace: VisualStabilityTrace,
|
||||
options: LegacyVisualStabilityOptions = {},
|
||||
) {
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
name,
|
||||
{
|
||||
...trace,
|
||||
frames: (trace as VisualStabilityTrace & { [capturedFrames]?: CapturedFrame[] })[capturedFrames] ?? [],
|
||||
},
|
||||
legacyVisualPlan(options),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { VisualInvariant, VisualPlan } from "./invariant"
|
||||
import type { VisualObservation, VisualStabilityTrace } from "./model"
|
||||
|
||||
export function analyzeVisualObservations<RegionName extends string>(
|
||||
observations: readonly VisualObservation<RegionName>[],
|
||||
plan: VisualPlan<RegionName>,
|
||||
) {
|
||||
const issues: string[] = []
|
||||
const invariants = plan.invariants
|
||||
const names = [...new Set(observations.flatMap((sample) => Object.keys(sample.regions) as RegionName[]))]
|
||||
const required = regions(invariants, "required")
|
||||
const continuousAny = invariants.filter(
|
||||
(invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "continuous-any" }> =>
|
||||
invariant.type === "continuous-any",
|
||||
)
|
||||
const unique = new Set(regions(invariants, "unique"))
|
||||
const stable = new Set(regions(invariants, "stable"))
|
||||
const fixed = invariants.filter(
|
||||
(invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "fixed" }> => invariant.type === "fixed",
|
||||
)
|
||||
const opacity = invariants.filter(
|
||||
(invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "opacity" }> => invariant.type === "opacity",
|
||||
)
|
||||
const continuity = invariants.filter(
|
||||
(invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "continuity" }> =>
|
||||
invariant.type === "continuity",
|
||||
)
|
||||
const motion = invariants.filter(
|
||||
(invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "motion" }> => invariant.type === "motion",
|
||||
)
|
||||
const labelStability = invariants.filter(
|
||||
(invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "label-stability" }> =>
|
||||
invariant.type === "label-stability",
|
||||
)
|
||||
|
||||
for (const name of new Set(required)) {
|
||||
if (!observations.some((sample) => sample.regions[name]?.visible)) issues.push(`${name} never rendered`)
|
||||
}
|
||||
for (const invariant of continuousAny) {
|
||||
if (!invariant.regions.some((name) => observations.some((sample) => sample.regions[name]?.visible)))
|
||||
issues.push(`${invariant.regions.join(" | ")} never rendered`)
|
||||
}
|
||||
|
||||
for (const name of names) {
|
||||
const samples = observations.flatMap((observation) => {
|
||||
const region = observation.regions[name]
|
||||
if (!region) return []
|
||||
const clipped =
|
||||
observation.viewport && (region.bottom <= observation.viewport.top || region.top >= observation.viewport.bottom)
|
||||
return [{ at: observation.at, ...region, visible: region.visible && !clipped }]
|
||||
})
|
||||
const visible = samples.filter((sample) => sample.visible)
|
||||
if (visible.length === 0) continue
|
||||
if (unique.has(name)) {
|
||||
const duplicate = samples.find((sample) => sample.count > 1)
|
||||
if (duplicate) issues.push(`${name} appeared ${duplicate.count} times at ${Math.round(duplicate.at)}ms`)
|
||||
}
|
||||
if (stable.has(name)) {
|
||||
const identities = [...new Set(visible.map((sample) => sample.node).filter((node) => node > 0))]
|
||||
if (identities.length > 1) issues.push(`${name} remounted ${identities.length - 1} times`)
|
||||
}
|
||||
for (const invariant of fixed.filter((invariant) => includes(invariant.regions, name))) {
|
||||
const origin = visible[0]
|
||||
const movement = origin ? Math.max(0, ...visible.map((sample) => Math.abs(sample.top - origin.top))) : 0
|
||||
if (movement > (invariant.tolerance ?? 1))
|
||||
issues.push(`${name} moved ${Math.round(movement * 10) / 10}px in the viewport`)
|
||||
}
|
||||
for (const invariant of opacity.filter((invariant) => includes(invariant.regions, name))) {
|
||||
for (const sample of visible) {
|
||||
if (sample.opacity < (invariant.floor ?? 0.65))
|
||||
issues.push(`${name} opacity fell to ${sample.opacity} at ${Math.round(sample.at)}ms`)
|
||||
}
|
||||
}
|
||||
if (continuity.some((invariant) => includes(invariant.regions, name))) {
|
||||
const firstPresent = samples.findIndex((sample) => sample.present)
|
||||
const lastPresent = samples.findLastIndex((sample) => sample.present)
|
||||
if (samples.slice(firstPresent, lastPresent + 1).some((sample) => !sample.present))
|
||||
issues.push(`${name} disappeared between present frames`)
|
||||
const firstVisible = samples.findIndex((sample) => sample.visible)
|
||||
const lastVisible = samples.findLastIndex((sample) => sample.visible)
|
||||
if (
|
||||
firstVisible >= 0 &&
|
||||
samples.slice(firstVisible, lastVisible + 1).some((sample) => !sample.visible && sample.inViewport)
|
||||
)
|
||||
issues.push(`${name} blanked between visible frames`)
|
||||
}
|
||||
for (const invariant of motion.filter((invariant) => includes(invariant.regions, name))) {
|
||||
for (const metric of ["top", "bottom", "width", "height"] as const) {
|
||||
const directions = visible
|
||||
.slice(1)
|
||||
.map((sample, index) => sample[metric] - visible[index]![metric])
|
||||
.filter((delta) => Math.abs(delta) > (invariant.tolerance ?? 1))
|
||||
.map(Math.sign)
|
||||
const reversals = directions.slice(1).filter((direction, index) => direction !== directions[index]).length
|
||||
const allowed =
|
||||
metric === "top" || metric === "bottom"
|
||||
? (invariant.maxPositionReversals ?? invariant.maxReversals ?? 1)
|
||||
: (invariant.maxReversals ?? 1)
|
||||
if (reversals > allowed) issues.push(`${name} ${metric} reversed ${reversals} times`)
|
||||
}
|
||||
}
|
||||
if (labelStability.some((invariant) => includes(invariant.regions, name))) {
|
||||
const labels = samples
|
||||
.map((sample) => sample.label)
|
||||
.filter((label) => label.length > 0)
|
||||
.filter((label, index, all) => label !== all[index - 1])
|
||||
if (labels.some((label, index) => labels.indexOf(label) !== index))
|
||||
issues.push(`${name} label reverted: ${labels.join(" -> ")}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (invariants.some((invariant) => invariant.type === "preserve-bottom-anchor")) {
|
||||
const viewports = observations.flatMap((sample) => (sample.viewport ? [sample.viewport] : []))
|
||||
if (viewports[0] && viewports[0].distanceFromBottom <= 4) {
|
||||
const lost = viewports.find((viewport) => viewport.distanceFromBottom > 4)
|
||||
if (lost) issues.push(`bottom anchor moved to ${lost.distanceFromBottom}px`)
|
||||
}
|
||||
}
|
||||
if (invariants.some((invariant) => invariant.type === "acquire-bottom-anchor")) {
|
||||
const final = observations.findLast((sample) => sample.viewport)?.viewport
|
||||
if (!final || final.distanceFromBottom > 4)
|
||||
issues.push(`did not acquire bottom anchor${final ? ` (${final.distanceFromBottom}px away)` : ""}`)
|
||||
}
|
||||
|
||||
for (const invariant of continuousAny) {
|
||||
const active = observations.map((sample) => invariant.regions.some((name) => sample.regions[name]?.visible))
|
||||
const first = active.indexOf(true)
|
||||
const last = active.lastIndexOf(true)
|
||||
if (first >= 0 && active.slice(first, last + 1).some((value) => !value))
|
||||
issues.push(`${invariant.regions.join(" | ")} blanked between visible frames`)
|
||||
}
|
||||
|
||||
for (const invariant of invariants.filter(
|
||||
(item): item is Extract<VisualInvariant<RegionName>, { type: "flow" }> => item.type === "flow",
|
||||
)) {
|
||||
for (const [before, after] of invariant.regions
|
||||
.slice(1)
|
||||
.map((after, index) => [invariant.regions[index]!, after])) {
|
||||
let maximum: { overlap: number; at: number } | undefined
|
||||
let inverted: { at: number } | undefined
|
||||
for (const sample of observations) {
|
||||
const first = sample.regions[before]
|
||||
const second = sample.regions[after]
|
||||
if (!first?.visible || !second?.visible) continue
|
||||
if (
|
||||
sample.viewport &&
|
||||
(first.bottom <= sample.viewport.top ||
|
||||
first.top >= sample.viewport.bottom ||
|
||||
second.bottom <= sample.viewport.top ||
|
||||
second.top >= sample.viewport.bottom)
|
||||
)
|
||||
continue
|
||||
const overlap = first.bottom - second.top
|
||||
if (first.top > second.top && !inverted) inverted = { at: sample.at }
|
||||
if (overlap > (invariant.overlapTolerance ?? 0.5) && (!maximum || overlap > maximum.overlap))
|
||||
maximum = { overlap, at: sample.at }
|
||||
}
|
||||
if (inverted) issues.push(`${before} rendered after ${after} at ${Math.round(inverted.at)}ms`)
|
||||
if (maximum)
|
||||
issues.push(
|
||||
`${before} overlapped ${after} by ${Math.round(maximum.overlap * 10) / 10}px at ${Math.round(maximum.at)}ms`,
|
||||
)
|
||||
}
|
||||
}
|
||||
return [...new Set(issues)]
|
||||
}
|
||||
|
||||
export function analyzeVisualTraceByMarker<RegionName extends string>(
|
||||
trace: VisualStabilityTrace<RegionName>,
|
||||
plan: VisualPlan<RegionName>,
|
||||
) {
|
||||
if (trace.markers.length === 0) return analyzeVisualObservations(trace.samples, plan)
|
||||
const required = [...new Set(plan.markerRequired ?? regions(plan.invariants, "required"))].flatMap((name) =>
|
||||
trace.samples.some((sample) => sample.regions[name]?.visible) ? [] : [`${name} never rendered`],
|
||||
)
|
||||
const withoutRequired = plan.invariants.filter((invariant) => invariant.type !== "required")
|
||||
const windows = trace.markers.flatMap((marker, index) => {
|
||||
const end = trace.markers[index + 1]?.at ?? Infinity
|
||||
const before = trace.samples.findLast((sample) => sample.at < marker.at)
|
||||
const samples = [
|
||||
...(before ? [before] : []),
|
||||
...trace.samples.filter((sample) => sample.at >= marker.at && sample.at < end),
|
||||
]
|
||||
if (samples.length < 2) return []
|
||||
return analyzeVisualObservations(samples, { ...plan, perMarker: false, invariants: withoutRequired }).map(
|
||||
(issue) => `${marker.label}: ${issue}`,
|
||||
)
|
||||
})
|
||||
const aggregateMotion =
|
||||
plan.aggregateMotion === false
|
||||
? []
|
||||
: analyzeVisualObservations(trace.samples, {
|
||||
invariants: plan.invariants.filter((invariant) => invariant.type === "motion"),
|
||||
}).filter((issue) => / (?:top|bottom|width|height) reversed \d+ times$/.test(issue))
|
||||
return [...new Set([...required, ...aggregateMotion, ...windows])]
|
||||
}
|
||||
|
||||
function regions<RegionName extends string, Type extends VisualInvariant<RegionName>["type"]>(
|
||||
invariants: readonly VisualInvariant<RegionName>[],
|
||||
type: Type,
|
||||
) {
|
||||
return invariants.flatMap((invariant) =>
|
||||
invariant.type === type && "regions" in invariant && invariant.regions !== "all" ? [...invariant.regions] : [],
|
||||
) as RegionName[]
|
||||
}
|
||||
|
||||
function includes<RegionName extends string>(regions: readonly RegionName[] | "all", name: RegionName) {
|
||||
return regions === "all" || regions.includes(name)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { CDPSession, Page } from "@playwright/test"
|
||||
import type { CapturedFrame } from "./model"
|
||||
|
||||
export type VisualCapture = {
|
||||
session: CDPSession
|
||||
frames: CapturedFrame[]
|
||||
startedAtEpoch: number
|
||||
running: boolean
|
||||
capture: Promise<void>
|
||||
}
|
||||
|
||||
export async function startVisualCapture(page: Page, startedAtEpoch: number) {
|
||||
if (process.env.OPENCODE_STABILITY_CAPTURE !== "1") return
|
||||
const session = await page.context().newCDPSession(page)
|
||||
await session.send("Page.enable")
|
||||
const recording: VisualCapture = {
|
||||
session,
|
||||
frames: [],
|
||||
startedAtEpoch,
|
||||
running: true,
|
||||
capture: Promise.resolve(),
|
||||
}
|
||||
recording.capture = (async () => {
|
||||
try {
|
||||
while (recording.running && recording.frames.length < 900) {
|
||||
const frame = await session.send("Page.captureScreenshot", {
|
||||
format: "jpeg",
|
||||
quality: 80,
|
||||
captureBeyondViewport: false,
|
||||
optimizeForSpeed: true,
|
||||
})
|
||||
recording.frames.push({ at: Date.now() - recording.startedAtEpoch, data: frame.data })
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
} catch {
|
||||
recording.running = false
|
||||
}
|
||||
})()
|
||||
return recording
|
||||
}
|
||||
|
||||
export async function stopVisualCapture(recording: VisualCapture | undefined) {
|
||||
if (!recording) return []
|
||||
recording.running = false
|
||||
try {
|
||||
await recording.capture
|
||||
} finally {
|
||||
await recording.session.detach().catch(() => undefined)
|
||||
}
|
||||
return recording.frames
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from "./analyzer"
|
||||
export * from "./capture"
|
||||
export * from "./invariant"
|
||||
export * from "./model"
|
||||
export * from "./probe"
|
||||
export * from "./regions"
|
||||
export * from "./reporter"
|
||||
export * from "./scenario"
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { VisualRegionDefinition } from "./regions"
|
||||
|
||||
type RegionSet<RegionName extends string> = readonly RegionName[] | "all"
|
||||
|
||||
export type VisualInvariant<RegionName extends string = string> =
|
||||
| { type: "required"; regions: readonly RegionName[] }
|
||||
| { type: "continuous-any"; regions: readonly RegionName[] }
|
||||
| { type: "unique"; regions: readonly RegionName[] }
|
||||
| { type: "stable"; regions: readonly RegionName[] }
|
||||
| { type: "fixed"; regions: readonly RegionName[]; tolerance?: number }
|
||||
| { type: "opacity"; regions: RegionSet<RegionName>; floor?: number }
|
||||
| {
|
||||
type: "motion"
|
||||
regions: RegionSet<RegionName>
|
||||
tolerance?: number
|
||||
maxReversals?: number
|
||||
maxPositionReversals?: number
|
||||
}
|
||||
| { type: "continuity"; regions: RegionSet<RegionName> }
|
||||
| { type: "label-stability"; regions: RegionSet<RegionName> }
|
||||
| { type: "flow"; regions: readonly RegionName[]; overlapTolerance?: number }
|
||||
| { type: "preserve-bottom-anchor" }
|
||||
| { type: "acquire-bottom-anchor" }
|
||||
|
||||
export type VisualPlan<RegionName extends string = string> = {
|
||||
regionNames?: readonly RegionName[]
|
||||
invariants: readonly VisualInvariant<RegionName>[]
|
||||
markerRequired?: readonly RegionName[]
|
||||
perMarker?: boolean
|
||||
aggregateMotion?: boolean
|
||||
}
|
||||
|
||||
export type LegacyVisualStabilityOptions<RegionName extends string = string> = {
|
||||
flow?: RegionName[]
|
||||
motionTolerance?: number
|
||||
opacityFloor?: number
|
||||
overlapTolerance?: number
|
||||
maxReversals?: number
|
||||
maxPositionReversals?: number
|
||||
stable?: RegionName[]
|
||||
fixed?: RegionName[]
|
||||
motion?: RegionName[]
|
||||
unique?: RegionName[]
|
||||
preserveBottomAnchor?: boolean
|
||||
acquireBottomAnchor?: boolean
|
||||
perMarker?: boolean
|
||||
continuousAny?: RegionName[][]
|
||||
required?: RegionName[]
|
||||
aggregateMotion?: boolean
|
||||
inferRequired?: boolean
|
||||
}
|
||||
|
||||
export function visualPlan<const Regions extends Record<string, VisualRegionDefinition>>(
|
||||
regions: Regions,
|
||||
invariants: readonly VisualInvariant<Extract<keyof Regions, string>>[],
|
||||
options: Omit<VisualPlan<Extract<keyof Regions, string>>, "regionNames" | "invariants"> = {},
|
||||
): VisualPlan<Extract<keyof Regions, string>> {
|
||||
return { ...options, regionNames: Object.keys(regions) as Extract<keyof Regions, string>[], invariants }
|
||||
}
|
||||
|
||||
export function legacyVisualPlan<RegionName extends string>(
|
||||
options: LegacyVisualStabilityOptions<RegionName> = {},
|
||||
): VisualPlan<RegionName> {
|
||||
const inferred =
|
||||
options.inferRequired === false
|
||||
? []
|
||||
: [
|
||||
...(options.stable ?? []),
|
||||
...(options.fixed ?? []),
|
||||
...(options.unique ?? []),
|
||||
...(options.motion ?? []),
|
||||
...(options.flow ?? []),
|
||||
]
|
||||
return {
|
||||
perMarker: options.perMarker,
|
||||
aggregateMotion: options.aggregateMotion,
|
||||
markerRequired: [
|
||||
...(options.required ?? []),
|
||||
...(options.stable ?? []),
|
||||
...(options.fixed ?? []),
|
||||
...(options.unique ?? []),
|
||||
...(options.motion ?? []),
|
||||
...(options.flow ?? []),
|
||||
],
|
||||
invariants: [
|
||||
{ type: "required", regions: [...(options.required ?? []), ...inferred] },
|
||||
...(options.continuousAny ?? []).map(
|
||||
(regions): VisualInvariant<RegionName> => ({ type: "continuous-any", regions }),
|
||||
),
|
||||
...(options.unique ? [{ type: "unique" as const, regions: options.unique }] : []),
|
||||
...(options.stable ? [{ type: "stable" as const, regions: options.stable }] : []),
|
||||
...(options.fixed
|
||||
? [{ type: "fixed" as const, regions: options.fixed, tolerance: options.motionTolerance }]
|
||||
: []),
|
||||
{ type: "opacity", regions: "all", floor: options.opacityFloor },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{
|
||||
type: "motion",
|
||||
regions: options.motion ?? "all",
|
||||
tolerance: options.motionTolerance,
|
||||
maxReversals: options.maxReversals,
|
||||
maxPositionReversals: options.maxPositionReversals,
|
||||
},
|
||||
{ type: "label-stability", regions: "all" },
|
||||
...(options.preserveBottomAnchor ? [{ type: "preserve-bottom-anchor" as const }] : []),
|
||||
...(options.acquireBottomAnchor ? [{ type: "acquire-bottom-anchor" as const }] : []),
|
||||
...(options.flow
|
||||
? [{ type: "flow" as const, regions: options.flow, overlapTolerance: options.overlapTolerance }]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
export type VisualRegionSample = {
|
||||
present: boolean
|
||||
visible: boolean
|
||||
inViewport: boolean
|
||||
cssHidden?: boolean
|
||||
top: number
|
||||
bottom: number
|
||||
layoutTop?: number
|
||||
layoutBottom?: number
|
||||
width: number
|
||||
height: number
|
||||
opacity: number
|
||||
count: number
|
||||
node: number
|
||||
label: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export type VisualViewportSample = {
|
||||
top: number
|
||||
bottom: number
|
||||
scrollTop: number
|
||||
scrollHeight: number
|
||||
clientHeight: number
|
||||
distanceFromBottom: number
|
||||
}
|
||||
|
||||
export type VisualObservation<RegionName extends string = string> = {
|
||||
at: number
|
||||
regions: string extends RegionName
|
||||
? Record<string, VisualRegionSample>
|
||||
: Partial<Record<RegionName, VisualRegionSample>>
|
||||
viewport?: VisualViewportSample
|
||||
}
|
||||
|
||||
export type VisualMarker = { at: number; label: string }
|
||||
|
||||
export type VisualStabilityTrace<RegionName extends string = string> = {
|
||||
markers: VisualMarker[]
|
||||
samples: VisualObservation<RegionName>[]
|
||||
}
|
||||
|
||||
export type CapturedFrame = { at: number; data: string }
|
||||
|
||||
export type VisualProbeResult<RegionName extends string = string> = VisualStabilityTrace<RegionName> & {
|
||||
frames: CapturedFrame[]
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { startVisualCapture, stopVisualCapture, type VisualCapture } from "./capture"
|
||||
import type { VisualMarker, VisualObservation, VisualProbeResult } from "./model"
|
||||
import type { VisualRegionDefinition } from "./regions"
|
||||
|
||||
type ProbeWindow<RegionName extends string = string> = Window & {
|
||||
__visualStabilityProbe?: {
|
||||
startedAt: number
|
||||
markers: VisualMarker[]
|
||||
samples: VisualObservation<RegionName>[]
|
||||
stop: () => void
|
||||
}
|
||||
}
|
||||
|
||||
const captures = new WeakMap<Page, VisualCapture>()
|
||||
|
||||
export async function startVisualProbe<Regions extends Record<string, VisualRegionDefinition>>(
|
||||
page: Page,
|
||||
regions: Regions,
|
||||
) {
|
||||
await stopCapture(page)
|
||||
await page.evaluate(() => {
|
||||
;(window as ProbeWindow).__visualStabilityProbe?.stop()
|
||||
})
|
||||
const startedAtEpoch = await page.evaluate((regions) => {
|
||||
const samples: VisualObservation[] = []
|
||||
const markers: VisualMarker[] = []
|
||||
const startedAt = performance.now()
|
||||
const nodes = new WeakMap<Node, number>()
|
||||
const lastBounds = new Map<string, { top: number; bottom: number }>()
|
||||
let nextNode = 1
|
||||
let running = true
|
||||
const round = (value: number) => Math.round(value * 10) / 10
|
||||
const opacity = (element: Element) => Number(getComputedStyle(element).opacity)
|
||||
const sample = () => {
|
||||
if (!running) return
|
||||
setTimeout(() => {
|
||||
if (!running) return
|
||||
const viewport = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
||||
element.querySelector("[data-timeline-row]"),
|
||||
)
|
||||
const viewportRect = viewport?.getBoundingClientRect()
|
||||
samples.push({
|
||||
at: performance.now() - startedAt,
|
||||
viewport: viewport
|
||||
? {
|
||||
top: round(viewportRect!.top),
|
||||
bottom: round(viewportRect!.bottom),
|
||||
scrollTop: round(viewport.scrollTop),
|
||||
scrollHeight: round(viewport.scrollHeight),
|
||||
clientHeight: round(viewport.clientHeight),
|
||||
distanceFromBottom: round(viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop),
|
||||
}
|
||||
: undefined,
|
||||
regions: Object.fromEntries(
|
||||
Object.entries(regions).map(([name, config]) => {
|
||||
const found = document.querySelector<HTMLElement>(config.selector)
|
||||
const count = document.querySelectorAll(config.selector).length
|
||||
const element = config.closest ? found?.closest<HTMLElement>(config.closest) : found
|
||||
if (!element)
|
||||
return [
|
||||
name,
|
||||
{
|
||||
present: false,
|
||||
visible: false,
|
||||
inViewport: false,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
opacity: 0,
|
||||
count,
|
||||
node: 0,
|
||||
label: "",
|
||||
text: "",
|
||||
},
|
||||
]
|
||||
const rect = element.getBoundingClientRect()
|
||||
const style = getComputedStyle(element)
|
||||
if (rect.height > 0) lastBounds.set(name, { top: rect.top, bottom: rect.bottom })
|
||||
const known = rect.height > 0 ? rect : lastBounds.get(name)
|
||||
const painted = (() => {
|
||||
const result = { top: rect.top, bottom: rect.bottom, left: rect.left, right: rect.right }
|
||||
let parent = element.parentElement
|
||||
while (parent) {
|
||||
const parentStyle = getComputedStyle(parent)
|
||||
if (["hidden", "clip", "scroll", "auto"].includes(parentStyle.overflowY)) {
|
||||
const parentRect = parent.getBoundingClientRect()
|
||||
result.top = Math.max(result.top, parentRect.top)
|
||||
result.bottom = Math.min(result.bottom, parentRect.bottom)
|
||||
}
|
||||
if (["hidden", "clip", "scroll", "auto"].includes(parentStyle.overflowX)) {
|
||||
const parentRect = parent.getBoundingClientRect()
|
||||
result.left = Math.max(result.left, parentRect.left)
|
||||
result.right = Math.min(result.right, parentRect.right)
|
||||
}
|
||||
if (parent === viewport) break
|
||||
parent = parent.parentElement
|
||||
}
|
||||
if (viewportRect) {
|
||||
result.top = Math.max(result.top, viewportRect.top)
|
||||
result.bottom = Math.min(result.bottom, viewportRect.bottom)
|
||||
result.left = Math.max(result.left, viewportRect.left)
|
||||
result.right = Math.min(result.right, viewportRect.right)
|
||||
}
|
||||
return result
|
||||
})()
|
||||
const contentOpacity = config.opacitySelectors?.length
|
||||
? Math.max(
|
||||
0,
|
||||
...config.opacitySelectors.flatMap((selector) =>
|
||||
[...element.querySelectorAll(selector)].map((node) => {
|
||||
let value = 1
|
||||
let current: Element | null = node
|
||||
while (current) {
|
||||
value *= opacity(current)
|
||||
if (current === element) break
|
||||
current = current.parentElement
|
||||
}
|
||||
return value
|
||||
}),
|
||||
),
|
||||
)
|
||||
: opacity(element)
|
||||
let visibleOpacity = contentOpacity
|
||||
let ancestor = element.parentElement
|
||||
let ancestorHidden = false
|
||||
while (ancestor) {
|
||||
const ancestorStyle = getComputedStyle(ancestor)
|
||||
visibleOpacity *= Number(ancestorStyle.opacity)
|
||||
if (ancestorStyle.display === "none" || ancestorStyle.visibility === "hidden") ancestorHidden = true
|
||||
if (ancestor === viewport) break
|
||||
ancestor = ancestor.parentElement
|
||||
}
|
||||
const cssHidden =
|
||||
ancestorHidden || style.display === "none" || style.visibility === "hidden" || visibleOpacity === 0
|
||||
return [
|
||||
name,
|
||||
{
|
||||
present: true,
|
||||
visible:
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden" &&
|
||||
visibleOpacity > 0 &&
|
||||
painted.right > painted.left &&
|
||||
painted.bottom > painted.top,
|
||||
inViewport:
|
||||
!viewportRect || (!!known && known.bottom > viewportRect.top && known.top < viewportRect.bottom),
|
||||
cssHidden,
|
||||
top: round(painted.top),
|
||||
bottom: round(painted.bottom),
|
||||
layoutTop: round(rect.top),
|
||||
layoutBottom: round(rect.bottom),
|
||||
width: round(painted.right - painted.left),
|
||||
height: round(painted.bottom - painted.top),
|
||||
opacity: round(visibleOpacity),
|
||||
count,
|
||||
node: (() => {
|
||||
const current = nodes.get(element)
|
||||
if (current) return current
|
||||
nodes.set(element, nextNode)
|
||||
return nextNode++
|
||||
})(),
|
||||
label: element.getAttribute("aria-label") ?? "",
|
||||
text: (element.textContent ?? "").trim().replace(/\s+/g, " ").slice(0, 500),
|
||||
},
|
||||
]
|
||||
}),
|
||||
),
|
||||
})
|
||||
requestAnimationFrame(sample)
|
||||
}, 0)
|
||||
}
|
||||
;(window as ProbeWindow).__visualStabilityProbe = {
|
||||
startedAt,
|
||||
markers,
|
||||
samples,
|
||||
stop: () => {
|
||||
running = false
|
||||
},
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
return new Promise<number>((resolve) => {
|
||||
const ready = () => {
|
||||
if (samples.length > 0) return resolve(performance.timeOrigin + startedAt)
|
||||
requestAnimationFrame(ready)
|
||||
}
|
||||
ready()
|
||||
})
|
||||
}, regions)
|
||||
const capture = await startVisualCapture(page, startedAtEpoch)
|
||||
if (capture) captures.set(page, capture)
|
||||
}
|
||||
|
||||
export async function stopVisualProbe<RegionName extends string = string>(
|
||||
page: Page,
|
||||
): Promise<VisualProbeResult<RegionName>> {
|
||||
return page
|
||||
.evaluate(() => {
|
||||
const probe = (window as ProbeWindow).__visualStabilityProbe
|
||||
if (!probe) throw new Error("Visual stability probe is not running")
|
||||
probe.stop()
|
||||
return { markers: probe.markers, samples: probe.samples }
|
||||
})
|
||||
.then(
|
||||
async (trace) => ({ ...trace, frames: await stopCapture(page) }) as unknown as VisualProbeResult<RegionName>,
|
||||
async (error: unknown) => {
|
||||
await stopCapture(page)
|
||||
throw error
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function markVisualProbe(page: Page, label: string) {
|
||||
await page.evaluate((label) => {
|
||||
const probe = (window as ProbeWindow).__visualStabilityProbe
|
||||
if (!probe) return
|
||||
probe.markers.push({ at: performance.now() - probe.startedAt, label })
|
||||
}, label)
|
||||
}
|
||||
|
||||
async function stopCapture(page: Page) {
|
||||
const capture = captures.get(page)
|
||||
if (capture) captures.delete(page)
|
||||
return stopVisualCapture(capture)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export type VisualRegionDefinition = {
|
||||
selector: string
|
||||
closest?: string
|
||||
opacitySelectors?: readonly string[]
|
||||
}
|
||||
|
||||
export function defineVisualRegions<const Regions extends Record<string, VisualRegionDefinition>>(regions: Regions) {
|
||||
return regions
|
||||
}
|
||||
|
||||
export function mapVisualRegions<const Regions extends Record<string, VisualRegionDefinition>, Result>(
|
||||
regions: Regions,
|
||||
map: (region: Regions[keyof Regions], name: keyof Regions) => Result,
|
||||
) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(regions).map(([name, region]) => [name, map(region as Regions[keyof Regions], name)]),
|
||||
) as { [Name in keyof Regions]: Result }
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { expect, type TestInfo } from "@playwright/test"
|
||||
import { writeFile } from "node:fs/promises"
|
||||
import { analyzeVisualObservations, analyzeVisualTraceByMarker } from "./analyzer"
|
||||
import type { VisualPlan } from "./invariant"
|
||||
import type { VisualProbeResult } from "./model"
|
||||
|
||||
export async function reportVisualStability<RegionName extends string>(
|
||||
testInfo: TestInfo,
|
||||
name: string,
|
||||
result: VisualProbeResult<RegionName>,
|
||||
plan: VisualPlan<RegionName>,
|
||||
) {
|
||||
const trace = { markers: result.markers, samples: result.samples }
|
||||
const issues = plan.perMarker
|
||||
? analyzeVisualTraceByMarker(trace, plan)
|
||||
: analyzeVisualObservations(result.samples, plan)
|
||||
const tracePath = testInfo.outputPath(`${name}-visual-trace.json`)
|
||||
const issuesPath = testInfo.outputPath(`${name}-visual-issues.json`)
|
||||
await writeFile(tracePath, JSON.stringify(trace, null, 2))
|
||||
await writeFile(
|
||||
issuesPath,
|
||||
JSON.stringify({ issues, markers: result.markers, capturedFrameCount: result.frames.length }, null, 2),
|
||||
)
|
||||
await testInfo.attach(`${name}-visual-trace`, { path: tracePath, contentType: "application/json" })
|
||||
await testInfo.attach(`${name}-visual-issues`, { path: issuesPath, contentType: "application/json" })
|
||||
if (issues.length) await attachViolationFrames(testInfo, name, result, issues)
|
||||
expect(issues, `${name}: ${issues.join("\n")}`).toEqual([])
|
||||
}
|
||||
|
||||
async function attachViolationFrames<RegionName extends string>(
|
||||
testInfo: TestInfo,
|
||||
name: string,
|
||||
result: VisualProbeResult<RegionName>,
|
||||
issues: string[],
|
||||
) {
|
||||
if (result.frames.length === 0) return
|
||||
const targets = [
|
||||
...new Set(
|
||||
issues.flatMap((issue) => {
|
||||
const match = issue.match(/ at (\d+)ms/)
|
||||
if (match) return [Number(match[1])]
|
||||
const marker = result.markers.find((item) => issue.startsWith(`${item.label}:`))
|
||||
return marker ? [marker.at] : []
|
||||
}),
|
||||
),
|
||||
].slice(0, 6)
|
||||
for (const [violation, target] of targets.entries()) {
|
||||
const nearest = result.frames.reduce(
|
||||
(best, frame, index) => (Math.abs(frame.at - target) < Math.abs(result.frames[best]!.at - target) ? index : best),
|
||||
0,
|
||||
)
|
||||
for (const [label, index] of [
|
||||
["before", Math.max(0, nearest - 1)],
|
||||
["violation", nearest],
|
||||
["after", Math.min(result.frames.length - 1, nearest + 1)],
|
||||
] as const) {
|
||||
await testInfo.attach(`${name}-${violation + 1}-${label}-${Math.round(result.frames[index]!.at)}ms`, {
|
||||
body: Buffer.from(result.frames[index]!.data, "base64"),
|
||||
contentType: "image/jpeg",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Page, TestInfo } from "@playwright/test"
|
||||
import type { VisualPlan } from "./invariant"
|
||||
import { startVisualProbe, stopVisualProbe } from "./probe"
|
||||
import type { VisualRegionDefinition } from "./regions"
|
||||
import { reportVisualStability } from "./reporter"
|
||||
|
||||
export async function runVisualStabilityScenario<const Regions extends Record<string, VisualRegionDefinition>>(input: {
|
||||
page: Page
|
||||
testInfo: TestInfo
|
||||
name: string
|
||||
regions: Regions
|
||||
plan: VisualPlan<Extract<keyof Regions, string>>
|
||||
run: () => Promise<void>
|
||||
}) {
|
||||
await startVisualProbe(input.page, input.regions)
|
||||
await input.run()
|
||||
const result = await stopVisualProbe<Extract<keyof Regions, string>>(input.page)
|
||||
await reportVisualStability(input.testInfo, input.name, result, input.plan)
|
||||
return result
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo -b",
|
||||
"typecheck:e2e": "tsgo -p e2e/tsconfig.json",
|
||||
"start": "vite",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
@@ -25,6 +26,7 @@
|
||||
"test:e2e:local": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:report": "playwright show-report e2e/playwright-report",
|
||||
"test:stability": "bun test ./e2e/performance/unit/visual-stability.test.ts && playwright test --config e2e/performance/timeline-stability/playwright.config.ts",
|
||||
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts"
|
||||
},
|
||||
"license": "MIT",
|
||||
@@ -38,6 +40,7 @@
|
||||
"@types/luxon": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||
@@ -50,6 +53,7 @@
|
||||
"@dnd-kit/solid": "0.5.0",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
|
||||
+47
-108
@@ -28,11 +28,11 @@ import {
|
||||
Show,
|
||||
} from "solid-js"
|
||||
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 { FileProvider } from "@/context/file"
|
||||
import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk"
|
||||
import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
|
||||
import { ServerSDKProvider } from "@/context/server-sdk"
|
||||
import { ServerSyncProvider } from "@/context/server-sync"
|
||||
import { GlobalProvider, useGlobal } from "@/context/global"
|
||||
import { HighlightsProvider } from "@/context/highlights"
|
||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||
@@ -40,10 +40,10 @@ import { LayoutProvider } from "@/context/layout"
|
||||
import { ModelsProvider } from "@/context/models"
|
||||
import { NotificationProvider } from "@/context/notification"
|
||||
import { PermissionProvider } from "@/context/permission"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { PromptProvider } from "@/context/prompt"
|
||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||
import { SettingsProvider, useSettings } from "@/context/settings"
|
||||
import { TerminalProvider } from "@/context/terminal"
|
||||
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||
import { WslServersProvider } from "@/wsl/context"
|
||||
@@ -52,16 +52,9 @@ import LegacyLayout from "@/pages/layout"
|
||||
import NewLayout from "@/pages/layout-new"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { useCheckServerHealth } from "./utils/server-health"
|
||||
import {
|
||||
legacySessionHref,
|
||||
legacySessionServer,
|
||||
requireServerKey,
|
||||
selectSessionLineage,
|
||||
sessionHref,
|
||||
} from "./utils/session-route"
|
||||
import { isSessionNotFoundError } from "./utils/server-errors"
|
||||
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||
|
||||
import Session from "@/pages/session"
|
||||
import { SessionPage, TargetSessionRouteContent } from "@/pages/session"
|
||||
import { NewHome, LegacyHome } from "@/pages/home"
|
||||
|
||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||
@@ -95,11 +88,7 @@ const SessionRoute = () => {
|
||||
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
|
||||
})
|
||||
|
||||
return (
|
||||
<SessionProviders>
|
||||
<Session />
|
||||
</SessionProviders>
|
||||
)
|
||||
return <SessionPage />
|
||||
}
|
||||
|
||||
const TargetSessionRoute = () => {
|
||||
@@ -111,79 +100,19 @@ const TargetSessionRoute = () => {
|
||||
})
|
||||
|
||||
return (
|
||||
// Owns the server-identity remount. Session changes must NOT remount this
|
||||
// subtree (SessionRouteErrorBoundary resets and createSessionLineage
|
||||
// re-resolves reactively instead); both rely on this key for server changes.
|
||||
<Show when={requireServerKey(params.serverKey)} keyed>
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>
|
||||
<ResolvedTargetSessionRoute />
|
||||
<TargetSessionRouteContent />
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</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
|
||||
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
|
||||
// Notification/Models + the visual Layout) for that server.
|
||||
@@ -231,7 +160,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>
|
||||
<TargetServerScopedProviders directory={directory}>
|
||||
<DraftServerScopedProviders directory={directory}>
|
||||
<SDKProvider directory={directory}>
|
||||
<DirectoryDataProvider directory={directory} server={serverKey}>
|
||||
<DraftProviders>
|
||||
@@ -239,7 +168,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||
</DraftProviders>
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
</TargetServerScopedProviders>
|
||||
</DraftServerScopedProviders>
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</Show>
|
||||
@@ -300,12 +229,36 @@ function SharedProviders(props: ParentProps) {
|
||||
<>
|
||||
<BodyDesignClass />
|
||||
<CommandProvider>
|
||||
<DesktopCommands />
|
||||
<HighlightsProvider>{props.children}</HighlightsProvider>
|
||||
</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.
|
||||
type ServerScopedShellProps = ParentProps<{
|
||||
directory?: () => string | undefined
|
||||
@@ -316,9 +269,7 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
|
||||
return (
|
||||
<PermissionProvider directory={props.directory}>
|
||||
<LayoutProvider>
|
||||
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</NotificationProvider>
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</LayoutProvider>
|
||||
</PermissionProvider>
|
||||
)
|
||||
@@ -342,28 +293,14 @@ function NewAppLayout(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function TargetServerScopedProviders(props: ServerScopedShellProps) {
|
||||
function DraftServerScopedProviders(props: ParentProps<{ directory?: () => string | undefined }>) {
|
||||
return (
|
||||
<PermissionProvider directory={props.directory}>
|
||||
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</NotificationProvider>
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</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.
|
||||
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
|
||||
function DraftProviders(props: ParentProps) {
|
||||
@@ -560,11 +497,13 @@ export function AppInterface(props: {
|
||||
component={props.router ?? Router}
|
||||
root={(routerProps) => (
|
||||
<TabsProvider>
|
||||
<ServerShell>
|
||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||
<NewAppLayout>{routerProps.children}</NewAppLayout>
|
||||
</Show>
|
||||
</ServerShell>
|
||||
<NotificationProvider>
|
||||
<ServerShell>
|
||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||
<NewAppLayout>{routerProps.children}</NewAppLayout>
|
||||
</Show>
|
||||
</ServerShell>
|
||||
</NotificationProvider>
|
||||
</TabsProvider>
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { batch, createEffect, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
type Mem = Performance & {
|
||||
@@ -51,31 +52,62 @@ const bad = (n: number | undefined, limit: number, low = false) => {
|
||||
|
||||
const session = (path: string) => path.includes("/session")
|
||||
|
||||
function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string; value: string; wide?: boolean }) {
|
||||
return (
|
||||
<Tooltip value={props.tip} placement="top">
|
||||
function Cell(props: {
|
||||
bad?: boolean
|
||||
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
|
||||
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,
|
||||
"col-span-2": !!props.wide,
|
||||
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
|
||||
}}
|
||||
>
|
||||
<div class="text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70">{props.label}</div>
|
||||
<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>
|
||||
{props.label}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
export function DebugBar() {
|
||||
export function DebugBar(props: { inline?: boolean } = {}) {
|
||||
const language = useLanguage()
|
||||
const location = useLocation()
|
||||
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 heapv = () => {
|
||||
const value = heap()
|
||||
@@ -363,15 +395,30 @@ export function DebugBar() {
|
||||
return (
|
||||
<aside
|
||||
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
|
||||
label={language.t("debugBar.nav.label")}
|
||||
tip={language.t("debugBar.nav.tip")}
|
||||
value={navv()}
|
||||
bad={bad(state.nav.dur, 400)}
|
||||
dim={state.nav.dur === undefined && !state.nav.pending}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.fps.label")}
|
||||
@@ -379,6 +426,7 @@ export function DebugBar() {
|
||||
value={state.fps === undefined ? na() : `${Math.round(state.fps)}`}
|
||||
bad={bad(state.fps, 50, true)}
|
||||
dim={state.fps === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.frame.label")}
|
||||
@@ -386,6 +434,7 @@ export function DebugBar() {
|
||||
value={time(state.gap) ?? na()}
|
||||
bad={bad(state.gap, 50)}
|
||||
dim={state.gap === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.jank.label")}
|
||||
@@ -393,6 +442,7 @@ export function DebugBar() {
|
||||
value={state.jank === undefined ? na() : `${state.jank}`}
|
||||
bad={bad(state.jank, 8)}
|
||||
dim={state.jank === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.long.label")}
|
||||
@@ -400,6 +450,7 @@ export function DebugBar() {
|
||||
value={longv()}
|
||||
bad={bad(state.long.block, 200)}
|
||||
dim={state.long.count === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.delay.label")}
|
||||
@@ -407,6 +458,7 @@ export function DebugBar() {
|
||||
value={time(state.delay) ?? na()}
|
||||
bad={bad(state.delay, 100)}
|
||||
dim={state.delay === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.inp.label")}
|
||||
@@ -414,6 +466,7 @@ export function DebugBar() {
|
||||
value={time(state.inp) ?? na()}
|
||||
bad={bad(state.inp, 200)}
|
||||
dim={state.inp === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.cls.label")}
|
||||
@@ -421,6 +474,7 @@ export function DebugBar() {
|
||||
value={state.cls === undefined ? na() : state.cls.toFixed(2)}
|
||||
bad={bad(state.cls, 0.1)}
|
||||
dim={state.cls === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.mem.label")}
|
||||
@@ -435,6 +489,7 @@ export function DebugBar() {
|
||||
value={heapv()}
|
||||
bad={bad(heap(), 0.8)}
|
||||
dim={state.heap.used === undefined}
|
||||
inline={props.inline}
|
||||
wide
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -65,6 +65,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
const [store, setStore] = createStore({
|
||||
methodIndex: undefined as undefined | number,
|
||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
||||
promptInputs: undefined as undefined | Record<string, string>,
|
||||
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
||||
error: undefined as string | undefined,
|
||||
})
|
||||
@@ -73,6 +74,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
| { type: "method.select"; index: number }
|
||||
| { type: "method.reset" }
|
||||
| { type: "auth.prompt" }
|
||||
| { type: "auth.inputs"; inputs: Record<string, string> }
|
||||
| { type: "auth.pending" }
|
||||
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
|
||||
| { type: "auth.error"; error: string }
|
||||
@@ -83,6 +85,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
if (action.type === "method.select") {
|
||||
draft.methodIndex = action.index
|
||||
draft.authorization = undefined
|
||||
draft.promptInputs = undefined
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
return
|
||||
@@ -90,6 +93,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
if (action.type === "method.reset") {
|
||||
draft.methodIndex = undefined
|
||||
draft.authorization = undefined
|
||||
draft.promptInputs = undefined
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
return
|
||||
@@ -99,6 +103,12 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.inputs") {
|
||||
draft.promptInputs = action.inputs
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.pending") {
|
||||
draft.state = "pending"
|
||||
draft.error = undefined
|
||||
@@ -151,6 +161,15 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
const method = methods()[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.prompts?.length && !inputs) {
|
||||
dispatch({ type: "auth.prompt" })
|
||||
@@ -190,7 +209,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
}
|
||||
}
|
||||
|
||||
function OAuthPromptsView() {
|
||||
function AuthPromptsView() {
|
||||
const [formStore, setFormStore] = createStore({
|
||||
value: {} as Record<string, string>,
|
||||
index: 0,
|
||||
@@ -198,8 +217,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
|
||||
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
|
||||
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>) => {
|
||||
if (!prompt.when) return true
|
||||
@@ -230,6 +248,10 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
setFormStore("index", next)
|
||||
return
|
||||
}
|
||||
if (method()?.type === "api") {
|
||||
dispatch({ type: "auth.inputs", inputs: value })
|
||||
return
|
||||
}
|
||||
await selectMethod(store.methodIndex, value)
|
||||
}
|
||||
|
||||
@@ -414,6 +436,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
auth: {
|
||||
type: "api",
|
||||
key: apiKey,
|
||||
...(store.promptInputs ? { metadata: store.promptInputs } : {}),
|
||||
},
|
||||
})
|
||||
await complete()
|
||||
@@ -622,7 +645,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={store.state === "prompt"}>
|
||||
<OAuthPromptsView />
|
||||
<AuthPromptsView />
|
||||
</Match>
|
||||
<Match when={store.state === "error"}>
|
||||
<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 { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
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 { popularProviders } from "@/hooks/use-providers"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
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 = () => {
|
||||
const local = useLocal()
|
||||
@@ -102,3 +115,151 @@ export const DialogManageModels: Component = () => {
|
||||
</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 { 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 { useLocal } from "@/context/local"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { popularProviders } from "@/hooks/use-providers"
|
||||
import { Button } from "@opencode-ai/ui/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 { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
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 { useLanguage } from "@/context/language"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
@@ -18,6 +22,22 @@ const isFree = (provider: string, cost: { input: number } | undefined) =>
|
||||
provider === "opencode" && (!cost || cost.input === 0)
|
||||
|
||||
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<{
|
||||
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) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
import { useFile } from "@/context/file"
|
||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import "@opencode-ai/ui/v2/file-tree-v2.css"
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
For,
|
||||
Match,
|
||||
on,
|
||||
Show,
|
||||
splitProps,
|
||||
Switch,
|
||||
untrack,
|
||||
type ComponentProps,
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import {
|
||||
dirsToExpand,
|
||||
pathToFileUrl,
|
||||
shouldListRoot,
|
||||
visibleKind,
|
||||
withFileDragImage,
|
||||
type Filter,
|
||||
type Kind,
|
||||
} from "@/components/file-tree"
|
||||
|
||||
export type { Kind } from "@/components/file-tree"
|
||||
|
||||
const MAX_DEPTH = 128
|
||||
|
||||
function visibleNodesForPath(path: string, children: (dir: string) => FileNode[], current: Filter | undefined) {
|
||||
const nodes = children(path)
|
||||
if (!current) return nodes
|
||||
|
||||
const parent = (item: string) => {
|
||||
const idx = item.lastIndexOf("/")
|
||||
if (idx === -1) return ""
|
||||
return item.slice(0, idx)
|
||||
}
|
||||
|
||||
const leaf = (item: string) => {
|
||||
const idx = item.lastIndexOf("/")
|
||||
return idx === -1 ? item : item.slice(idx + 1)
|
||||
}
|
||||
|
||||
const out = nodes.filter((node) => {
|
||||
if (node.type === "file") return current.files.has(node.path)
|
||||
return current.dirs.has(node.path)
|
||||
})
|
||||
|
||||
const seen = new Set(out.map((node) => node.path))
|
||||
|
||||
for (const dir of current.dirs) {
|
||||
if (parent(dir) !== path) continue
|
||||
if (seen.has(dir)) continue
|
||||
out.push({
|
||||
name: leaf(dir),
|
||||
path: dir,
|
||||
absolute: dir,
|
||||
type: "directory",
|
||||
ignored: false,
|
||||
})
|
||||
seen.add(dir)
|
||||
}
|
||||
|
||||
for (const item of current.files) {
|
||||
if (parent(item) !== path) continue
|
||||
if (seen.has(item)) continue
|
||||
out.push({
|
||||
name: leaf(item),
|
||||
path: item,
|
||||
absolute: item,
|
||||
type: "file",
|
||||
ignored: false,
|
||||
})
|
||||
seen.add(item)
|
||||
}
|
||||
|
||||
out.sort((a, b) => {
|
||||
if (a.type !== b.type) {
|
||||
return a.type === "directory" ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
const INDENT_STEP = 16
|
||||
|
||||
function rowPaddingLeft(level: number, type: FileNode["type"]) {
|
||||
if (type === "directory") return 8 + level * INDENT_STEP
|
||||
if (level === 0) return 8
|
||||
return 8 + level * INDENT_STEP - INDENT_STEP
|
||||
}
|
||||
|
||||
function guideLineLeft(level: number) {
|
||||
return rowPaddingLeft(level, "directory") + 8
|
||||
}
|
||||
|
||||
export const kindLabel = (kind: Kind) => {
|
||||
if (kind === "add") return "A"
|
||||
if (kind === "del") return "D"
|
||||
return ""
|
||||
}
|
||||
|
||||
export const kindChange = (kind: Kind) => {
|
||||
if (kind === "add") return "added"
|
||||
if (kind === "del") return "deleted"
|
||||
return "modified"
|
||||
}
|
||||
|
||||
const FileTreeNodeV2 = (
|
||||
p: ParentProps &
|
||||
ComponentProps<"div"> &
|
||||
ComponentProps<"button"> & {
|
||||
node: FileNode
|
||||
level: number
|
||||
active?: string
|
||||
draggable: boolean
|
||||
kinds?: ReadonlyMap<string, Kind>
|
||||
marks?: Set<string>
|
||||
as?: "div" | "button"
|
||||
},
|
||||
) => {
|
||||
const [local, rest] = splitProps(p, [
|
||||
"node",
|
||||
"level",
|
||||
"active",
|
||||
"draggable",
|
||||
"kinds",
|
||||
"marks",
|
||||
"as",
|
||||
"children",
|
||||
"class",
|
||||
"classList",
|
||||
])
|
||||
const kind = () => visibleKind(local.node, local.kinds, local.marks)
|
||||
|
||||
return (
|
||||
<Dynamic
|
||||
component={local.as ?? "div"}
|
||||
data-slot="file-tree-v2-row"
|
||||
data-selected={local.node.path === local.active ? "" : undefined}
|
||||
data-ignored={local.node.ignored ? "" : undefined}
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
style={`padding-left: ${rowPaddingLeft(local.level, local.node.type)}px`}
|
||||
draggable={local.draggable}
|
||||
onDragStart={(event: DragEvent) => {
|
||||
if (!local.draggable) return
|
||||
event.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
|
||||
event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy"
|
||||
withFileDragImage(event)
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{local.children}
|
||||
<span class="flex-1 min-w-0 text-12-medium whitespace-nowrap truncate">{local.node.name}</span>
|
||||
{(() => {
|
||||
const value = kind()
|
||||
if (!value || local.node.type !== "file") return null
|
||||
return (
|
||||
<span data-slot="file-tree-v2-change" data-change={kindChange(value)}>
|
||||
{kindLabel(value)}
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
</Dynamic>
|
||||
)
|
||||
}
|
||||
|
||||
// V2-styled fork of FileTree for the review sidebar. Unlike the v1 tree it never
|
||||
// lists unloaded subdirectories, so callers must pass `allowed` (nodes are
|
||||
// synthesized from that list) for nested content to appear.
|
||||
export default function FileTreeV2(props: {
|
||||
path: string
|
||||
active?: string
|
||||
level?: number
|
||||
allowed?: readonly string[]
|
||||
kinds?: ReadonlyMap<string, Kind>
|
||||
draggable?: boolean
|
||||
onFileClick?: (file: FileNode) => void
|
||||
|
||||
_filter?: Filter
|
||||
_marks?: Set<string>
|
||||
_deeps?: Map<string, number>
|
||||
_kinds?: ReadonlyMap<string, Kind>
|
||||
_chain?: readonly string[]
|
||||
}) {
|
||||
const file = useFile()
|
||||
const level = props.level ?? 0
|
||||
const draggable = () => props.draggable ?? true
|
||||
|
||||
const key = (p: string) =>
|
||||
file
|
||||
.normalize(p)
|
||||
.replace(/[\\/]+$/, "")
|
||||
.replaceAll("\\", "/")
|
||||
const chain = props._chain ? [...props._chain, key(props.path)] : [key(props.path)]
|
||||
|
||||
const filter = createMemo(() => {
|
||||
if (props._filter) return props._filter
|
||||
|
||||
const allowed = props.allowed
|
||||
if (!allowed) return
|
||||
|
||||
const files = new Set(allowed)
|
||||
const dirs = new Set<string>()
|
||||
|
||||
for (const item of allowed) {
|
||||
const parts = item.split("/")
|
||||
const parents = parts.slice(0, -1)
|
||||
for (const [idx] of parents.entries()) {
|
||||
const dir = parents.slice(0, idx + 1).join("/")
|
||||
if (dir) dirs.add(dir)
|
||||
}
|
||||
}
|
||||
|
||||
return { files, dirs }
|
||||
})
|
||||
|
||||
const marks = createMemo(() => {
|
||||
if (props._marks) return props._marks
|
||||
|
||||
const out = new Set<string>(props.kinds?.keys() ?? [])
|
||||
if (out.size === 0) return
|
||||
return out
|
||||
})
|
||||
|
||||
const kinds = createMemo(() => {
|
||||
if (props._kinds) return props._kinds
|
||||
return props.kinds
|
||||
})
|
||||
|
||||
const deeps = createMemo(() => {
|
||||
if (props._deeps) return props._deeps
|
||||
|
||||
const out = new Map<string, number>()
|
||||
|
||||
const root = props.path
|
||||
if (!(file.tree.state(root)?.expanded ?? false)) return out
|
||||
|
||||
const seen = new Set<string>()
|
||||
const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = []
|
||||
|
||||
const push = (dir: string, lvl: number) => {
|
||||
const id = key(dir)
|
||||
if (seen.has(id)) return
|
||||
seen.add(id)
|
||||
|
||||
const kids = file.tree
|
||||
.children(dir)
|
||||
.filter((node) => node.type === "directory" && (file.tree.state(node.path)?.expanded ?? false))
|
||||
.map((node) => node.path)
|
||||
|
||||
stack.push({ dir, lvl, i: 0, kids, max: lvl })
|
||||
}
|
||||
|
||||
push(root, level - 1)
|
||||
|
||||
while (stack.length > 0) {
|
||||
const top = stack[stack.length - 1]!
|
||||
|
||||
if (top.i < top.kids.length) {
|
||||
const next = top.kids[top.i]!
|
||||
top.i++
|
||||
push(next, top.lvl + 1)
|
||||
continue
|
||||
}
|
||||
|
||||
out.set(top.dir, top.max)
|
||||
stack.pop()
|
||||
|
||||
const parent = stack[stack.length - 1]
|
||||
if (!parent) continue
|
||||
parent.max = Math.max(parent.max, top.max)
|
||||
}
|
||||
|
||||
return out
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const current = filter()
|
||||
const dirs = dirsToExpand({
|
||||
level,
|
||||
filter: current,
|
||||
expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false,
|
||||
})
|
||||
// Nodes come from the `allowed` filter; skip listing so directories that only
|
||||
// exist on the diff's base branch do not each fail with an error toast.
|
||||
for (const dir of dirs) file.tree.expand(dir, { list: false })
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.path,
|
||||
(path) => {
|
||||
const dir = untrack(() => file.tree.state(path))
|
||||
if (!shouldListRoot({ level, dir })) return
|
||||
void file.tree.list(path)
|
||||
},
|
||||
{ defer: false },
|
||||
),
|
||||
)
|
||||
|
||||
const nodes = createMemo(() => visibleNodesForPath(props.path, file.tree.children, filter()))
|
||||
|
||||
return (
|
||||
// group/file-tree-v2 scopes the group-hover guide lines below; hosts may add
|
||||
// an outer group with the same name to widen the hover area.
|
||||
<div data-component="file-tree-v2" class="group/file-tree-v2">
|
||||
<For each={nodes()}>
|
||||
{(node) => {
|
||||
const expanded = () => file.tree.state(node.path)?.expanded ?? false
|
||||
const deep = () => deeps().get(node.path) ?? -1
|
||||
const hasChildren = () => visibleNodesForPath(node.path, file.tree.children, filter()).length > 0
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={node.type === "directory"}>
|
||||
<Collapsible
|
||||
variant="ghost"
|
||||
class="w-full"
|
||||
data-scope="file-tree-v2"
|
||||
forceMount={false}
|
||||
open={expanded()}
|
||||
onOpenChange={(open) =>
|
||||
open ? file.tree.expand(node.path, { list: false }) : file.tree.collapse(node.path)
|
||||
}
|
||||
>
|
||||
<Collapsible.Trigger>
|
||||
<FileTreeNodeV2
|
||||
node={node}
|
||||
level={level}
|
||||
active={props.active}
|
||||
draggable={draggable()}
|
||||
kinds={kinds()}
|
||||
marks={marks()}
|
||||
>
|
||||
<div
|
||||
data-slot="file-tree-v2-chevron"
|
||||
data-expanded={expanded() ? "" : undefined}
|
||||
class="size-4 flex items-center justify-center"
|
||||
>
|
||||
<Icon name="chevron-down" />
|
||||
</div>
|
||||
</FileTreeNodeV2>
|
||||
</Collapsible.Trigger>
|
||||
<Show when={hasChildren()}>
|
||||
<Collapsible.Content class="relative">
|
||||
<div
|
||||
classList={{
|
||||
"absolute top-0 bottom-0 w-px pointer-events-none bg-border-weak-base opacity-0 transition-opacity duration-150 ease-out motion-reduce:transition-none": true,
|
||||
"group-hover/file-tree-v2:opacity-100": expanded() && deep() === level,
|
||||
"group-hover/file-tree-v2:opacity-50": !(expanded() && deep() === level),
|
||||
}}
|
||||
style={`left: ${guideLineLeft(level)}px`}
|
||||
/>
|
||||
<Show
|
||||
when={level < MAX_DEPTH && !chain.includes(key(node.path))}
|
||||
fallback={<div class="px-2 py-1 text-12-regular text-text-weak">...</div>}
|
||||
>
|
||||
<FileTreeV2
|
||||
path={node.path}
|
||||
level={level + 1}
|
||||
allowed={props.allowed}
|
||||
kinds={props.kinds}
|
||||
active={props.active}
|
||||
draggable={props.draggable}
|
||||
onFileClick={props.onFileClick}
|
||||
_filter={filter()}
|
||||
_marks={marks()}
|
||||
_deeps={deeps()}
|
||||
_kinds={kinds()}
|
||||
_chain={chain}
|
||||
/>
|
||||
</Show>
|
||||
</Collapsible.Content>
|
||||
</Show>
|
||||
</Collapsible>
|
||||
</Match>
|
||||
<Match when={node.type === "file"}>
|
||||
<FileTreeNodeV2
|
||||
node={node}
|
||||
level={level}
|
||||
active={props.active}
|
||||
draggable={draggable()}
|
||||
kinds={kinds()}
|
||||
marks={marks()}
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={() => props.onFileClick?.(node)}
|
||||
>
|
||||
<Show when={level > 0}>
|
||||
<div class="w-4 shrink-0" />
|
||||
</Show>
|
||||
<Show
|
||||
when={!node.ignored}
|
||||
fallback={<FileIcon node={node} class="size-4 filetree-icon filetree-icon--mono" mono />}
|
||||
>
|
||||
<span class="filetree-iconpair size-4">
|
||||
<FileIcon node={node} class="size-4 filetree-icon filetree-icon--color" />
|
||||
<FileIcon node={node} class="size-4 filetree-icon filetree-icon--mono" mono />
|
||||
</span>
|
||||
</Show>
|
||||
</FileTreeNodeV2>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -21,13 +21,13 @@ import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
|
||||
const MAX_DEPTH = 128
|
||||
|
||||
function pathToFileUrl(filepath: string): string {
|
||||
export function pathToFileUrl(filepath: string): string {
|
||||
return `file://${encodeFilePath(filepath)}`
|
||||
}
|
||||
|
||||
type Kind = "add" | "del" | "mix"
|
||||
export type Kind = "add" | "del" | "mix"
|
||||
|
||||
type Filter = {
|
||||
export type Filter = {
|
||||
files: Set<string>
|
||||
dirs: Set<string>
|
||||
}
|
||||
@@ -78,7 +78,7 @@ const kindDotColor = (kind: Kind) => {
|
||||
return "background-color: var(--icon-diff-modified-base)"
|
||||
}
|
||||
|
||||
const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => {
|
||||
export const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => {
|
||||
const kind = kinds?.get(node.path)
|
||||
if (!kind) return
|
||||
if (!marks?.has(node.path)) return
|
||||
@@ -99,7 +99,7 @@ const buildDragImage = (target: HTMLElement) => {
|
||||
return image
|
||||
}
|
||||
|
||||
const withFileDragImage = (event: DragEvent) => {
|
||||
export const withFileDragImage = (event: DragEvent) => {
|
||||
const image = buildDragImage(event.currentTarget as HTMLElement)
|
||||
if (!image) return
|
||||
document.body.appendChild(image)
|
||||
|
||||
@@ -35,10 +35,14 @@ import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
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 { Select } from "@opencode-ai/ui/select"
|
||||
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 { Persist, persisted } from "@/utils/persist"
|
||||
import { usePermission } from "@/context/permission"
|
||||
@@ -66,6 +70,7 @@ import { promptPlaceholder } from "./prompt-input/placeholder"
|
||||
import { createPromptInputTransientState } from "./prompt-input/transient-state"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export type PromptInputState = ReturnType<typeof usePrompt>
|
||||
|
||||
@@ -212,6 +217,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
let fileInputRef: HTMLInputElement | undefined
|
||||
let scrollRef!: HTMLDivElement
|
||||
let slashPopoverRef!: HTMLDivElement
|
||||
let restoreEndOnFocus = true
|
||||
let savedCursor: number | null = null
|
||||
|
||||
const mirror = { input: false }
|
||||
const inset = 56
|
||||
@@ -584,13 +591,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const restoreFocus = () => {
|
||||
requestAnimationFrame(() => {
|
||||
const cursor = prompt.cursor() ?? promptLength(prompt.current())
|
||||
const cursor = savedCursor ?? prompt.cursor() ?? promptLength(prompt.current())
|
||||
editorRef.focus()
|
||||
setCursorPosition(editorRef, cursor)
|
||||
queueScroll()
|
||||
})
|
||||
}
|
||||
|
||||
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 cursor = currentCursor()
|
||||
renderEditor(parts)
|
||||
@@ -611,6 +628,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
|
||||
|
||||
const handleBlur = () => {
|
||||
savedCursor = currentCursor()
|
||||
closePopover()
|
||||
setComposing(false)
|
||||
}
|
||||
@@ -627,24 +645,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(() =>
|
||||
props.controls.agents.available
|
||||
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
||||
.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) => {
|
||||
if (!option) return
|
||||
if (option.type === "agent") {
|
||||
addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 })
|
||||
} else {
|
||||
addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 })
|
||||
return
|
||||
}
|
||||
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) => {
|
||||
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 {
|
||||
@@ -655,30 +738,36 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onKeyDown: atOnKeyDown,
|
||||
} = useFilteredList<AtOption>({
|
||||
items: async (query) => {
|
||||
const references = referenceList()
|
||||
const agents = agentList()
|
||||
const mcpResources = mcpResourceList()
|
||||
const open = recent()
|
||||
const seen = new Set(open)
|
||||
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 fileOptions: AtOption[] = paths
|
||||
.filter((path) => !seen.has(path))
|
||||
.map((path) => ({ type: "file", path, display: path }))
|
||||
return [...agents, ...pinned, ...fileOptions]
|
||||
return [...references, ...agents, ...mcpResources, ...pinned, ...fileOptions]
|
||||
},
|
||||
key: atKey,
|
||||
filterKeys: ["display"],
|
||||
skipFilter: (item) => item.type === "file" && !item.recent,
|
||||
groupBy: (item) => {
|
||||
if (item.type === "reference") return "reference"
|
||||
if (item.type === "agent") return "agent"
|
||||
if (item.type === "resource") return "resource"
|
||||
if (item.recent) return "recent"
|
||||
return "file"
|
||||
},
|
||||
sortGroupsBy: (a, b) => {
|
||||
const rank = (category: string) => {
|
||||
if (category === "agent") return 0
|
||||
if (category === "recent") return 1
|
||||
return 2
|
||||
if (category === "reference") return 0
|
||||
if (category === "agent") return 1
|
||||
if (category === "resource") return 2
|
||||
if (category === "recent") return 3
|
||||
return 4
|
||||
}
|
||||
return rank(a.category) - rank(b.category)
|
||||
},
|
||||
@@ -744,7 +833,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const pill = document.createElement("span")
|
||||
pill.textContent = part.content
|
||||
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)
|
||||
pill.setAttribute("contenteditable", "false")
|
||||
pill.style.userSelect = "text"
|
||||
@@ -789,8 +888,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-scroll active command into view when navigating with keyboard
|
||||
createEffect(() => {
|
||||
const scrollSlashActiveIntoView = () => {
|
||||
const activeId = slashActive()
|
||||
if (!activeId || !slashPopoverRef) return
|
||||
|
||||
@@ -798,7 +896,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const element = slashPopoverRef.querySelector(`[data-slash-id="${activeId}"]`)
|
||||
element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||
})
|
||||
})
|
||||
}
|
||||
const selectPopoverActive = () => {
|
||||
if (store.popover === "at") {
|
||||
const items = atFlat()
|
||||
@@ -860,12 +958,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const pushFile = (file: HTMLElement) => {
|
||||
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({
|
||||
type: "file",
|
||||
path: file.dataset.path!,
|
||||
content,
|
||||
start: position,
|
||||
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
|
||||
}
|
||||
@@ -1285,6 +1400,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
if (store.popover === "slash") {
|
||||
slashOnKeyDown(event)
|
||||
if (event.key === "ArrowUp" || event.key === "ArrowDown" || ctrlNav) {
|
||||
scrollSlashActiveIntoView()
|
||||
}
|
||||
}
|
||||
event.preventDefault()
|
||||
return
|
||||
@@ -1343,9 +1461,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
|
||||
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 providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
|
||||
const providersShouldFadeIn = createMemo<boolean>((prev) => prev ?? providersLoading())
|
||||
|
||||
const [promptReady] = createResource(
|
||||
() => prompt.ready.promise,
|
||||
@@ -1359,12 +1477,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const modelControlState = createMemo<ComposerModelControlState>(() => ({
|
||||
loading: providersLoading(),
|
||||
shouldAnimate: providersShouldFadeIn(),
|
||||
paid: props.controls.model.paid,
|
||||
title: language.t("command.model.choose"),
|
||||
keybind: command.keybind("model.choose"),
|
||||
keybind: command.keybindParts("model.choose"),
|
||||
model: props.controls.model.selection,
|
||||
providerID: props.controls.model.selection.current()?.provider?.id,
|
||||
modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
|
||||
newLayoutDesigns: props.controls.newLayoutDesigns,
|
||||
style: control(),
|
||||
onClose: restoreFocus,
|
||||
onUnpaidClick: () => {
|
||||
@@ -1375,10 +1495,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}))
|
||||
|
||||
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 agentControlState = createMemo<ComposerAgentControlState>(() => ({
|
||||
title: language.t("command.agent.cycle"),
|
||||
keybind: command.keybind("agent.cycle"),
|
||||
keybind: command.keybindParts("agent.cycle"),
|
||||
options: props.controls.agents.options,
|
||||
current: props.controls.agents.current,
|
||||
style: control(),
|
||||
@@ -1403,6 +1528,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
setSlashActive={setSlashActive}
|
||||
onSlashSelect={handleSlashSelect}
|
||||
commandKeybind={command.keybind}
|
||||
commandKeybindParts={command.keybindParts}
|
||||
newLayoutDesigns={props.controls.newLayoutDesigns}
|
||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||
/>
|
||||
<Switch>
|
||||
@@ -1456,10 +1583,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<div class="relative max-h-[180px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
|
||||
<div
|
||||
data-component="prompt-input"
|
||||
ref={(el) => {
|
||||
editorRef = el
|
||||
props.ref?.(el)
|
||||
}}
|
||||
ref={bindEditorRef}
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label={designPlaceholder()}
|
||||
@@ -1474,6 +1598,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onPaste={handlePaste}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
classList={{
|
||||
@@ -1494,12 +1619,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
<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()}
|
||||
<TooltipKeybind
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
title={language.t("prompt.action.attachFile")}
|
||||
keybind={command.keybind("file.attach")}
|
||||
value={
|
||||
<>
|
||||
{language.t("prompt.action.attachFile")}
|
||||
<KeybindV2 keys={command.keybindParts("file.attach")} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
data-action="prompt-attach"
|
||||
@@ -1513,47 +1642,76 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
aria-label={language.t("prompt.action.attachFile")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</TooltipV2>
|
||||
<Show when={showAgentControl()}>
|
||||
<ComposerAgentControl state={agentControlState()} />
|
||||
</Show>
|
||||
{props.toolbar}
|
||||
<ComposerModelControl state={modelControlState()} />
|
||||
<Show when={store.mode !== "shell" && showVariantControl()}>
|
||||
<Show when={!providersLoading() && store.mode !== "shell" && showVariantControl()}>
|
||||
<div
|
||||
data-component="prompt-variant-control"
|
||||
class="[&_[data-action=prompt-model-variant]]:![font-weight:440]"
|
||||
classList={{
|
||||
"animate-in fade-in": providersShouldFadeIn(),
|
||||
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
|
||||
!props.controls.model.selection.variant.current() && !store.variantOpen,
|
||||
}}
|
||||
>
|
||||
<TooltipKeybind
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.model.variant.cycle")}
|
||||
keybind={command.keybind("model.variant.cycle")}
|
||||
value={
|
||||
<>
|
||||
{language.t("command.model.variant.cycle")}
|
||||
<KeybindV2 keys={command.keybindParts("model.variant.cycle")} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
size="normal"
|
||||
options={variants()}
|
||||
current={props.controls.model.selection.variant.current() ?? "default"}
|
||||
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||
<MenuV2
|
||||
gutter={6}
|
||||
modal={false}
|
||||
placement="top-start"
|
||||
onOpenChange={(open) => setStore("variantOpen", open)}
|
||||
onSelect={(value) => {
|
||||
props.controls.model.selection.variant.set(value === "default" ? undefined : value)
|
||||
restoreFocus()
|
||||
}}
|
||||
class="capitalize max-w-[160px] justify-start text-v2-text-text-faint"
|
||||
valueClass="truncate text-[13px] font-[440] leading-5 text-v2-text-text-faint"
|
||||
triggerStyle={control()}
|
||||
triggerProps={{ "data-action": "prompt-model-variant" }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
>
|
||||
<MenuV2.Trigger
|
||||
as={ButtonV2}
|
||||
data-action="prompt-model-variant"
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
class="max-w-[160px] justify-start capitalize"
|
||||
style={control()}
|
||||
>
|
||||
<span class="truncate">
|
||||
{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>
|
||||
</Show>
|
||||
</div>
|
||||
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
|
||||
<TooltipV2 placement="top" inactive={!working() && blank()} value={tip()}>
|
||||
<IconButton
|
||||
data-action="prompt-submit"
|
||||
type="submit"
|
||||
@@ -1568,7 +1726,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}}
|
||||
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</DockShellForm>
|
||||
</div>
|
||||
@@ -1628,10 +1786,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
>
|
||||
<div
|
||||
data-component="prompt-input"
|
||||
ref={(el) => {
|
||||
editorRef = el
|
||||
props.ref?.(el)
|
||||
}}
|
||||
ref={bindEditorRef}
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label={placeholder()}
|
||||
@@ -1646,6 +1801,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onPaste={handlePaste}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
classList={{
|
||||
@@ -1765,7 +1921,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<Show when={!agentsLoading()}>
|
||||
<div
|
||||
data-component="prompt-agent-control"
|
||||
style={agentsShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
||||
classList={{ "animate-in fade-in duration-300": agentsShouldFadeIn() }}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
@@ -1794,7 +1950,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<Show when={store.mode !== "shell"}>
|
||||
<div
|
||||
data-component="prompt-model-control"
|
||||
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
||||
classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
|
||||
>
|
||||
<Show
|
||||
when={props.controls.model.paid}
|
||||
@@ -1873,7 +2029,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<Show when={showVariantControl()}>
|
||||
<div
|
||||
data-component="prompt-variant-control"
|
||||
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
||||
classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
@@ -1914,7 +2070,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
type ComposerAgentControlState = {
|
||||
title: string
|
||||
keybind: string
|
||||
keybind: string[]
|
||||
options: string[]
|
||||
current: string
|
||||
style: JSX.CSSProperties | undefined
|
||||
@@ -1923,12 +2079,14 @@ type ComposerAgentControlState = {
|
||||
|
||||
type ComposerModelControlState = {
|
||||
loading: boolean
|
||||
shouldAnimate: boolean
|
||||
paid: boolean
|
||||
title: string
|
||||
keybind: string
|
||||
keybind: string[]
|
||||
model: ReturnType<typeof useLocal>["model"]
|
||||
providerID?: string
|
||||
modelName: string
|
||||
newLayoutDesigns: boolean
|
||||
style: JSX.CSSProperties | undefined
|
||||
onClose: () => void
|
||||
onUnpaidClick: () => void
|
||||
@@ -1940,7 +2098,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">
|
||||
<Icon name="sliders" size="small" />
|
||||
</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
|
||||
size="normal"
|
||||
options={props.state.options}
|
||||
@@ -1952,7 +2119,7 @@ function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
|
||||
triggerProps={{ "data-action": "prompt-agent" }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1963,13 +2130,23 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
||||
<Show
|
||||
when={props.state.paid}
|
||||
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
|
||||
data-action="prompt-model"
|
||||
as="div"
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
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}
|
||||
onClick={props.state.onUnpaidClick}
|
||||
>
|
||||
@@ -1987,39 +2164,78 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</TooltipV2>
|
||||
}
|
||||
>
|
||||
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
|
||||
<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",
|
||||
"data-action": "prompt-model",
|
||||
}}
|
||||
onClose={props.state.onClose}
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
gutter={4}
|
||||
value={
|
||||
<>
|
||||
{props.state.title}
|
||||
<KeybindV2 keys={props.state.keybind} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={props.state.newLayoutDesigns}
|
||||
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}>
|
||||
{(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="-ml-1 shrink-0 flex size-fit">
|
||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||
</span>
|
||||
</ModelSelectorPopover>
|
||||
</TooltipKeybind>
|
||||
<ModelSelectorPopoverV2
|
||||
model={props.state.model}
|
||||
triggerAs={ButtonV2}
|
||||
triggerProps={{
|
||||
variant: "ghost-muted",
|
||||
size: "normal",
|
||||
style: props.state.style,
|
||||
class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
|
||||
classList: { "animate-in fade-in": props.state.shouldAnimate },
|
||||
"data-action": "prompt-model",
|
||||
}}
|
||||
onClose={props.state.onClose}
|
||||
>
|
||||
<ModelControlContent state={props.state} v2 />
|
||||
</ModelSelectorPopoverV2>
|
||||
</Show>
|
||||
</TooltipV2>
|
||||
</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", () => {
|
||||
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 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 {
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
url: `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
|
||||
filename: getFilename(attachment.path),
|
||||
source: {
|
||||
type: "file",
|
||||
text: {
|
||||
value: attachment.content,
|
||||
start: attachment.start,
|
||||
end: attachment.end,
|
||||
},
|
||||
path,
|
||||
},
|
||||
mime: attachment.mime ?? "text/plain",
|
||||
url: attachment.url ?? `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
|
||||
filename: attachment.filename ?? getFilename(attachment.path),
|
||||
source,
|
||||
} satisfies PromptRequestPart
|
||||
})
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
||||
</span>
|
||||
}
|
||||
placement="top"
|
||||
openDelay={2000}
|
||||
openDelay={800}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import { Component, For, Match, Show, Switch } from "solid-js"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-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"
|
||||
|
||||
export type AtOption =
|
||||
| { 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 }
|
||||
|
||||
export interface SlashCommand {
|
||||
@@ -30,6 +42,8 @@ type PromptPopoverProps = {
|
||||
setSlashActive: (id: string) => void
|
||||
onSlashSelect: (item: SlashCommand) => void
|
||||
commandKeybind: (id: string) => string | undefined
|
||||
commandKeybindParts: (id: string) => string[]
|
||||
newLayoutDesigns: boolean
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
@@ -41,15 +55,29 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
|
||||
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
|
||||
overflow-auto no-scrollbar flex flex-col p-2 rounded-[12px]
|
||||
bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]"
|
||||
overflow-auto no-scrollbar flex flex-col p-2"
|
||||
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()}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={props.popover === "at"}>
|
||||
<Show
|
||||
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)}>
|
||||
{(item) => {
|
||||
@@ -58,13 +86,117 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
|
||||
if (item.type === "agent") {
|
||||
return (
|
||||
<button
|
||||
class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
|
||||
classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
|
||||
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)}
|
||||
onMouseEnter={() => props.setAtActive(key)}
|
||||
onPointerMove={() => props.setAtActive(key)}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -75,16 +207,44 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
|
||||
|
||||
return (
|
||||
<button
|
||||
class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
|
||||
classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
|
||||
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)}
|
||||
onMouseEnter={() => props.setAtActive(key)}
|
||||
onPointerMove={() => props.setAtActive(key)}
|
||||
>
|
||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
|
||||
<div class="flex items-center text-14-regular min-w-0">
|
||||
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{directory}</span>
|
||||
<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="whitespace-nowrap truncate min-w-0"
|
||||
classList={{
|
||||
"text-v2-text-text-muted": props.newLayoutDesigns,
|
||||
"text-text-weak": !props.newLayoutDesigns,
|
||||
}}
|
||||
>
|
||||
{directory}
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
</button>
|
||||
@@ -96,41 +256,98 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
|
||||
<Match when={props.popover === "slash"}>
|
||||
<Show
|
||||
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}>
|
||||
{(cmd) => (
|
||||
<button
|
||||
data-slash-id={cmd.id}
|
||||
classList={{
|
||||
"w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
|
||||
"bg-surface-raised-base-hover": props.slashActive === cmd.id,
|
||||
}}
|
||||
onClick={() => props.onSlashSelect(cmd)}
|
||||
onMouseEnter={() => props.setSlashActive(cmd.id)}
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
|
||||
<Show when={cmd.description}>
|
||||
<span class="text-14-regular text-text-weak truncate">{cmd.description}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Show when={cmd.type === "custom" && cmd.source !== "command"}>
|
||||
<span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
|
||||
{cmd.source === "skill"
|
||||
? props.t("prompt.slash.badge.skill")
|
||||
: cmd.source === "mcp"
|
||||
? props.t("prompt.slash.badge.mcp")
|
||||
: props.t("prompt.slash.badge.custom")}
|
||||
{(cmd) => {
|
||||
const keybind = () => props.commandKeybind(cmd.id)
|
||||
const keybindParts = () => props.commandKeybindParts(cmd.id)
|
||||
return (
|
||||
<button
|
||||
data-slash-id={cmd.id}
|
||||
classList={{
|
||||
"w-full flex items-center justify-between gap-4 px-2 py-1": true,
|
||||
"rounded-[4px] scroll-my-2": props.newLayoutDesigns,
|
||||
"rounded-md": !props.newLayoutDesigns,
|
||||
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.slashActive === cmd.id,
|
||||
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.slashActive === cmd.id,
|
||||
}}
|
||||
onClick={() => props.onSlashSelect(cmd)}
|
||||
onPointerMove={() => props.setSlashActive(cmd.id)}
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<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,
|
||||
}}
|
||||
>
|
||||
/{cmd.trigger}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={props.commandKeybind(cmd.id)}>
|
||||
<span class="text-12-regular text-text-subtle">{props.commandKeybind(cmd.id)}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<Show when={cmd.description}>
|
||||
<span
|
||||
class="truncate"
|
||||
classList={{
|
||||
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
|
||||
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>
|
||||
</Show>
|
||||
</Match>
|
||||
|
||||
@@ -7,6 +7,7 @@ export function createPromptSubmissionState(input: {
|
||||
prompt: Prompt
|
||||
context: (ContextItem & { key: string })[]
|
||||
}) {
|
||||
const initial = input.target
|
||||
let target = input.target
|
||||
let cleared: Prompt | undefined
|
||||
|
||||
@@ -15,6 +16,7 @@ export function createPromptSubmissionState(input: {
|
||||
context: input.context,
|
||||
target: () => target,
|
||||
clear() {
|
||||
if (initial !== target) initial.reset()
|
||||
target.reset()
|
||||
cleared = target.current()
|
||||
},
|
||||
|
||||
@@ -92,11 +92,17 @@ export function PromptWorkspaceSelector(props: {
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
<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">{props.branch || "main"}</span>
|
||||
</div>
|
||||
<Show when={props.branch}>
|
||||
{(branch) => (
|
||||
<>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
<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 { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
|
||||
import { Match, Show, Switch, createMemo, type ComponentProps, type JSX } from "solid-js"
|
||||
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 { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
|
||||
import { useFile } from "@/context/file"
|
||||
import { useLayout } from "@/context/layout"
|
||||
@@ -9,13 +11,23 @@ import { useSync } from "@/context/sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
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 { createSessionTabs } from "@/pages/session/helpers"
|
||||
|
||||
interface SessionContextUsageProps {
|
||||
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: {
|
||||
@@ -23,7 +35,7 @@ function openSessionContext(args: {
|
||||
layout: ReturnType<typeof useLayout>
|
||||
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")
|
||||
void args.tabs.open("context")
|
||||
args.tabs.setActive("context")
|
||||
@@ -39,12 +51,14 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
|
||||
const variant = createMemo(() => props.variant ?? "button")
|
||||
const buttonAppearance = createMemo(() => props.buttonAppearance ?? "default")
|
||||
const tabState = createSessionTabs({
|
||||
tabs,
|
||||
pathFromTab: file.pathFromTab,
|
||||
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||
})
|
||||
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
|
||||
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
||||
|
||||
const usd = createMemo(
|
||||
() =>
|
||||
@@ -54,21 +68,30 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
}),
|
||||
)
|
||||
|
||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
|
||||
const context = createMemo(() => metrics().context)
|
||||
const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
|
||||
const tokens = createMemo(() => info()?.tokens)
|
||||
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 = () => {
|
||||
if (!params.id) return
|
||||
|
||||
if (tabState.activeTab() === "context") {
|
||||
const sessionView = view()
|
||||
if (contextVisible()) {
|
||||
tabs().close("context")
|
||||
if (sessionView.reviewPanel.source() === "context-button" && !hasOtherTabs()) sessionView.reviewPanel.close()
|
||||
return
|
||||
}
|
||||
|
||||
openSessionContext({
|
||||
view: view(),
|
||||
view: sessionView,
|
||||
layout,
|
||||
tabs: tabs(),
|
||||
})
|
||||
@@ -76,38 +99,54 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
|
||||
const circle = () => (
|
||||
<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>
|
||||
)
|
||||
|
||||
const tooltipValue = () => (
|
||||
<div>
|
||||
<Show when={context()}>
|
||||
{(ctx) => (
|
||||
<>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-text-invert-strong">{ctx().total.toLocaleString(language.intl())}</span>
|
||||
<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 class="flex w-[120px] flex-col gap-2">
|
||||
<ContextTooltipRow name={language.t("context.usage.cost")} value={cost()} />
|
||||
<ContextTooltipRow name={language.t("context.usage.usage")} value={`${context()?.usage ?? 0}%`} />
|
||||
<ContextTooltipRow
|
||||
name={language.t("context.usage.tokens")}
|
||||
value={getSessionTokenTotal(tokens())?.toLocaleString(language.intl()) ?? "0"}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Show when={params.id}>
|
||||
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"}>
|
||||
<TooltipV2 value={tooltipValue()} placement={props.placement ?? "top"} shift={-8}>
|
||||
<Switch>
|
||||
<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}>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -120,7 +159,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
</Button>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Tooltip>
|
||||
</TooltipV2>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
import { getSessionContextMetrics } from "./session-context-metrics"
|
||||
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
|
||||
|
||||
const assistant = (
|
||||
id: string,
|
||||
@@ -37,8 +37,8 @@ const user = (id: string) => {
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
describe("getSessionContextMetrics", () => {
|
||||
test("computes totals and usage from latest assistant with tokens", () => {
|
||||
describe("getSessionContext", () => {
|
||||
test("computes usage from latest assistant with tokens", () => {
|
||||
const messages = [
|
||||
user("u1"),
|
||||
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(metrics.context?.message.id).toBe("a2")
|
||||
expect(metrics.context?.total).toBe(500)
|
||||
expect(metrics.context?.usage).toBe(50)
|
||||
expect(metrics.context?.providerLabel).toBe("OpenAI")
|
||||
expect(metrics.context?.modelLabel).toBe("GPT-4.1")
|
||||
expect(ctx?.message.id).toBe("a2")
|
||||
expect(ctx?.usage).toBe(50)
|
||||
expect(ctx?.providerLabel).toBe("OpenAI")
|
||||
expect(ctx?.modelLabel).toBe("GPT-4.1")
|
||||
})
|
||||
|
||||
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 providers = [{ id: "p-1", models: {} }]
|
||||
|
||||
const metrics = getSessionContextMetrics(messages, providers)
|
||||
const ctx = getSessionContext(messages, providers)
|
||||
|
||||
expect(metrics.context?.providerLabel).toBe("p-1")
|
||||
expect(metrics.context?.modelLabel).toBe("m-1")
|
||||
expect(metrics.context?.limit).toBeUndefined()
|
||||
expect(metrics.context?.usage).toBeNull()
|
||||
expect(ctx?.providerLabel).toBe("p-1")
|
||||
expect(ctx?.modelLabel).toBe("m-1")
|
||||
expect(ctx?.limit).toBeUndefined()
|
||||
expect(ctx?.usage).toBeNull()
|
||||
})
|
||||
|
||||
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 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))
|
||||
const two = getSessionContextMetrics(messages, providers)
|
||||
const two = getSessionContext(messages, providers)
|
||||
|
||||
expect(one.context?.message.id).toBe("a1")
|
||||
expect(two.context?.message.id).toBe("a2")
|
||||
expect(two.totalCost).toBe(1)
|
||||
expect(one?.message.id).toBe("a1")
|
||||
expect(two?.message.id).toBe("a2")
|
||||
})
|
||||
|
||||
test("returns empty metrics when inputs are undefined", () => {
|
||||
const metrics = getSessionContextMetrics(undefined, undefined)
|
||||
test("returns undefined when inputs are undefined", () => {
|
||||
const ctx = getSessionContext(undefined, undefined)
|
||||
|
||||
expect(metrics.totalCost).toBe(0)
|
||||
expect(metrics.context).toBeUndefined()
|
||||
expect(ctx).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 = {
|
||||
id: string
|
||||
@@ -21,19 +21,9 @@ type Context = {
|
||||
modelLabel: string
|
||||
limit: number | undefined
|
||||
input: number
|
||||
output: number
|
||||
reasoning: number
|
||||
cacheRead: number
|
||||
cacheWrite: number
|
||||
total: number
|
||||
usage: number | null
|
||||
}
|
||||
|
||||
type Metrics = {
|
||||
totalCost: number
|
||||
context: Context | undefined
|
||||
}
|
||||
|
||||
const tokenTotal = (msg: AssistantMessage) => {
|
||||
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 totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0)
|
||||
const build = (messages: Message[] = [], providers: Provider[] = []): Context | undefined => {
|
||||
const message = lastAssistantWithTokens(messages)
|
||||
if (!message) return { totalCost, context: undefined }
|
||||
if (!message) return undefined
|
||||
|
||||
const provider = providers.find((item) => item.id === message.providerID)
|
||||
const model = provider?.models[message.modelID]
|
||||
@@ -58,25 +47,22 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Metrics =>
|
||||
const total = tokenTotal(message)
|
||||
|
||||
return {
|
||||
totalCost,
|
||||
context: {
|
||||
message,
|
||||
provider,
|
||||
model,
|
||||
providerLabel: provider?.name ?? message.providerID,
|
||||
modelLabel: model?.name ?? message.modelID,
|
||||
limit,
|
||||
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,
|
||||
},
|
||||
message,
|
||||
provider,
|
||||
model,
|
||||
providerLabel: provider?.name ?? message.providerID,
|
||||
modelLabel: model?.name ?? message.modelID,
|
||||
limit,
|
||||
input: message.tokens.input,
|
||||
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)
|
||||
}
|
||||
|
||||
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 { useSDK } from "@/context/sdk"
|
||||
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 { createSessionContextFormatter } from "./session-context-format"
|
||||
|
||||
@@ -134,12 +134,12 @@ export function SessionContextTab() {
|
||||
}),
|
||||
)
|
||||
|
||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
|
||||
const ctx = createMemo(() => metrics().context)
|
||||
const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
|
||||
const tokens = createMemo(() => info()?.tokens)
|
||||
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
|
||||
|
||||
const cost = createMemo(() => {
|
||||
return usd().format(metrics().totalCost)
|
||||
return usd().format(info()?.cost ?? 0)
|
||||
})
|
||||
|
||||
const counts = createMemo(() => {
|
||||
@@ -204,14 +204,14 @@ export function SessionContextTab() {
|
||||
{ label: "context.stats.provider", value: providerLabel },
|
||||
{ label: "context.stats.model", value: modelLabel },
|
||||
{ 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.inputTokens", value: () => formatter().number(ctx()?.input) },
|
||||
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.output) },
|
||||
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.reasoning) },
|
||||
{ label: "context.stats.inputTokens", value: () => formatter().number(tokens()?.input) },
|
||||
{ label: "context.stats.outputTokens", value: () => formatter().number(tokens()?.output) },
|
||||
{ label: "context.stats.reasoningTokens", value: () => formatter().number(tokens()?.reasoning) },
|
||||
{
|
||||
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.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
|
||||
|
||||
@@ -541,6 +541,7 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
|
||||
</Show>
|
||||
<Show when={props.state.reviewVisible}>
|
||||
<TooltipV2
|
||||
class="shrink-0"
|
||||
placement="bottom"
|
||||
value={
|
||||
<>
|
||||
|
||||
@@ -399,7 +399,7 @@
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
line-height: 16px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-titlebar-tab-slot]:not(:first-child)::before {
|
||||
[data-titlebar-tab-slot]:not(:first-child):not([data-active="true"])::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
@@ -32,6 +32,10 @@
|
||||
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 {
|
||||
content: "";
|
||||
position: absolute;
|
||||
|
||||
@@ -5,25 +5,29 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { projectForSession } from "@/pages/layout/helpers"
|
||||
import { ServerConnection, serverName } from "@/context/server"
|
||||
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import type { Session } from "@opencode-ai/sdk/v2"
|
||||
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
|
||||
import { TabPreviewPopover } from "./titlebar-tab-popover"
|
||||
import "./titlebar-tab-nav.css"
|
||||
|
||||
// MouseEvent.button uses 1 for the middle/wheel button.
|
||||
const MIDDLE_MOUSE_BUTTON = 1
|
||||
|
||||
export function TabNavItem(props: {
|
||||
ref?: Ref<HTMLDivElement>
|
||||
href: string
|
||||
server: ServerConnection.Key
|
||||
session: () => Session | undefined
|
||||
fallbackTitle?: string
|
||||
onTitleChange?: (title: string) => void
|
||||
onTitleChangeFailed?: (title: string) => void
|
||||
onClose: () => void
|
||||
onNavigate: () => void
|
||||
active?: boolean
|
||||
activeServer: boolean
|
||||
forceTruncate?: boolean
|
||||
suppressNavigation?: () => boolean
|
||||
dragging?: boolean
|
||||
@@ -53,6 +57,28 @@ export function TabNavItem(props: {
|
||||
if (!session) return
|
||||
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
|
||||
})
|
||||
// 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 = () => {
|
||||
if (!titleEl || editing()) {
|
||||
@@ -71,7 +97,7 @@ export function TabNavItem(props: {
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
props.session()?.title
|
||||
title()
|
||||
props.forceTruncate
|
||||
editing()
|
||||
scheduleTitleOverflow()
|
||||
@@ -130,9 +156,9 @@ export function TabNavItem(props: {
|
||||
createEffect(() => {
|
||||
if (editing()) return
|
||||
if (!titleEl) return
|
||||
const title = props.session()?.title
|
||||
if (title === undefined) return
|
||||
titleEl.textContent = title
|
||||
const value = title()
|
||||
if (value === undefined) return
|
||||
titleEl.textContent = value
|
||||
})
|
||||
|
||||
const openRename = (event: MouseEvent) => {
|
||||
@@ -168,7 +194,7 @@ export function TabNavItem(props: {
|
||||
onCleanup(cleanup)
|
||||
})
|
||||
|
||||
return (
|
||||
const tab = (
|
||||
<div
|
||||
ref={(el) => {
|
||||
tabRoot = el
|
||||
@@ -184,77 +210,91 @@ export function TabNavItem(props: {
|
||||
data-dragging={props.dragging}
|
||||
data-pressed={props.pressed}
|
||||
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)
|
||||
}}
|
||||
>
|
||||
<Show when={props.session()}>
|
||||
{(session) => {
|
||||
return (
|
||||
<a
|
||||
data-slot="tab-link"
|
||||
data-titlebar-tab-link
|
||||
href={props.href}
|
||||
draggable={false}
|
||||
onDragStart={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
if (editing()) return
|
||||
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]"
|
||||
>
|
||||
<Show when={title() !== undefined}>
|
||||
<a
|
||||
data-slot="tab-link"
|
||||
data-titlebar-tab-link
|
||||
href={props.href}
|
||||
draggable={false}
|
||||
onDragStart={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
// Navigate on mousedown to shave the press-release delay off tab switches.
|
||||
if (event.button !== 0) return
|
||||
if (editing()) return
|
||||
if (props.suppressNavigation?.()) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
|
||||
if (event.detail > 0) return
|
||||
if (editing()) return
|
||||
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]"
|
||||
>
|
||||
<Show when={props.session()}>
|
||||
{(session) => (
|
||||
<span data-slot="project-avatar-slot">
|
||||
<SessionTabAvatar
|
||||
project={project()}
|
||||
directory={session().directory}
|
||||
sessionId={session().id}
|
||||
activeServer={props.activeServer}
|
||||
server={props.server}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
ref={(el) => {
|
||||
titleEl = el
|
||||
titleEl.textContent = session().title
|
||||
}}
|
||||
data-slot="tab-title"
|
||||
data-titlebar-tab-title
|
||||
class="min-w-0 flex-1 outline-none leading-4"
|
||||
classList={{
|
||||
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
||||
"select-text": editing(),
|
||||
}}
|
||||
contenteditable={editing() ? true : undefined}
|
||||
onDblClick={openRename}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void closeRename(true)
|
||||
return
|
||||
}
|
||||
if (event.key !== "Escape") return
|
||||
event.preventDefault()
|
||||
titleEl.textContent = session().title
|
||||
void closeRename(false)
|
||||
}}
|
||||
onBlur={() => void closeRename(true)}
|
||||
onPointerDown={(event) => {
|
||||
if (!editing()) return
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (!editing()) return
|
||||
event.preventDefault()
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
)
|
||||
}}
|
||||
)}
|
||||
</Show>
|
||||
<span
|
||||
ref={(el) => {
|
||||
titleEl = el
|
||||
titleEl.textContent = title() ?? ""
|
||||
}}
|
||||
data-slot="tab-title"
|
||||
data-titlebar-tab-title
|
||||
class="min-w-0 flex-1 outline-none leading-4"
|
||||
classList={{
|
||||
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
||||
"select-text": editing(),
|
||||
}}
|
||||
contenteditable={editing() ? true : undefined}
|
||||
onDblClick={openRename}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void closeRename(true)
|
||||
return
|
||||
}
|
||||
if (event.key !== "Escape") return
|
||||
event.preventDefault()
|
||||
titleEl.textContent = props.session()?.title ?? ""
|
||||
void closeRename(false)
|
||||
}}
|
||||
onBlur={() => void closeRename(true)}
|
||||
onPointerDown={(event) => {
|
||||
if (!editing()) return
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (!editing()) return
|
||||
event.preventDefault()
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
</Show>
|
||||
|
||||
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
||||
@@ -272,6 +312,23 @@ export function TabNavItem(props: {
|
||||
</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(),
|
||||
serverName: serverLabel(),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DraftTabItem(props: {
|
||||
@@ -302,7 +359,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)]"
|
||||
classList={{ invisible: props.hidden }}
|
||||
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)
|
||||
}}
|
||||
>
|
||||
@@ -315,8 +377,16 @@ export function DraftTabItem(props: {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
// Navigate on mousedown to shave the press-release delay off tab switches.
|
||||
if (event.button !== 0) return
|
||||
if (props.suppressNavigation?.()) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
|
||||
if (event.detail > 0) return
|
||||
if (props.suppressNavigation?.()) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
[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: 6px;
|
||||
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: instant (trying no animation — may revert to 120ms ease-out). */
|
||||
animation: none;
|
||||
|
||||
&:focus-visible,
|
||||
&:focus-within {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Exit: instant (trying no animation — may revert to 80ms ease-out). */
|
||||
&[data-closed] {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Warm streak (rapid tab hopping): appear/disappear instantly so the enter
|
||||
and exit animations don't repeat on every tab. */
|
||||
&[data-instant],
|
||||
&[data-instant][data-closed] {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
[data-slot="header"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-slot="project"] {
|
||||
font-weight: 440;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="title"] {
|
||||
font-weight: 530;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-base);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
[data-slot="row"] {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="detail"] {
|
||||
flex: 1 0 0;
|
||||
min-width: 0;
|
||||
/* Wrap the full path onto the next line instead of clipping the end. */
|
||||
overflow-wrap: anywhere;
|
||||
font-weight: 440;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
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,92 @@
|
||||
import { HoverCard as Kobalte } from "@kobalte/core/hover-card"
|
||||
import { createSignal, Show, type JSXElement } from "solid-js"
|
||||
import "./titlebar-tab-popover.css"
|
||||
|
||||
// Initial hover delay before the preview appears, per design.
|
||||
const OPEN_DELAY = 400
|
||||
// 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 = 500
|
||||
let lastClosedAt = 0
|
||||
|
||||
export interface TabPreviewData {
|
||||
projectName?: string
|
||||
title?: string
|
||||
path?: string
|
||||
serverName?: string
|
||||
}
|
||||
|
||||
export function TabPreviewPopover(props: {
|
||||
trigger: JSXElement
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
data: TabPreviewData
|
||||
}) {
|
||||
let triggerEl: HTMLDivElement | undefined
|
||||
// When opened during a rapid tab-hopping streak, this preview appears and
|
||||
// disappears instantly (no repeated enter/exit animation) — only the first,
|
||||
// "cold" preview animates. Mirrors how browsers reuse one tab tooltip.
|
||||
const [instant, setInstant] = createSignal(false)
|
||||
|
||||
const warm = () => Date.now() - lastClosedAt < SKIP_WINDOW
|
||||
// Kobalte reads openDelay lazily when the pointer enters the trigger, so this
|
||||
// resolves the skip window per-hover.
|
||||
const resolveOpenDelay = () => (warm() ? 0 : OPEN_DELAY)
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (open) setInstant(warm())
|
||||
else 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"
|
||||
data-instant={instant() || undefined}
|
||||
>
|
||||
<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="detail">{props.data.path}</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.data.serverName}>
|
||||
<div data-slot="server">{props.data.serverName}</div>
|
||||
</Show>
|
||||
</Kobalte.Content>
|
||||
</Kobalte.Portal>
|
||||
</Kobalte>
|
||||
)
|
||||
}
|
||||
@@ -24,13 +24,13 @@ function SessionTabSlot(props: {
|
||||
id: string
|
||||
index: () => number
|
||||
active: () => boolean
|
||||
activeServerKey: ServerConnection.Key
|
||||
forceTruncate: boolean
|
||||
serverCtx: () => ServerCtx | undefined
|
||||
onNavigate: (element: HTMLDivElement) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const tabs = useTabs()
|
||||
const language = useLanguage()
|
||||
const sortable = useSortable({
|
||||
get id() {
|
||||
return props.id
|
||||
@@ -50,6 +50,7 @@ function SessionTabSlot(props: {
|
||||
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
|
||||
)
|
||||
const session = createMemo(() => cachedSession() ?? loadedSession())
|
||||
const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session())
|
||||
let prefetched = false
|
||||
|
||||
createEffect(() => {
|
||||
@@ -85,8 +86,9 @@ function SessionTabSlot(props: {
|
||||
ref={sortable.ref}
|
||||
data-titlebar-tab-slot
|
||||
data-tab-key={props.id}
|
||||
data-active={props.active()}
|
||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||
classList={{ hidden: !session() }}
|
||||
classList={{ hidden: !session() && !missingSession() }}
|
||||
>
|
||||
<TabNavItem
|
||||
ref={(el) => {
|
||||
@@ -95,6 +97,7 @@ function SessionTabSlot(props: {
|
||||
href={tabHref(props.tab)}
|
||||
server={props.tab.server}
|
||||
session={session}
|
||||
fallbackTitle={missingSession() ? language.t("session.tab.unknown") : undefined}
|
||||
onTitleChange={(title) => {
|
||||
const value = session()
|
||||
const ctx = props.serverCtx()
|
||||
@@ -108,7 +111,6 @@ function SessionTabSlot(props: {
|
||||
onNavigate={() => props.onNavigate(ref)}
|
||||
onClose={props.onClose}
|
||||
active={props.active()}
|
||||
activeServer={props.tab.server === props.activeServerKey}
|
||||
forceTruncate={props.forceTruncate}
|
||||
dragging={sortable.isDragSource()}
|
||||
/>
|
||||
@@ -140,6 +142,7 @@ function DraftTabSlot(props: {
|
||||
ref={sortable.ref}
|
||||
data-titlebar-tab-slot
|
||||
data-tab-key={props.id}
|
||||
data-active={props.active()}
|
||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||
>
|
||||
<DraftTabItem
|
||||
@@ -160,7 +163,6 @@ function DraftTabSlot(props: {
|
||||
export function TitlebarTabStrip(props: {
|
||||
tabs: Tab[]
|
||||
currentTab: () => Tab | undefined
|
||||
activeServerKey: ServerConnection.Key
|
||||
forceTruncate: boolean
|
||||
onNavigate: (tab: Tab, el?: HTMLDivElement) => void
|
||||
onClose: (tab: Tab) => void
|
||||
@@ -266,7 +268,6 @@ export function TitlebarTabStrip(props: {
|
||||
id={id}
|
||||
index={index}
|
||||
active={() => props.currentTab() === tab}
|
||||
activeServerKey={props.activeServerKey}
|
||||
forceTruncate={props.forceTruncate}
|
||||
serverCtx={serverCtx}
|
||||
onNavigate={(element) => {
|
||||
|
||||
@@ -99,6 +99,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
|
||||
const path = () => `${location.pathname}${location.search}${location.hash}`
|
||||
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.id) return false
|
||||
const parts = location.pathname.replace(/\/+$/, "").split("/")
|
||||
@@ -227,7 +229,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
}}
|
||||
style={{
|
||||
"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,
|
||||
"max-width": electronWindows()
|
||||
? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))`
|
||||
@@ -453,7 +456,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
<TitlebarTabStrip
|
||||
tabs={tabsStore}
|
||||
currentTab={currentTab}
|
||||
activeServerKey={server.key}
|
||||
forceTruncate={tabsAreOverflowing()}
|
||||
onOverflowChange={setTabsAreOverflowing}
|
||||
onNavigate={(tab, el) => {
|
||||
@@ -466,7 +468,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
}}
|
||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||
/>
|
||||
<Show when={!(creating() && params.dir)}>
|
||||
<Show when={!creating()}>
|
||||
<TooltipV2
|
||||
placement="bottom"
|
||||
value={
|
||||
@@ -569,7 +571,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
placement="bottom"
|
||||
title={language.t("command.session.new")}
|
||||
keybind={command.keybind("session.new")}
|
||||
openDelay={2000}
|
||||
openDelay={800}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -599,7 +601,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
>
|
||||
<Show when={hasProjects() && nav()}>
|
||||
<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
|
||||
variant="ghost"
|
||||
icon="chevron-left"
|
||||
@@ -609,7 +611,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
|
||||
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={800}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="chevron-right"
|
||||
|
||||
@@ -85,7 +85,15 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
|
||||
active: null as CommentFocus | null,
|
||||
})
|
||||
|
||||
const all = () => aggregate(store.comments)
|
||||
// Reuse the previous array when contents are unchanged so consumers keep a stable
|
||||
// identity; a fresh array per call cascaded into diff annotation re-renders.
|
||||
let lastAll: LineComment[] = []
|
||||
const all = () => {
|
||||
const next = aggregate(store.comments)
|
||||
if (next.length === lastAll.length && next.every((item, index) => item === lastAll[index])) return lastAll
|
||||
lastAll = next
|
||||
return next
|
||||
}
|
||||
|
||||
const setRef = (
|
||||
key: "focus" | "active",
|
||||
|
||||
@@ -127,10 +127,14 @@ export function createFileTreeStore(options: TreeStoreOptions) {
|
||||
return promise
|
||||
}
|
||||
|
||||
const expandDir = (input: string) => {
|
||||
// `list: false` marks a directory expanded without fetching its children, for
|
||||
// trees whose nodes are synthesized from a filter; listing directories that
|
||||
// only exist on a diff's base branch fails and surfaces error toasts.
|
||||
const expandDir = (input: string, behavior?: { list?: boolean }) => {
|
||||
const dir = options.normalizeDir(input)
|
||||
ensureDir(dir)
|
||||
setTree("dir", dir, "expanded", true)
|
||||
if (behavior?.list === false) return
|
||||
void listDir(dir)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,48 +1,55 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Config, OpencodeClient, Project, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { createServerSession } from "../server-session"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
|
||||
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
||||
|
||||
function directoryState() {
|
||||
return createStore<State>({
|
||||
status: "loading",
|
||||
agent: [],
|
||||
command: [],
|
||||
reference: [],
|
||||
project: "",
|
||||
projectMeta: undefined,
|
||||
icon: undefined,
|
||||
provider_ready: true,
|
||||
provider,
|
||||
config: {},
|
||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
||||
session: [],
|
||||
sessionTotal: 0,
|
||||
session_status: {},
|
||||
session_working(id: string) {
|
||||
return this.session_status[id]?.type !== "idle"
|
||||
},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
mcp_ready: true,
|
||||
mcp: {},
|
||||
mcp_resource: {},
|
||||
lsp_ready: true,
|
||||
lsp: [],
|
||||
vcs: undefined,
|
||||
limit: 5,
|
||||
message: {},
|
||||
part: {},
|
||||
part_text_accum_delta: {},
|
||||
})
|
||||
}
|
||||
|
||||
describe("bootstrapDirectory", () => {
|
||||
test("marks a loading directory partial during bootstrap and complete after success", async () => {
|
||||
const mcpReads: string[] = []
|
||||
const [store, setStore] = createStore<State>({
|
||||
status: "loading",
|
||||
agent: [],
|
||||
command: [],
|
||||
project: "",
|
||||
projectMeta: undefined,
|
||||
icon: undefined,
|
||||
provider_ready: true,
|
||||
provider,
|
||||
config: {},
|
||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
||||
session: [],
|
||||
sessionTotal: 0,
|
||||
session_status: {},
|
||||
session_working(id: string) {
|
||||
return this.session_status[id]?.type !== "idle"
|
||||
},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
mcp_ready: true,
|
||||
mcp: {},
|
||||
lsp_ready: true,
|
||||
lsp: [],
|
||||
vcs: undefined,
|
||||
limit: 5,
|
||||
message: {},
|
||||
part: {},
|
||||
part_text_accum_delta: {},
|
||||
})
|
||||
const [store, setStore] = directoryState()
|
||||
|
||||
await bootstrapDirectory({
|
||||
directory: "/project",
|
||||
@@ -67,6 +74,7 @@ describe("bootstrapDirectory", () => {
|
||||
},
|
||||
permission: { list: async () => ({ data: [] }) },
|
||||
question: { list: async () => ({ data: [] }) },
|
||||
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
|
||||
mcp: {
|
||||
status: async () => {
|
||||
mcpReads.push("status")
|
||||
@@ -90,6 +98,66 @@ describe("bootstrapDirectory", () => {
|
||||
expect(store.status).toBe("complete")
|
||||
expect(mcpReads).toEqual([])
|
||||
})
|
||||
|
||||
test("seeds session status even while warming session info stalls", async () => {
|
||||
const [store, setStore] = directoryState()
|
||||
const stalled = Promise.withResolvers<never>()
|
||||
const client = {
|
||||
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
|
||||
config: { get: async () => ({ data: {} }) },
|
||||
session: {
|
||||
status: async () => ({ data: { ses_busy: { type: "busy" } } }),
|
||||
get: () => stalled.promise,
|
||||
},
|
||||
vcs: { get: async () => ({ data: undefined }) },
|
||||
command: { list: async () => ({ data: [] }) },
|
||||
permission: { list: async () => ({ data: [] }) },
|
||||
question: { list: async () => ({ data: [] }) },
|
||||
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
|
||||
mcp: { status: async () => ({ data: {} }) },
|
||||
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
||||
} as unknown as OpencodeClient
|
||||
const session = createServerSession(client)
|
||||
const stale: Session = {
|
||||
id: "ses_stale",
|
||||
slug: "ses_stale",
|
||||
projectID: "project",
|
||||
directory: "/project",
|
||||
title: "stale",
|
||||
version: "1",
|
||||
time: { created: 1, updated: 1 },
|
||||
}
|
||||
session.remember(stale)
|
||||
session.set("session_status", stale.id, { type: "busy" })
|
||||
|
||||
await bootstrapDirectory({
|
||||
directory: "/project",
|
||||
scope: ServerScope.local,
|
||||
mcp: false,
|
||||
global: {
|
||||
config: {} satisfies Config,
|
||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
||||
project: [{ id: "project", worktree: "/project" } as Project],
|
||||
provider,
|
||||
},
|
||||
sdk: client,
|
||||
store,
|
||||
setStore,
|
||||
vcsCache: { setStore() {} } as unknown as VcsCache,
|
||||
loadSessions() {},
|
||||
translate: (key) => key,
|
||||
queryClient: new QueryClient(),
|
||||
session,
|
||||
})
|
||||
|
||||
const deadline = Date.now() + 500
|
||||
while (!session.data.session_working("ses_busy") && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
|
||||
expect(session.data.session_status["ses_busy"]?.type).toBe("busy")
|
||||
expect(session.data.session_status[stale.id]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("query keys", () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
Project,
|
||||
ProviderAuthResponse,
|
||||
QuestionRequest,
|
||||
ReferenceInfo,
|
||||
Session,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@/utils/toast"
|
||||
@@ -18,7 +19,7 @@ import type { ServerSession } from "../server-session"
|
||||
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
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 { 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!)),
|
||||
})
|
||||
|
||||
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: {
|
||||
directory: string
|
||||
scope: ServerScope
|
||||
@@ -239,25 +247,29 @@ export async function bootstrapDirectory(input: {
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.session.status().then(async (x) => {
|
||||
if (input.session) {
|
||||
const statuses = x.data ?? {}
|
||||
await Promise.all(
|
||||
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
|
||||
)
|
||||
input.session.set(
|
||||
"session_status",
|
||||
produce((draft) => {
|
||||
for (const sessionID of Object.keys(draft)) {
|
||||
if (statuses[sessionID]) continue
|
||||
if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
|
||||
}
|
||||
}),
|
||||
)
|
||||
for (const [sessionID, status] of Object.entries(statuses)) {
|
||||
input.session.set("session_status", sessionID, reconcile(status))
|
||||
}
|
||||
if (!input.session) {
|
||||
input.setStore("session_status", x.data!)
|
||||
return
|
||||
}
|
||||
if (!input.session) input.setStore("session_status", x.data!)
|
||||
const statuses = x.data ?? {}
|
||||
input.session.set(
|
||||
"session_status",
|
||||
produce((draft) => {
|
||||
for (const sessionID of Object.keys(draft)) {
|
||||
if (statuses[sessionID]) continue
|
||||
if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
|
||||
}
|
||||
}),
|
||||
)
|
||||
for (const [sessionID, status] of Object.entries(statuses)) {
|
||||
input.session.set("session_status", sessionID, reconcile(status))
|
||||
}
|
||||
// Warm session info only after seeding statuses so a stalled session
|
||||
// fetch cannot park busy indicators behind it, mirroring how live
|
||||
// session.status events apply first and resolve info in the background.
|
||||
await Promise.all(
|
||||
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
|
||||
)
|
||||
}),
|
||||
),
|
||||
!seededProject &&
|
||||
@@ -277,6 +289,7 @@ export async function bootstrapDirectory(input: {
|
||||
}),
|
||||
),
|
||||
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(() =>
|
||||
input.sdk.permission.list().then((x) => {
|
||||
@@ -339,6 +352,7 @@ export async function bootstrapDirectory(input: {
|
||||
),
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
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) => {
|
||||
const project = getFilename(input.directory)
|
||||
|
||||
@@ -34,7 +34,9 @@ const queryOptionsApi = {
|
||||
}),
|
||||
agents: (directory: string) => ({ queryKey: [directory, "agents"], 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 () => [] }),
|
||||
references: (directory: string) => ({ queryKey: [directory, "references"], queryFn: async () => [] }),
|
||||
sessions: (directory: string) => ({ queryKey: [directory, "loadSessions"] as const }),
|
||||
} as unknown as QueryOptionsApi
|
||||
|
||||
@@ -197,14 +199,18 @@ describe("createChildStoreManager", () => {
|
||||
try {
|
||||
if (!manager) throw new Error("manager required")
|
||||
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 resourceQuery = querySingles[offset + 2]
|
||||
if (!query) throw new Error("query required")
|
||||
if (!resourceQuery) throw new Error("resource query required")
|
||||
expect(query().enabled).toBe(false)
|
||||
expect(resourceQuery().enabled).toBe(false)
|
||||
|
||||
setStore("status", "complete")
|
||||
manager.child("/project", { bootstrap: false, mcp: true })
|
||||
expect(query().enabled).toBe(true)
|
||||
expect(resourceQuery().enabled).toBe(true)
|
||||
expect(store.mcp).toEqual({ demo: { status: "disabled" } })
|
||||
expect(mcpLoads).toEqual(["/project"])
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user