Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a53f8e084 | ||
|
|
6f5b2f786e | ||
|
|
7910ce5d36 | ||
|
|
6ad171dba9 | ||
|
|
556703f8ab | ||
|
|
cb5674edc7 | ||
|
|
b99de4118e | ||
|
|
040700dbc4 | ||
|
|
4d5da9697e | ||
|
|
a28648f530 | ||
|
|
4d81e2d4d9 | ||
|
|
21e72cbf42 | ||
|
|
5f277d1e62 | ||
|
|
d67e877e28 | ||
|
|
d4e51e04b3 | ||
|
|
070c1679e4 | ||
|
|
406d216cd2 | ||
|
|
5dc8b4ef29 | ||
|
|
2f41d89163 | ||
|
|
b2eae867a1 | ||
|
|
3c2fda4d91 | ||
|
|
2678ceb45e | ||
|
|
58a4cd00b6 | ||
|
|
0faa191b6d | ||
|
|
58cf092105 | ||
|
|
0ff8bfe1d9 | ||
|
|
ceb79c786a | ||
|
|
b1a15d559b | ||
|
|
124a8abf9b | ||
|
|
85c2bb342b | ||
|
|
4c57e39466 | ||
|
|
0cdd4e4e16 | ||
|
|
a9b01be0c2 | ||
|
|
528daf5490 | ||
|
|
0e176d3ac3 | ||
|
|
27f359852e | ||
|
|
173128d431 | ||
|
|
e8ee1e239f | ||
|
|
656fa191c1 | ||
|
|
6b9f8fb9b3 | ||
|
|
f77e5cf8fb | ||
|
|
e6cdc21f2d | ||
|
|
1fe8d4d7ad | ||
|
|
e44320980d | ||
|
|
f5d7fe3072 | ||
|
|
835a27cf51 | ||
|
|
85afaaa13d | ||
|
|
490615169e | ||
|
|
bb232247d0 | ||
|
|
94c128f73b | ||
|
|
613562f504 | ||
|
|
9c4325bcf8 | ||
|
|
ad08fd57df | ||
|
|
54ba59d3e1 | ||
|
|
a4330a225d | ||
|
|
69ddc91c35 | ||
|
|
4c4aed5a87 | ||
|
|
5a40158abf | ||
|
|
4dce485854 | ||
|
|
5ec5d1dace | ||
|
|
d2c765e2b3 | ||
|
|
d036c57d59 | ||
|
|
e7493e2204 | ||
|
|
3500bf64b8 | ||
|
|
4f982ddb94 | ||
|
|
ff3bb7424d | ||
|
|
89d6f60d25 | ||
|
|
ee18c9976e | ||
|
|
794532928f | ||
|
|
7b773c65ec | ||
|
|
e53aa79dc6 | ||
|
|
d9a97249c0 | ||
|
|
86cef16940 | ||
|
|
ce38997c76 | ||
|
|
7e10c728d4 | ||
|
|
3627c67cf2 | ||
|
|
2518fd81f6 | ||
|
|
39ef7fc90e | ||
|
|
37ae0a4051 | ||
|
|
b312928e9f | ||
|
|
2f2856e20a | ||
|
|
831eb6881b | ||
|
|
f20ee2fad2 | ||
|
|
8b9710e56c | ||
|
|
c6262f9d40 | ||
|
|
b749fa90f2 | ||
|
|
8a51cbd253 | ||
|
|
399b8f0701 | ||
|
|
3742e42fdf | ||
|
|
0388ec6862 | ||
|
|
366b8a8034 | ||
|
|
ef9bc4ec9e | ||
|
|
5838b58913 | ||
|
|
2712244ad3 | ||
|
|
6388cbaf92 | ||
|
|
5cc61e1b53 | ||
|
|
0243be86a7 | ||
|
|
9154cd64e7 | ||
|
|
c71d1bde5e | ||
|
|
f27ef595f6 | ||
|
|
34328828ae | ||
|
|
18fb19da3b | ||
|
|
849e1ac543 | ||
|
|
656a8d8f55 | ||
|
|
b976f339e8 | ||
|
|
7d7837e5b6 | ||
|
|
1db292f4df | ||
|
|
49a3a9fe36 | ||
|
|
e51ed460a6 | ||
|
|
d15c2ce349 | ||
|
|
5cc4bb4089 | ||
|
|
6e9e027886 | ||
|
|
f9a3d129a4 | ||
|
|
c53d1d3ad8 | ||
|
|
f386137fba | ||
|
|
c797b60069 | ||
|
|
a139e9297d | ||
|
|
050f99ec54 | ||
|
|
23ed652901 | ||
|
|
13a68f3de3 | ||
|
|
fdad35aaa7 | ||
|
|
a2ce4eb650 | ||
|
|
8fa04986cf | ||
|
|
a5710ed3e1 | ||
|
|
2efdc9df93 | ||
|
|
0c245886fe | ||
|
|
f03288b411 | ||
|
|
09388c98f3 | ||
|
|
ae25c1e7b7 | ||
|
|
0813c14cc6 | ||
|
|
b5151c421f | ||
|
|
e66fd079db | ||
|
|
207ebf4b8c | ||
|
|
12d862dbd3 | ||
|
|
981353793d | ||
|
|
426dcfa3b0 | ||
|
|
69cb49f7cc | ||
|
|
e30678a088 | ||
|
|
771b29a857 | ||
|
|
e6d1aae33a | ||
|
|
9dc8ac4734 | ||
|
|
fdd037ba20 | ||
|
|
523f792b48 | ||
|
|
2230c3c401 | ||
|
|
1b494e5087 | ||
|
|
9c43893a0f | ||
|
|
6dfe19b445 | ||
|
|
a965a06259 | ||
|
|
0654f28c72 | ||
|
|
a32b76dee0 | ||
|
|
a52d640c8c | ||
|
|
218869cf45 | ||
|
|
e99d7a4292 | ||
|
|
f0beb38f91 | ||
|
|
66fcab7b08 | ||
|
|
641e1781a2 | ||
|
|
490b95efe7 | ||
|
|
ba1edea0ab | ||
|
|
73c9b685a7 | ||
|
|
99d8aab0ac | ||
|
|
7dd6369952 | ||
|
|
06f60af1e9 | ||
|
|
66d0beba6f | ||
|
|
6b99dd50b6 | ||
|
|
c53c9d4e4e | ||
|
|
bbd0f3a252 | ||
|
|
b7e208b4f1 | ||
|
|
be9b4d1bcd | ||
|
|
5b5b791d75 | ||
|
|
0b7a5b1e7b | ||
|
|
28bb16ca2a | ||
|
|
8a95be492d | ||
|
|
c42c5a0cc6 | ||
|
|
b2c2478d9d | ||
|
|
1a9af8acb6 | ||
|
|
4c7fe60493 | ||
|
|
c108f304c6 | ||
|
|
2b8acfa0e2 | ||
|
|
b83282b940 | ||
|
|
c4fd677785 | ||
|
|
770cb66628 | ||
|
|
b0bc3d87f5 | ||
|
|
a2634337b8 | ||
|
|
7417c869fc | ||
|
|
091cf25de8 | ||
|
|
7a071eff5c | ||
|
|
7da24ebf5d | ||
|
|
d6e0f47361 | ||
|
|
95385eb652 | ||
|
|
a71b11caca | ||
|
|
e9568999c3 | ||
|
|
5e699c9426 | ||
|
|
e0ca52ed1f | ||
|
|
1d9dcd2a27 | ||
|
|
eeeb21ff86 | ||
|
|
2094e8b255 | ||
|
|
e1cf761d29 | ||
|
|
f64bb91257 | ||
|
|
eb9eb5e334 | ||
|
|
d4d1292a0e | ||
|
|
b7605add58 | ||
|
|
6c7d968c44 | ||
|
|
326c70184d | ||
|
|
aec6ca71fa | ||
|
|
c04da45be5 | ||
|
|
74effa8eec | ||
|
|
cb411248bf | ||
|
|
46d7d2fdc0 | ||
|
|
d68afcaa55 | ||
|
|
bf35a865ba | ||
|
|
6733a5a822 | ||
|
|
7e28098365 | ||
|
|
ae5c9ed3dd | ||
|
|
a9bf1c0505 | ||
|
|
dad248832d | ||
|
|
6e89d3e597 | ||
|
|
3ebba02d04 | ||
|
|
cf425d114e | ||
|
|
39691e5174 | ||
|
|
adaee66364 | ||
|
|
a6978167ae | ||
|
|
80c36c788c | ||
|
|
76cdc668e8 | ||
|
|
2ba1ecabc9 | ||
|
|
e3b6d84b57 | ||
|
|
0638e49b02 | ||
|
|
2c58964a6b | ||
|
|
9507b0eace | ||
|
|
4da199697b | ||
|
|
9cccaa693a | ||
|
|
bb37e908ad | ||
|
|
d802e28381 | ||
|
|
7665b8e30d | ||
|
|
a3d4ea0de1 | ||
|
|
152df2428d | ||
|
|
1a420a1a71 | ||
|
|
4c185c70f2 | ||
|
|
6f9e5335dc | ||
|
|
6c9ae5ce9f | ||
|
|
8cbe7b4a01 | ||
|
|
07348d14a2 | ||
|
|
5f40bd42f8 | ||
|
|
0e5edef51e | ||
|
|
3448118be8 | ||
|
|
2bb3dc585b | ||
|
|
27baa2d65c | ||
|
|
62909e917a | ||
|
|
a60e715fc6 | ||
|
|
161734fb95 | ||
|
|
4e26b0aec7 | ||
|
|
6531cfc521 | ||
|
|
6ddd13c6ac | ||
|
|
7948de1612 | ||
|
|
f363904feb | ||
|
|
85ff05670a | ||
|
|
324230806e | ||
|
|
7f7e622426 | ||
|
|
27447bab26 | ||
|
|
45ac20b8aa | ||
|
|
218330aec1 | ||
|
|
22a4c5a77e | ||
|
|
29dbfc25e5 |
@@ -31,6 +31,10 @@ runs:
|
|||||||
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
|
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
|
||||||
bun-download-url: ${{ steps.bun-url.outputs.url }}
|
bun-download-url: ${{ steps.bun-url.outputs.url }}
|
||||||
|
|
||||||
|
- name: Install setuptools for distutils compatibility
|
||||||
|
run: python3 -m pip install setuptools || pip install setuptools || true
|
||||||
|
shell: bash
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install
|
run: bun install
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
name: storybook
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev]
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/storybook.yml"
|
||||||
|
- "package.json"
|
||||||
|
- "bun.lock"
|
||||||
|
- "packages/storybook/**"
|
||||||
|
- "packages/ui/**"
|
||||||
|
pull_request:
|
||||||
|
branches: [dev]
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/storybook.yml"
|
||||||
|
- "package.json"
|
||||||
|
- "bun.lock"
|
||||||
|
- "packages/storybook/**"
|
||||||
|
- "packages/ui/**"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: storybook build
|
||||||
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
|
- name: Build Storybook
|
||||||
|
run: bun --cwd packages/storybook build
|
||||||
@@ -6,6 +6,14 @@ on:
|
|||||||
- dev
|
- dev
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
unit:
|
unit:
|
||||||
name: unit (${{ matrix.settings.name }})
|
name: unit (${{ matrix.settings.name }})
|
||||||
@@ -86,18 +94,3 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
packages/app/e2e/test-results
|
packages/app/e2e/test-results
|
||||||
packages/app/e2e/playwright-report
|
packages/app/e2e/playwright-report
|
||||||
|
|
||||||
required:
|
|
||||||
name: test (linux)
|
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
|
||||||
needs:
|
|
||||||
- unit
|
|
||||||
- e2e
|
|
||||||
if: always()
|
|
||||||
steps:
|
|
||||||
- name: Verify upstream test jobs passed
|
|
||||||
run: |
|
|
||||||
echo "unit=${{ needs.unit.result }}"
|
|
||||||
echo "e2e=${{ needs.e2e.result }}"
|
|
||||||
test "${{ needs.unit.result }}" = "success"
|
|
||||||
test "${{ needs.e2e.result }}" = "success"
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
plans/
|
node_modules
|
||||||
bun.lock
|
plans
|
||||||
package.json
|
package.json
|
||||||
|
bun.lock
|
||||||
|
.gitignore
|
||||||
|
package-lock.json
|
||||||
@@ -5,6 +5,11 @@
|
|||||||
"options": {},
|
"options": {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"permission": {
|
||||||
|
"edit": {
|
||||||
|
"packages/opencode/migration/*": "deny",
|
||||||
|
},
|
||||||
|
},
|
||||||
"mcp": {},
|
"mcp": {},
|
||||||
"tools": {
|
"tools": {
|
||||||
"github-triage": false,
|
"github-triage": false,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
/// <reference path="../env.d.ts" />
|
/// <reference path="../env.d.ts" />
|
||||||
import { tool } from "@opencode-ai/plugin"
|
import { tool } from "@opencode-ai/plugin"
|
||||||
import DESCRIPTION from "./github-pr-search.txt"
|
|
||||||
|
|
||||||
async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
||||||
const response = await fetch(`https://api.github.com${endpoint}`, {
|
const response = await fetch(`https://api.github.com${endpoint}`, {
|
||||||
@@ -24,7 +23,16 @@ interface PR {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default tool({
|
export default tool({
|
||||||
description: DESCRIPTION,
|
description: `Use this tool to search GitHub pull requests by title and description.
|
||||||
|
|
||||||
|
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
|
||||||
|
- PR number and title
|
||||||
|
- Author
|
||||||
|
- State (open/closed/merged)
|
||||||
|
- Labels
|
||||||
|
- Description snippet
|
||||||
|
|
||||||
|
Use the query parameter to search for keywords that might appear in PR titles or descriptions.`,
|
||||||
args: {
|
args: {
|
||||||
query: tool.schema.string().describe("Search query for PR titles and descriptions"),
|
query: tool.schema.string().describe("Search query for PR titles and descriptions"),
|
||||||
limit: tool.schema.number().describe("Maximum number of results to return").default(10),
|
limit: tool.schema.number().describe("Maximum number of results to return").default(10),
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
Use this tool to search GitHub pull requests by title and description.
|
|
||||||
|
|
||||||
This tool searches PRs in the sst/opencode repository and returns LLM-friendly results including:
|
|
||||||
- PR number and title
|
|
||||||
- Author
|
|
||||||
- State (open/closed/merged)
|
|
||||||
- Labels
|
|
||||||
- Description snippet
|
|
||||||
|
|
||||||
Use the query parameter to search for keywords that might appear in PR titles or descriptions.
|
|
||||||
@@ -1,20 +1,11 @@
|
|||||||
/// <reference path="../env.d.ts" />
|
/// <reference path="../env.d.ts" />
|
||||||
import { tool } from "@opencode-ai/plugin"
|
import { tool } from "@opencode-ai/plugin"
|
||||||
import DESCRIPTION from "./github-triage.txt"
|
|
||||||
|
|
||||||
const TEAM = {
|
const TEAM = {
|
||||||
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
|
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
|
||||||
zen: ["fwang", "MrMushrooooom"],
|
zen: ["fwang", "MrMushrooooom"],
|
||||||
tui: [
|
tui: ["thdxr", "kommander", "rekram1-node"],
|
||||||
"thdxr",
|
core: ["thdxr", "rekram1-node", "jlongster"],
|
||||||
"kommander",
|
|
||||||
// "rekram1-node" (on vacation)
|
|
||||||
],
|
|
||||||
core: [
|
|
||||||
"thdxr",
|
|
||||||
// "rekram1-node", (on vacation)
|
|
||||||
"jlongster",
|
|
||||||
],
|
|
||||||
docs: ["R44VC0RP"],
|
docs: ["R44VC0RP"],
|
||||||
windows: ["Hona"],
|
windows: ["Hona"],
|
||||||
} as const
|
} as const
|
||||||
@@ -48,9 +39,17 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default tool({
|
export default tool({
|
||||||
description: DESCRIPTION,
|
description: `Use this tool to assign and/or label a GitHub issue.
|
||||||
|
|
||||||
|
Choose labels and assignee using the current triage policy and ownership rules.
|
||||||
|
Pick the most fitting labels for the issue and assign one owner.
|
||||||
|
|
||||||
|
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`,
|
||||||
args: {
|
args: {
|
||||||
assignee: tool.schema.enum(ASSIGNEES as [string, ...string[]]).describe("The username of the assignee"),
|
assignee: tool.schema
|
||||||
|
.enum(ASSIGNEES as [string, ...string[]])
|
||||||
|
.describe("The username of the assignee")
|
||||||
|
.default("rekram1-node"),
|
||||||
labels: tool.schema
|
labels: tool.schema
|
||||||
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
|
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
|
||||||
.describe("The labels(s) to add to the issue")
|
.describe("The labels(s) to add to the issue")
|
||||||
@@ -73,8 +72,7 @@ export default tool({
|
|||||||
results.push("Dropped label: nix (issue does not mention nix)")
|
results.push("Dropped label: nix (issue does not mention nix)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
|
const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
|
||||||
const assignee = web ? pick(TEAM.desktop) : args.assignee
|
|
||||||
|
|
||||||
if (labels.includes("zen") && !zen) {
|
if (labels.includes("zen") && !zen) {
|
||||||
throw new Error("Only add the zen label when issue title/body contains 'zen'")
|
throw new Error("Only add the zen label when issue title/body contains 'zen'")
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
Use this tool to assign and/or label a GitHub issue.
|
|
||||||
|
|
||||||
Choose labels and assignee using the current triage policy and ownership rules.
|
|
||||||
Pick the most fitting labels for the issue and assign one owner.
|
|
||||||
|
|
||||||
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.
|
|
||||||
|
|
||||||
(Note: rekram1-node is on vacation, do not assign issues to him.)
|
|
||||||
@@ -122,3 +122,7 @@ const table = sqliteTable("session", {
|
|||||||
- Avoid mocks as much as possible
|
- Avoid mocks as much as possible
|
||||||
- Test actual implementation, do not duplicate logic into tests
|
- Test actual implementation, do not duplicate logic into tests
|
||||||
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
|
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
|
||||||
|
|
||||||
|
## Type Checking
|
||||||
|
|
||||||
|
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
|
||||||
|
|||||||
+2
-1
@@ -35,7 +35,8 @@
|
|||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a> |
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
<a href="README.gr.md">Ελληνικά</a>
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+2
-1
@@ -35,7 +35,8 @@
|
|||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a> |
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
<a href="README.gr.md">Ελληνικά</a>
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -27,6 +27,7 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
@@ -34,7 +35,8 @@
|
|||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a> |
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
<a href="README.gr.md">Ελληνικά</a>
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+2
-1
@@ -35,7 +35,8 @@
|
|||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a> |
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
<a href="README.gr.md">Ελληνικά</a>
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -27,6 +27,7 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
@@ -34,7 +35,8 @@
|
|||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a> |
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
<a href="README.gr.md">Ελληνικά</a>
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -27,6 +27,7 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
@@ -34,7 +35,8 @@
|
|||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a> |
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
<a href="README.gr.md">Ελληνικά</a>
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -27,6 +27,7 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
@@ -34,7 +35,8 @@
|
|||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a> |
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
<a href="README.gr.md">Ελληνικά</a>
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -27,6 +27,7 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
@@ -34,7 +35,8 @@
|
|||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a> |
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
<a href="README.gr.md">Ελληνικά</a>
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+2
-1
@@ -35,7 +35,8 @@
|
|||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a> |
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
<a href="README.gr.md">Ελληνικά</a>
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -27,6 +27,7 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
@@ -34,7 +35,8 @@
|
|||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a> |
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
<a href="README.gr.md">Ελληνικά</a>
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -27,6 +27,7 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
@@ -34,7 +35,8 @@
|
|||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a> |
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
<a href="README.gr.md">Ελληνικά</a>
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -27,6 +27,7 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
@@ -34,7 +35,8 @@
|
|||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a> |
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
<a href="README.gr.md">Ελληνικά</a>
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
@@ -35,7 +35,8 @@
|
|||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a> |
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
<a href="README.gr.md">Ελληνικά</a>
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -127,7 +128,7 @@ If you are working on a project that's related to OpenCode and is using "opencod
|
|||||||
|
|
||||||
#### How is this different from Claude Code?
|
#### How is this different from Claude Code?
|
||||||
|
|
||||||
It's very similar to Claude Code in terms of capability. Here are the key differences:
|
It's very similar to Claude Code in terms of capability. Here are the key differences::
|
||||||
|
|
||||||
- 100% open source
|
- 100% open source
|
||||||
- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important.
|
- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important.
|
||||||
|
|||||||
+3
-1
@@ -27,6 +27,7 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
@@ -34,7 +35,8 @@
|
|||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a> |
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
<a href="README.gr.md">Ελληνικά</a>
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -27,6 +27,7 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
@@ -34,7 +35,8 @@
|
|||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a> |
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
<a href="README.gr.md">Ελληνικά</a>
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -27,6 +27,7 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
@@ -34,7 +35,8 @@
|
|||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a> |
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
<a href="README.gr.md">Ελληνικά</a>
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -27,6 +27,7 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
@@ -34,7 +35,8 @@
|
|||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a> |
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
<a href="README.gr.md">Ελληνικά</a>
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -27,6 +27,7 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
@@ -34,7 +35,8 @@
|
|||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a> |
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
<a href="README.gr.md">Ελληνικά</a>
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+2
-1
@@ -35,7 +35,8 @@
|
|||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a> |
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
<a href="README.gr.md">Ελληνικά</a>
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
<p align="center">
|
||||||
|
<a href="https://opencode.ai">
|
||||||
|
<picture>
|
||||||
|
<source srcset="packages/console/app/src/asset/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
|
||||||
|
<source srcset="packages/console/app/src/asset/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
|
||||||
|
<img src="packages/console/app/src/asset/logo-ornate-light.svg" alt="OpenCode logo">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p align="center">Trợ lý lập trình AI mã nguồn mở.</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||||
|
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||||
|
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="README.md">English</a> |
|
||||||
|
<a href="README.zh.md">简体中文</a> |
|
||||||
|
<a href="README.zht.md">繁體中文</a> |
|
||||||
|
<a href="README.ko.md">한국어</a> |
|
||||||
|
<a href="README.de.md">Deutsch</a> |
|
||||||
|
<a href="README.es.md">Español</a> |
|
||||||
|
<a href="README.fr.md">Français</a> |
|
||||||
|
<a href="README.it.md">Italiano</a> |
|
||||||
|
<a href="README.da.md">Dansk</a> |
|
||||||
|
<a href="README.ja.md">日本語</a> |
|
||||||
|
<a href="README.pl.md">Polski</a> |
|
||||||
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
|
<a href="README.ar.md">العربية</a> |
|
||||||
|
<a href="README.no.md">Norsk</a> |
|
||||||
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
|
<a href="README.th.md">ไทย</a> |
|
||||||
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a> |
|
||||||
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
[](https://opencode.ai)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Cài đặt
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# YOLO
|
||||||
|
curl -fsSL https://opencode.ai/install | bash
|
||||||
|
|
||||||
|
# Các trình quản lý gói (Package managers)
|
||||||
|
npm i -g opencode-ai@latest # hoặc bun/pnpm/yarn
|
||||||
|
scoop install opencode # Windows
|
||||||
|
choco install opencode # Windows
|
||||||
|
brew install anomalyco/tap/opencode # macOS và Linux (khuyên dùng, luôn cập nhật)
|
||||||
|
brew install opencode # macOS và Linux (công thức brew chính thức, ít cập nhật hơn)
|
||||||
|
sudo pacman -S opencode # Arch Linux (Bản ổn định)
|
||||||
|
paru -S opencode-bin # Arch Linux (Bản mới nhất từ AUR)
|
||||||
|
mise use -g opencode # Mọi hệ điều hành
|
||||||
|
nix run nixpkgs#opencode # hoặc github:anomalyco/opencode cho nhánh dev mới nhất
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Hãy xóa các phiên bản cũ hơn 0.1.x trước khi cài đặt.
|
||||||
|
|
||||||
|
### Ứng dụng Desktop (BETA)
|
||||||
|
|
||||||
|
OpenCode cũng có sẵn dưới dạng ứng dụng desktop. Tải trực tiếp từ [trang releases](https://github.com/anomalyco/opencode/releases) hoặc [opencode.ai/download](https://opencode.ai/download).
|
||||||
|
|
||||||
|
| Nền tảng | Tải xuống |
|
||||||
|
| --------------------- | ------------------------------------- |
|
||||||
|
| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
|
||||||
|
| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
|
||||||
|
| Windows | `opencode-desktop-windows-x64.exe` |
|
||||||
|
| Linux | `.deb`, `.rpm`, hoặc AppImage |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS (Homebrew)
|
||||||
|
brew install --cask opencode-desktop
|
||||||
|
# Windows (Scoop)
|
||||||
|
scoop bucket add extras; scoop install extras/opencode-desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Thư mục cài đặt
|
||||||
|
|
||||||
|
Tập lệnh cài đặt tuân theo thứ tự ưu tiên sau cho đường dẫn cài đặt:
|
||||||
|
|
||||||
|
1. `$OPENCODE_INSTALL_DIR` - Thư mục cài đặt tùy chỉnh
|
||||||
|
2. `$XDG_BIN_DIR` - Đường dẫn tuân thủ XDG Base Directory Specification
|
||||||
|
3. `$HOME/bin` - Thư mục nhị phân tiêu chuẩn của người dùng (nếu tồn tại hoặc có thể tạo)
|
||||||
|
4. `$HOME/.opencode/bin` - Mặc định dự phòng
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ví dụ
|
||||||
|
OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
|
||||||
|
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
### Agents (Đại diện)
|
||||||
|
|
||||||
|
OpenCode bao gồm hai agent được tích hợp sẵn mà bạn có thể chuyển đổi bằng phím `Tab`.
|
||||||
|
|
||||||
|
- **build** - Agent mặc định, có toàn quyền truy cập cho công việc lập trình
|
||||||
|
- **plan** - Agent chỉ đọc dùng để phân tích và khám phá mã nguồn
|
||||||
|
- Mặc định từ chối việc chỉnh sửa tệp
|
||||||
|
- Hỏi quyền trước khi chạy các lệnh bash
|
||||||
|
- Lý tưởng để khám phá các codebase lạ hoặc lên kế hoạch thay đổi
|
||||||
|
|
||||||
|
Ngoài ra còn có một subagent **general** dùng cho các tìm kiếm phức tạp và tác vụ nhiều bước.
|
||||||
|
Agent này được sử dụng nội bộ và có thể gọi bằng cách dùng `@general` trong tin nhắn.
|
||||||
|
|
||||||
|
Tìm hiểu thêm về [agents](https://opencode.ai/docs/agents).
|
||||||
|
|
||||||
|
### Tài liệu
|
||||||
|
|
||||||
|
Để biết thêm thông tin về cách cấu hình OpenCode, [**hãy truy cập tài liệu của chúng tôi**](https://opencode.ai/docs).
|
||||||
|
|
||||||
|
### Đóng góp
|
||||||
|
|
||||||
|
Nếu bạn muốn đóng góp cho OpenCode, vui lòng đọc [tài liệu hướng dẫn đóng góp](./CONTRIBUTING.md) trước khi gửi pull request.
|
||||||
|
|
||||||
|
### Xây dựng trên nền tảng OpenCode
|
||||||
|
|
||||||
|
Nếu bạn đang làm việc trên một dự án liên quan đến OpenCode và sử dụng "opencode" như một phần của tên dự án, ví dụ "opencode-dashboard" hoặc "opencode-mobile", vui lòng thêm một ghi chú vào README của bạn để làm rõ rằng dự án đó không được xây dựng bởi đội ngũ OpenCode và không liên kết với chúng tôi dưới bất kỳ hình thức nào.
|
||||||
|
|
||||||
|
### Các câu hỏi thường gặp (FAQ)
|
||||||
|
|
||||||
|
#### OpenCode khác biệt thế nào so với Claude Code?
|
||||||
|
|
||||||
|
Về mặt tính năng, nó rất giống Claude Code. Dưới đây là những điểm khác biệt chính:
|
||||||
|
|
||||||
|
- 100% mã nguồn mở
|
||||||
|
- Không bị ràng buộc với bất kỳ nhà cung cấp nào. Mặc dù chúng tôi khuyên dùng các mô hình được cung cấp qua [OpenCode Zen](https://opencode.ai/zen), OpenCode có thể được sử dụng với Claude, OpenAI, Google, hoặc thậm chí các mô hình chạy cục bộ. Khi các mô hình phát triển, khoảng cách giữa chúng sẽ thu hẹp lại và giá cả sẽ giảm, vì vậy việc không phụ thuộc vào nhà cung cấp là rất quan trọng.
|
||||||
|
- Hỗ trợ LSP ngay từ đầu
|
||||||
|
- Tập trung vào TUI (Giao diện người dùng dòng lệnh). OpenCode được xây dựng bởi những người dùng neovim và đội ngũ tạo ra [terminal.shop](https://terminal.shop); chúng tôi sẽ đẩy giới hạn của những gì có thể làm được trên terminal lên mức tối đa.
|
||||||
|
- Kiến trúc client/server. Chẳng hạn, điều này cho phép OpenCode chạy trên máy tính của bạn trong khi bạn điều khiển nó từ xa qua một ứng dụng di động, nghĩa là frontend TUI chỉ là một trong những client có thể dùng.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Tham gia cộng đồng của chúng tôi** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||||
+3
-1
@@ -27,6 +27,7 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
@@ -34,7 +35,8 @@
|
|||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a> |
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
<a href="README.gr.md">Ελληνικά</a>
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -27,6 +27,7 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
@@ -34,7 +35,8 @@
|
|||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a> |
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
<a href="README.gr.md">Ελληνικά</a>
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+2
-1
@@ -8,6 +8,7 @@ import type { Context as GitHubContext } from "@actions/github/lib/context"
|
|||||||
import type { IssueCommentEvent, PullRequestReviewCommentEvent } from "@octokit/webhooks-types"
|
import type { IssueCommentEvent, PullRequestReviewCommentEvent } from "@octokit/webhooks-types"
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||||
import { spawn } from "node:child_process"
|
import { spawn } from "node:child_process"
|
||||||
|
import { setTimeout as sleep } from "node:timers/promises"
|
||||||
|
|
||||||
type GitHubAuthor = {
|
type GitHubAuthor = {
|
||||||
login: string
|
login: string
|
||||||
@@ -281,7 +282,7 @@ async function assertOpencodeConnected() {
|
|||||||
connected = true
|
connected = true
|
||||||
break
|
break
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
await Bun.sleep(300)
|
await sleep(300)
|
||||||
} while (retry++ < 30)
|
} while (retry++ < 30)
|
||||||
|
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-jtBYpfiE9g0otqZEtOksW1Nbg+O8CJP9OEOEhsa7sa8=",
|
"x86_64-linux": "sha256-dhL4YeSi4Lm9yDp919Fx7N2hyLUbZQa2qWoCf/50ce8=",
|
||||||
"aarch64-linux": "sha256-m+YNZIB7I7EMPyfqkKsvDvmBX9R1szmEKxXpxTNFLH8=",
|
"aarch64-linux": "sha256-//YxCsrvYlxuvd0MtFFO+pLxjmuemyrvGzSIPxzO+rA=",
|
||||||
"aarch64-darwin": "sha256-1gVmtkC1/I8sdHZcaeSFJheySVlpCyKCjf9zbVsVqAQ=",
|
"aarch64-darwin": "sha256-c65kSWteQNaBcQUsjbXNqT61vt98JPNYo9yMNvUygCw=",
|
||||||
"x86_64-darwin": "sha256-Tvk5YL6Z0xRul4jopbGme/997iHBylXC0Cq3RnjQb+I="
|
"x86_64-darwin": "sha256-hlTzEFv3nZHwlDXU65LfMC+NaqYjjyZqagdJ366CNxY="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -11,6 +11,7 @@
|
|||||||
"dev:web": "bun --cwd packages/app dev",
|
"dev:web": "bun --cwd packages/app dev",
|
||||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||||
"typecheck": "bun turbo typecheck",
|
"typecheck": "bun turbo typecheck",
|
||||||
|
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"random": "echo 'Random script'",
|
"random": "echo 'Random script'",
|
||||||
"hello": "echo 'Hello World!'",
|
"hello": "echo 'Hello World!'",
|
||||||
@@ -41,8 +42,9 @@
|
|||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
"diff": "8.0.2",
|
"diff": "8.0.2",
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"drizzle-kit": "1.0.0-beta.12-a5629fb",
|
"effect": "4.0.0-beta.29",
|
||||||
"drizzle-orm": "1.0.0-beta.12-a5629fb",
|
"drizzle-kit": "1.0.0-beta.16-c2458b2",
|
||||||
|
"drizzle-orm": "1.0.0-beta.16-c2458b2",
|
||||||
"ai": "5.0.124",
|
"ai": "5.0.124",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
@@ -77,7 +79,7 @@
|
|||||||
"prettier": "3.6.2",
|
"prettier": "3.6.2",
|
||||||
"semver": "^7.6.0",
|
"semver": "^7.6.0",
|
||||||
"sst": "3.18.10",
|
"sst": "3.18.10",
|
||||||
"turbo": "2.5.6"
|
"turbo": "2.8.13"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "3.933.0",
|
"@aws-sdk/client-s3": "3.933.0",
|
||||||
@@ -97,6 +99,7 @@
|
|||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"esbuild",
|
"esbuild",
|
||||||
|
"node-pty",
|
||||||
"protobufjs",
|
"protobufjs",
|
||||||
"tree-sitter",
|
"tree-sitter",
|
||||||
"tree-sitter-bash",
|
"tree-sitter-bash",
|
||||||
|
|||||||
@@ -71,6 +71,12 @@ test("test description", async ({ page, sdk, gotoSession }) => {
|
|||||||
- `closeDialog(page, dialog)` - Close any dialog
|
- `closeDialog(page, dialog)` - Close any dialog
|
||||||
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
|
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
|
||||||
- `withSession(sdk, title, callback)` - Create temp session
|
- `withSession(sdk, title, callback)` - Create temp session
|
||||||
|
- `withProject(...)` - Create temp project/workspace
|
||||||
|
- `sessionIDFromUrl(url)` - Read session ID from URL
|
||||||
|
- `slugFromUrl(url)` - Read workspace slug from URL
|
||||||
|
- `waitSlug(page, skip?)` - Wait for resolved workspace slug
|
||||||
|
- `trackSession(sessionID, directory?)` - Register session for fixture cleanup
|
||||||
|
- `trackDirectory(directory)` - Register directory for fixture cleanup
|
||||||
- `clickListItem(container, filter)` - Click list item by key/text
|
- `clickListItem(container, filter)` - Click list item by key/text
|
||||||
|
|
||||||
**Selectors** (`selectors.ts`):
|
**Selectors** (`selectors.ts`):
|
||||||
@@ -109,7 +115,7 @@ import { test, expect } from "@playwright/test"
|
|||||||
|
|
||||||
### Error Handling
|
### Error Handling
|
||||||
|
|
||||||
Tests should clean up after themselves:
|
Tests should clean up after themselves. Prefer fixture-managed cleanup:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
test("test with cleanup", async ({ page, sdk, gotoSession }) => {
|
test("test with cleanup", async ({ page, sdk, gotoSession }) => {
|
||||||
@@ -120,6 +126,11 @@ test("test with cleanup", async ({ page, sdk, gotoSession }) => {
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- Prefer `withSession(...)` for temp sessions
|
||||||
|
- In `withProject(...)` tests that create sessions or extra workspaces, call `trackSession(sessionID, directory?)` and `trackDirectory(directory)`
|
||||||
|
- This lets fixture teardown abort, wait for idle, and clean up safely under CI concurrency
|
||||||
|
- Avoid calling `sdk.session.delete(...)` directly
|
||||||
|
|
||||||
### Timeouts
|
### Timeouts
|
||||||
|
|
||||||
Default: 60s per test, 10s per assertion. Override when needed:
|
Default: 60s per test, 10s per assertion. Override when needed:
|
||||||
@@ -161,9 +172,10 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings
|
|||||||
1. Choose appropriate folder or create new one
|
1. Choose appropriate folder or create new one
|
||||||
2. Import from `../fixtures`
|
2. Import from `../fixtures`
|
||||||
3. Use helper functions from `../actions` and `../selectors`
|
3. Use helper functions from `../actions` and `../selectors`
|
||||||
4. Clean up any created resources
|
4. When validating routing, use shared helpers from `../actions`. Workspace URL slugs can be canonicalized on Windows, so assert against canonical or resolved workspace slugs.
|
||||||
5. Use specific selectors (avoid CSS classes)
|
5. Clean up any created resources
|
||||||
6. Test one feature per test file
|
6. Use specific selectors (avoid CSS classes)
|
||||||
|
7. Test one feature per test file
|
||||||
|
|
||||||
## Local Development
|
## Local Development
|
||||||
|
|
||||||
|
|||||||
+185
-50
@@ -3,12 +3,12 @@ import fs from "node:fs/promises"
|
|||||||
import os from "node:os"
|
import os from "node:os"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { execSync } from "node:child_process"
|
import { execSync } from "node:child_process"
|
||||||
import { modKey, serverUrl } from "./utils"
|
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
||||||
import {
|
import {
|
||||||
sessionItemSelector,
|
|
||||||
dropdownMenuTriggerSelector,
|
dropdownMenuTriggerSelector,
|
||||||
dropdownMenuContentSelector,
|
dropdownMenuContentSelector,
|
||||||
projectMenuTriggerSelector,
|
projectMenuTriggerSelector,
|
||||||
|
projectCloseMenuSelector,
|
||||||
projectWorkspacesToggleSelector,
|
projectWorkspacesToggleSelector,
|
||||||
titlebarRightSelector,
|
titlebarRightSelector,
|
||||||
popoverBodySelector,
|
popoverBodySelector,
|
||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
workspaceItemSelector,
|
workspaceItemSelector,
|
||||||
workspaceMenuTriggerSelector,
|
workspaceMenuTriggerSelector,
|
||||||
} from "./selectors"
|
} from "./selectors"
|
||||||
import type { createSdk } from "./utils"
|
|
||||||
|
|
||||||
export async function defocus(page: Page) {
|
export async function defocus(page: Page) {
|
||||||
await page
|
await page
|
||||||
@@ -61,9 +60,9 @@ export async function closeDialog(page: Page, dialog: Locator) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function isSidebarClosed(page: Page) {
|
export async function isSidebarClosed(page: Page) {
|
||||||
const main = page.locator("main")
|
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||||
const classes = (await main.getAttribute("class")) ?? ""
|
await expect(button).toBeVisible()
|
||||||
return classes.includes("xl:border-l")
|
return (await button.getAttribute("aria-expanded")) !== "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toggleSidebar(page: Page) {
|
export async function toggleSidebar(page: Page) {
|
||||||
@@ -75,48 +74,34 @@ export async function openSidebar(page: Page) {
|
|||||||
if (!(await isSidebarClosed(page))) return
|
if (!(await isSidebarClosed(page))) return
|
||||||
|
|
||||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||||
const visible = await button
|
await button.click()
|
||||||
.isVisible()
|
|
||||||
.then((x) => x)
|
|
||||||
.catch(() => false)
|
|
||||||
|
|
||||||
if (visible) await button.click()
|
const opened = await expect(button)
|
||||||
if (!visible) await toggleSidebar(page)
|
.toHaveAttribute("aria-expanded", "true", { timeout: 1500 })
|
||||||
|
|
||||||
const main = page.locator("main")
|
|
||||||
const opened = await expect(main)
|
|
||||||
.not.toHaveClass(/xl:border-l/, { timeout: 1500 })
|
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
|
|
||||||
if (opened) return
|
if (opened) return
|
||||||
|
|
||||||
await toggleSidebar(page)
|
await toggleSidebar(page)
|
||||||
await expect(main).not.toHaveClass(/xl:border-l/)
|
await expect(button).toHaveAttribute("aria-expanded", "true")
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function closeSidebar(page: Page) {
|
export async function closeSidebar(page: Page) {
|
||||||
if (await isSidebarClosed(page)) return
|
if (await isSidebarClosed(page)) return
|
||||||
|
|
||||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||||
const visible = await button
|
await button.click()
|
||||||
.isVisible()
|
|
||||||
.then((x) => x)
|
|
||||||
.catch(() => false)
|
|
||||||
|
|
||||||
if (visible) await button.click()
|
const closed = await expect(button)
|
||||||
if (!visible) await toggleSidebar(page)
|
.toHaveAttribute("aria-expanded", "false", { timeout: 1500 })
|
||||||
|
|
||||||
const main = page.locator("main")
|
|
||||||
const closed = await expect(main)
|
|
||||||
.toHaveClass(/xl:border-l/, { timeout: 1500 })
|
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
|
|
||||||
if (closed) return
|
if (closed) return
|
||||||
|
|
||||||
await toggleSidebar(page)
|
await toggleSidebar(page)
|
||||||
await expect(main).toHaveClass(/xl:border-l/)
|
await expect(button).toHaveAttribute("aria-expanded", "false")
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openSettings(page: Page) {
|
export async function openSettings(page: Page) {
|
||||||
@@ -197,17 +182,48 @@ export async function createTestProject() {
|
|||||||
await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
|
await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
|
||||||
|
|
||||||
execSync("git init", { cwd: root, stdio: "ignore" })
|
execSync("git init", { cwd: root, stdio: "ignore" })
|
||||||
|
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
|
||||||
execSync("git add -A", { cwd: root, stdio: "ignore" })
|
execSync("git add -A", { cwd: root, stdio: "ignore" })
|
||||||
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
|
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
stdio: "ignore",
|
stdio: "ignore",
|
||||||
})
|
})
|
||||||
|
|
||||||
return root
|
return resolveDirectory(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanupTestProject(directory: string) {
|
export async function cleanupTestProject(directory: string) {
|
||||||
await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined)
|
try {
|
||||||
|
execSync("git fsmonitor--daemon stop", { cwd: directory, stdio: "ignore" })
|
||||||
|
} catch {}
|
||||||
|
await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function slugFromUrl(url: string) {
|
||||||
|
return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitSlug(page: Page, skip: string[] = []) {
|
||||||
|
let prev = ""
|
||||||
|
let next = ""
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
() => {
|
||||||
|
const slug = slugFromUrl(page.url())
|
||||||
|
if (!slug) return ""
|
||||||
|
if (skip.includes(slug)) return ""
|
||||||
|
if (slug !== prev) {
|
||||||
|
prev = slug
|
||||||
|
next = ""
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
next = slug
|
||||||
|
return slug
|
||||||
|
},
|
||||||
|
{ timeout: 45_000 },
|
||||||
|
)
|
||||||
|
.not.toBe("")
|
||||||
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sessionIDFromUrl(url: string) {
|
export function sessionIDFromUrl(url: string) {
|
||||||
@@ -216,7 +232,7 @@ export function sessionIDFromUrl(url: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function hoverSessionItem(page: Page, sessionID: string) {
|
export async function hoverSessionItem(page: Page, sessionID: string) {
|
||||||
const sessionEl = page.locator(sessionItemSelector(sessionID)).first()
|
const sessionEl = page.locator(`[data-session-id="${sessionID}"]`).last()
|
||||||
await expect(sessionEl).toBeVisible()
|
await expect(sessionEl).toBeVisible()
|
||||||
await sessionEl.hover()
|
await sessionEl.hover()
|
||||||
return sessionEl
|
return sessionEl
|
||||||
@@ -317,6 +333,57 @@ export async function clickListItem(
|
|||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function status(sdk: ReturnType<typeof createSdk>, sessionID: string) {
|
||||||
|
const data = await sdk.session
|
||||||
|
.status()
|
||||||
|
.then((x) => x.data ?? {})
|
||||||
|
.catch(() => undefined)
|
||||||
|
return data?.[sessionID]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stable(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 10_000) {
|
||||||
|
let prev = ""
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const info = await sdk.session
|
||||||
|
.get({ sessionID })
|
||||||
|
.then((x) => x.data)
|
||||||
|
.catch(() => undefined)
|
||||||
|
if (!info) return true
|
||||||
|
const next = `${info.title}:${info.time.updated ?? info.time.created}`
|
||||||
|
if (next !== prev) {
|
||||||
|
prev = next
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
{ timeout },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitSessionIdle(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 30_000) {
|
||||||
|
await expect.poll(() => status(sdk, sessionID).then((x) => !x || x.type === "idle"), { timeout }).toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupSession(input: {
|
||||||
|
sessionID: string
|
||||||
|
directory?: string
|
||||||
|
sdk?: ReturnType<typeof createSdk>
|
||||||
|
}) {
|
||||||
|
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory) : undefined)
|
||||||
|
if (!sdk) throw new Error("cleanupSession requires sdk or directory")
|
||||||
|
await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
|
||||||
|
const current = await status(sdk, input.sessionID).catch(() => undefined)
|
||||||
|
if (current && current.type !== "idle") {
|
||||||
|
await sdk.session.abort({ sessionID: input.sessionID }).catch(() => undefined)
|
||||||
|
await waitSessionIdle(sdk, input.sessionID).catch(() => undefined)
|
||||||
|
}
|
||||||
|
await stable(sdk, input.sessionID).catch(() => undefined)
|
||||||
|
await sdk.session.delete({ sessionID: input.sessionID }).catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
export async function withSession<T>(
|
export async function withSession<T>(
|
||||||
sdk: ReturnType<typeof createSdk>,
|
sdk: ReturnType<typeof createSdk>,
|
||||||
title: string,
|
title: string,
|
||||||
@@ -328,7 +395,7 @@ export async function withSession<T>(
|
|||||||
try {
|
try {
|
||||||
return await callback(session)
|
return await callback(session)
|
||||||
} finally {
|
} finally {
|
||||||
await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
|
await cleanupSession({ sdk, sessionID: session.id })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,6 +508,57 @@ export async function seedSessionPermission(
|
|||||||
return { id: result.id }
|
return { id: result.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function seedSessionTask(
|
||||||
|
sdk: ReturnType<typeof createSdk>,
|
||||||
|
input: {
|
||||||
|
sessionID: string
|
||||||
|
description: string
|
||||||
|
prompt: string
|
||||||
|
subagentType?: string
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const text = [
|
||||||
|
"Your only valid response is one task tool call.",
|
||||||
|
`Use this JSON input: ${JSON.stringify({
|
||||||
|
description: input.description,
|
||||||
|
prompt: input.prompt,
|
||||||
|
subagent_type: input.subagentType ?? "general",
|
||||||
|
})}`,
|
||||||
|
"Do not output plain text.",
|
||||||
|
"Wait for the task to start and return the child session id.",
|
||||||
|
].join("\n")
|
||||||
|
|
||||||
|
const result = await seed({
|
||||||
|
sdk,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
prompt: text,
|
||||||
|
timeout: 90_000,
|
||||||
|
probe: async () => {
|
||||||
|
const messages = await sdk.session.messages({ sessionID: input.sessionID, limit: 50 }).then((x) => x.data ?? [])
|
||||||
|
const part = messages
|
||||||
|
.flatMap((message) => message.parts)
|
||||||
|
.find((part) => {
|
||||||
|
if (part.type !== "tool" || part.tool !== "task") return false
|
||||||
|
if (part.state.input?.description !== input.description) return false
|
||||||
|
return typeof part.state.metadata?.sessionId === "string" && part.state.metadata.sessionId.length > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!part) return
|
||||||
|
const id = part.state.metadata?.sessionId
|
||||||
|
if (typeof id !== "string" || !id) return
|
||||||
|
const child = await sdk.session
|
||||||
|
.get({ sessionID: id })
|
||||||
|
.then((x) => x.data)
|
||||||
|
.catch(() => undefined)
|
||||||
|
if (!child?.id) return
|
||||||
|
return { sessionID: id }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result) throw new Error("Timed out seeding task tool")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
export async function seedSessionTodos(
|
export async function seedSessionTodos(
|
||||||
sdk: ReturnType<typeof createSdk>,
|
sdk: ReturnType<typeof createSdk>,
|
||||||
input: {
|
input: {
|
||||||
@@ -515,32 +633,42 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
|
|||||||
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
|
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
|
||||||
await expect(trigger).toHaveCount(1)
|
await expect(trigger).toHaveCount(1)
|
||||||
|
|
||||||
|
const menu = page
|
||||||
|
.locator(dropdownMenuContentSelector)
|
||||||
|
.filter({ has: page.locator(projectCloseMenuSelector(projectSlug)) })
|
||||||
|
.first()
|
||||||
|
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
|
||||||
|
|
||||||
|
const clicked = await trigger
|
||||||
|
.click({ timeout: 1500 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (clicked) {
|
||||||
|
const opened = await menu
|
||||||
|
.waitFor({ state: "visible", timeout: 1500 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
if (opened) {
|
||||||
|
await expect(close).toBeVisible()
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await trigger.focus()
|
await trigger.focus()
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
const menu = page.locator(dropdownMenuContentSelector).first()
|
|
||||||
const opened = await menu
|
const opened = await menu
|
||||||
.waitFor({ state: "visible", timeout: 1500 })
|
.waitFor({ state: "visible", timeout: 1500 })
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
|
|
||||||
if (opened) {
|
if (opened) {
|
||||||
const viewport = page.viewportSize()
|
await expect(close).toBeVisible()
|
||||||
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
|
|
||||||
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
|
|
||||||
await page.mouse.move(x, y)
|
|
||||||
return menu
|
return menu
|
||||||
}
|
}
|
||||||
|
|
||||||
await trigger.click({ force: true })
|
throw new Error(`Failed to open project menu: ${projectSlug}`)
|
||||||
|
|
||||||
await expect(menu).toBeVisible()
|
|
||||||
|
|
||||||
const viewport = page.viewportSize()
|
|
||||||
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
|
|
||||||
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
|
|
||||||
await page.mouse.move(x, y)
|
|
||||||
return menu
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
|
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
|
||||||
@@ -553,11 +681,18 @@ export async function setWorkspacesEnabled(page: Page, projectSlug: string, enab
|
|||||||
|
|
||||||
if (current === enabled) return
|
if (current === enabled) return
|
||||||
|
|
||||||
await openProjectMenu(page, projectSlug)
|
const flip = async (timeout?: number) => {
|
||||||
|
const menu = await openProjectMenu(page, projectSlug)
|
||||||
|
const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
|
||||||
|
await expect(toggle).toBeVisible()
|
||||||
|
return toggle.click({ force: true, timeout })
|
||||||
|
}
|
||||||
|
|
||||||
const toggle = page.locator(projectWorkspacesToggleSelector(projectSlug)).first()
|
const flipped = await flip(1500)
|
||||||
await expect(toggle).toBeVisible()
|
.then(() => true)
|
||||||
await toggle.click({ force: true })
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (!flipped) await flip()
|
||||||
|
|
||||||
const expected = enabled ? "New workspace" : "New session"
|
const expected = enabled ? "New workspace" : "New session"
|
||||||
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { serverName } from "../utils"
|
import { serverNamePattern } from "../utils"
|
||||||
|
|
||||||
test("home renders and shows core entrypoints", async ({ page }) => {
|
test("home renders and shows core entrypoints", async ({ page }) => {
|
||||||
await page.goto("/")
|
await page.goto("/")
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
|
||||||
await expect(page.getByRole("button", { name: serverName })).toBeVisible()
|
await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("server picker dialog opens from home", async ({ page }) => {
|
test("server picker dialog opens from home", async ({ page }) => {
|
||||||
await page.goto("/")
|
await page.goto("/")
|
||||||
|
|
||||||
const trigger = page.getByRole("button", { name: serverName })
|
const trigger = page.getByRole("button", { name: serverNamePattern })
|
||||||
await expect(trigger).toBeVisible()
|
await expect(trigger).toBeVisible()
|
||||||
await trigger.click()
|
await trigger.click()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { serverName, serverUrl } from "../utils"
|
import { serverNamePattern, serverUrls } from "../utils"
|
||||||
import { clickListItem, closeDialog, clickMenuItem } from "../actions"
|
import { closeDialog, clickMenuItem } from "../actions"
|
||||||
|
|
||||||
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
|
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
|
||||||
|
|
||||||
@@ -31,10 +31,9 @@ test("can set a default server on web", async ({ page, gotoSession }) => {
|
|||||||
const dialog = page.getByRole("dialog")
|
const dialog = page.getByRole("dialog")
|
||||||
await expect(dialog).toBeVisible()
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
const row = dialog.locator('[data-slot="list-item"]').filter({ hasText: serverName }).first()
|
await expect(dialog.getByText(serverNamePattern).first()).toBeVisible()
|
||||||
await expect(row).toBeVisible()
|
|
||||||
|
|
||||||
const menuTrigger = row.locator('[data-slot="dropdown-menu-trigger"]').first()
|
const menuTrigger = dialog.locator('[data-slot="dropdown-menu-trigger"]').first()
|
||||||
await expect(menuTrigger).toBeVisible()
|
await expect(menuTrigger).toBeVisible()
|
||||||
await menuTrigger.click({ force: true })
|
await menuTrigger.click({ force: true })
|
||||||
|
|
||||||
@@ -42,14 +41,18 @@ test("can set a default server on web", async ({ page, gotoSession }) => {
|
|||||||
await expect(menu).toBeVisible()
|
await expect(menu).toBeVisible()
|
||||||
await clickMenuItem(menu, /set as default/i)
|
await clickMenuItem(menu, /set as default/i)
|
||||||
|
|
||||||
await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)).toBe(serverUrl)
|
await expect
|
||||||
await expect(row.getByText("Default", { exact: true })).toBeVisible()
|
.poll(async () =>
|
||||||
|
serverUrls.includes((await page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)) ?? ""),
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
await expect(dialog.getByText("Default", { exact: true })).toBeVisible()
|
||||||
|
|
||||||
await closeDialog(page, dialog)
|
await closeDialog(page, dialog)
|
||||||
|
|
||||||
await ensurePopoverOpen()
|
await ensurePopoverOpen()
|
||||||
|
|
||||||
const serverRow = popover.locator("button").filter({ hasText: serverName }).first()
|
const serverRow = popover.locator("button").filter({ hasText: serverNamePattern }).first()
|
||||||
await expect(serverRow).toBeVisible()
|
await expect(serverRow).toBeVisible()
|
||||||
await expect(serverRow.getByText("Default", { exact: true })).toBeVisible()
|
await expect(serverRow.getByText("Default", { exact: true })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ test("titlebar back/forward navigates between sessions", async ({ page, slug, sd
|
|||||||
|
|
||||||
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
|
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
|
||||||
await expect(link).toBeVisible()
|
await expect(link).toBeVisible()
|
||||||
await link.scrollIntoViewIfNeeded()
|
|
||||||
await link.click()
|
await link.click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||||
@@ -56,7 +55,6 @@ test("titlebar forward is cleared after branching history from sidebar", async (
|
|||||||
|
|
||||||
const second = page.locator(`[data-session-id="${b.id}"] a`).first()
|
const second = page.locator(`[data-session-id="${b.id}"] a`).first()
|
||||||
await expect(second).toBeVisible()
|
await expect(second).toBeVisible()
|
||||||
await second.scrollIntoViewIfNeeded()
|
|
||||||
await second.click()
|
await second.click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${b.id}(?:\\?|#|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${b.id}(?:\\?|#|$)`))
|
||||||
@@ -76,7 +74,6 @@ test("titlebar forward is cleared after branching history from sidebar", async (
|
|||||||
|
|
||||||
const third = page.locator(`[data-session-id="${c.id}"] a`).first()
|
const third = page.locator(`[data-session-id="${c.id}"] a`).first()
|
||||||
await expect(third).toBeVisible()
|
await expect(third).toBeVisible()
|
||||||
await third.scrollIntoViewIfNeeded()
|
|
||||||
await third.click()
|
await third.click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${c.id}(?:\\?|#|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${c.id}(?:\\?|#|$)`))
|
||||||
@@ -102,7 +99,6 @@ test("keyboard shortcuts navigate titlebar history", async ({ page, slug, sdk, g
|
|||||||
|
|
||||||
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
|
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
|
||||||
await expect(link).toBeVisible()
|
await expect(link).toBeVisible()
|
||||||
await link.scrollIntoViewIfNeeded()
|
|
||||||
await link.click()
|
await link.click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const expanded = async (el: { getAttribute: (name: string) => Promise<string | n
|
|||||||
test("review panel can be toggled via keybind", async ({ page, gotoSession }) => {
|
test("review panel can be toggled via keybind", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
|
const reviewPanel = page.locator("#review-panel")
|
||||||
|
|
||||||
const treeToggle = page.getByRole("button", { name: "Toggle file tree" }).first()
|
const treeToggle = page.getByRole("button", { name: "Toggle file tree" }).first()
|
||||||
await expect(treeToggle).toBeVisible()
|
await expect(treeToggle).toBeVisible()
|
||||||
if (await expanded(treeToggle)) await treeToggle.click()
|
if (await expanded(treeToggle)) await treeToggle.click()
|
||||||
@@ -19,13 +21,13 @@ test("review panel can be toggled via keybind", async ({ page, gotoSession }) =>
|
|||||||
await expect(reviewToggle).toBeVisible()
|
await expect(reviewToggle).toBeVisible()
|
||||||
if (await expanded(reviewToggle)) await reviewToggle.click()
|
if (await expanded(reviewToggle)) await reviewToggle.click()
|
||||||
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
|
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
|
||||||
await expect(page.locator("#review-panel")).toHaveCount(0)
|
await expect(reviewPanel).toHaveAttribute("aria-hidden", "true")
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Shift+R`)
|
await page.keyboard.press(`${modKey}+Shift+R`)
|
||||||
await expect(reviewToggle).toHaveAttribute("aria-expanded", "true")
|
await expect(reviewToggle).toHaveAttribute("aria-expanded", "true")
|
||||||
await expect(page.locator("#review-panel")).toBeVisible()
|
await expect(reviewPanel).toHaveAttribute("aria-hidden", "false")
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Shift+R`)
|
await page.keyboard.press(`${modKey}+Shift+R`)
|
||||||
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
|
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
|
||||||
await expect(page.locator("#review-panel")).toHaveCount(0)
|
await expect(reviewPanel).toHaveAttribute("aria-hidden", "true")
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ test("file tree can expand folders and open a file", async ({ page, gotoSession
|
|||||||
await tab.click()
|
await tab.click()
|
||||||
await expect(tab).toHaveAttribute("aria-selected", "true")
|
await expect(tab).toHaveAttribute("aria-selected", "true")
|
||||||
|
|
||||||
|
await toggle.click()
|
||||||
|
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
||||||
|
|
||||||
|
await toggle.click()
|
||||||
|
await expect(toggle).toHaveAttribute("aria-expanded", "true")
|
||||||
|
await expect(allTab).toHaveAttribute("aria-selected", "true")
|
||||||
|
|
||||||
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
||||||
await expect(viewer).toBeVisible()
|
await expect(viewer).toBeVisible()
|
||||||
await expect(viewer).toContainText("export default function FileTree")
|
await expect(viewer).toContainText("export default function FileTree")
|
||||||
|
|||||||
@@ -101,3 +101,56 @@ test("cmd+f opens text viewer search while prompt is focused", async ({ page, go
|
|||||||
await expect(findInput).toBeVisible()
|
await expect(findInput).toBeVisible()
|
||||||
await expect(findInput).toBeFocused()
|
await expect(findInput).toBeFocused()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("cmd+f opens text viewer search while prompt is not focused", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
await page.keyboard.type("/open")
|
||||||
|
|
||||||
|
const command = page.locator('[data-slash-id="file.open"]').first()
|
||||||
|
await expect(command).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const dialog = page
|
||||||
|
.getByRole("dialog")
|
||||||
|
.filter({ has: page.getByPlaceholder(/search files/i) })
|
||||||
|
.first()
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
const input = dialog.getByRole("textbox").first()
|
||||||
|
await input.fill("package.json")
|
||||||
|
|
||||||
|
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
|
||||||
|
let index = -1
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
|
||||||
|
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
|
||||||
|
return index >= 0
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
const item = items.nth(index)
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
await item.click()
|
||||||
|
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
|
const tab = page.getByRole("tab", { name: "package.json" })
|
||||||
|
await expect(tab).toBeVisible()
|
||||||
|
await tab.click()
|
||||||
|
|
||||||
|
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
||||||
|
await expect(viewer).toBeVisible()
|
||||||
|
|
||||||
|
await viewer.click()
|
||||||
|
await page.keyboard.press(`${modKey}+f`)
|
||||||
|
|
||||||
|
const findInput = page.getByPlaceholder("Find")
|
||||||
|
await expect(findInput).toBeVisible()
|
||||||
|
await expect(findInput).toBeFocused()
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test as base, expect, type Page } from "@playwright/test"
|
import { test as base, expect, type Page } from "@playwright/test"
|
||||||
import { cleanupTestProject, createTestProject, seedProjects } from "./actions"
|
import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
|
||||||
import { promptSelector } from "./selectors"
|
import { promptSelector } from "./selectors"
|
||||||
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
||||||
|
|
||||||
@@ -13,6 +13,8 @@ type TestFixtures = {
|
|||||||
directory: string
|
directory: string
|
||||||
slug: string
|
slug: string
|
||||||
gotoSession: (sessionID?: string) => Promise<void>
|
gotoSession: (sessionID?: string) => Promise<void>
|
||||||
|
trackSession: (sessionID: string, directory?: string) => void
|
||||||
|
trackDirectory: (directory: string) => void
|
||||||
}) => Promise<T>,
|
}) => Promise<T>,
|
||||||
options?: { extra?: string[] },
|
options?: { extra?: string[] },
|
||||||
) => Promise<T>
|
) => Promise<T>
|
||||||
@@ -51,20 +53,36 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
|||||||
},
|
},
|
||||||
withProject: async ({ page }, use) => {
|
withProject: async ({ page }, use) => {
|
||||||
await use(async (callback, options) => {
|
await use(async (callback, options) => {
|
||||||
const directory = await createTestProject()
|
const root = await createTestProject()
|
||||||
const slug = dirSlug(directory)
|
const slug = dirSlug(root)
|
||||||
await seedStorage(page, { directory, extra: options?.extra })
|
const sessions = new Map<string, string>()
|
||||||
|
const dirs = new Set<string>()
|
||||||
|
await seedStorage(page, { directory: root, extra: options?.extra })
|
||||||
|
|
||||||
const gotoSession = async (sessionID?: string) => {
|
const gotoSession = async (sessionID?: string) => {
|
||||||
await page.goto(sessionPath(directory, sessionID))
|
await page.goto(sessionPath(root, sessionID))
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
const current = sessionIDFromUrl(page.url())
|
||||||
|
if (current) trackSession(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackSession = (sessionID: string, directory?: string) => {
|
||||||
|
sessions.set(sessionID, directory ?? root)
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackDirectory = (directory: string) => {
|
||||||
|
if (directory !== root) dirs.add(directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
return await callback({ directory, slug, gotoSession })
|
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(directory)
|
await Promise.allSettled(
|
||||||
|
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
|
||||||
|
)
|
||||||
|
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
|
||||||
|
await cleanupTestProject(root)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,25 +1,15 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openSidebar } from "../actions"
|
import { clickMenuItem, openProjectMenu, openSidebar } from "../actions"
|
||||||
|
|
||||||
test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
|
test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
await withProject(async () => {
|
await withProject(async ({ slug }) => {
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
const open = async () => {
|
const open = async () => {
|
||||||
const header = page.locator(".group\\/project").first()
|
const menu = await openProjectMenu(page, slug)
|
||||||
await header.hover()
|
await clickMenuItem(menu, /^Edit$/i, { force: true })
|
||||||
const trigger = header.getByRole("button", { name: "More options" }).first()
|
|
||||||
await expect(trigger).toBeVisible()
|
|
||||||
await trigger.click({ force: true })
|
|
||||||
|
|
||||||
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
|
|
||||||
await expect(menu).toBeVisible()
|
|
||||||
|
|
||||||
const editItem = menu.getByRole("menuitem", { name: "Edit" }).first()
|
|
||||||
await expect(editItem).toBeVisible()
|
|
||||||
await editItem.click({ force: true })
|
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog")
|
const dialog = page.getByRole("dialog")
|
||||||
await expect(dialog).toBeVisible()
|
await expect(dialog).toBeVisible()
|
||||||
|
|||||||
@@ -1,36 +1,8 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, openProjectMenu } from "../actions"
|
import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, openProjectMenu } from "../actions"
|
||||||
import { projectCloseHoverSelector, projectSwitchSelector } from "../selectors"
|
import { projectSwitchSelector } from "../selectors"
|
||||||
import { dirSlug } from "../utils"
|
import { dirSlug } from "../utils"
|
||||||
|
|
||||||
test("can close a project via hover card close button", async ({ page, withProject }) => {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
|
||||||
|
|
||||||
const other = await createTestProject()
|
|
||||||
const otherSlug = dirSlug(other)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await withProject(
|
|
||||||
async () => {
|
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
|
||||||
await expect(otherButton).toBeVisible()
|
|
||||||
await otherButton.hover()
|
|
||||||
|
|
||||||
const close = page.locator(projectCloseHoverSelector(otherSlug)).first()
|
|
||||||
await expect(close).toBeVisible()
|
|
||||||
await close.click()
|
|
||||||
|
|
||||||
await expect(otherButton).toHaveCount(0)
|
|
||||||
},
|
|
||||||
{ extra: [other] },
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
await cleanupTestProject(other)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("closing active project navigates to another open project", async ({ page, withProject }) => {
|
test("closing active project navigates to another open project", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
@@ -53,16 +25,26 @@ test("closing active project navigates to another open project", async ({ page,
|
|||||||
await clickMenuItem(menu, /^Close$/i, { force: true })
|
await clickMenuItem(menu, /^Close$/i, { force: true })
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(() => {
|
.poll(
|
||||||
const pathname = new URL(page.url()).pathname
|
() => {
|
||||||
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
|
const pathname = new URL(page.url()).pathname
|
||||||
if (pathname === "/") return "home"
|
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
|
||||||
return ""
|
if (pathname === "/") return "home"
|
||||||
})
|
return ""
|
||||||
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
.toMatch(/^(project|home)$/)
|
.toMatch(/^(project|home)$/)
|
||||||
|
|
||||||
await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
|
await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
|
||||||
await expect(otherButton).toHaveCount(0)
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
return await page.locator(projectSwitchSelector(otherSlug)).count()
|
||||||
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.toBe(0)
|
||||||
},
|
},
|
||||||
{ extra: [other] },
|
{ extra: [other] },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,18 +1,39 @@
|
|||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
import { base64Decode } from "@opencode-ai/util/encode"
|
||||||
|
import type { Page } from "@playwright/test"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import {
|
import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions"
|
||||||
defocus,
|
|
||||||
createTestProject,
|
|
||||||
cleanupTestProject,
|
|
||||||
openSidebar,
|
|
||||||
setWorkspacesEnabled,
|
|
||||||
sessionIDFromUrl,
|
|
||||||
} from "../actions"
|
|
||||||
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||||
import { createSdk, dirSlug, sessionPath } from "../utils"
|
import { dirSlug, resolveDirectory } from "../utils"
|
||||||
|
|
||||||
function slugFromUrl(url: string) {
|
async function workspaces(page: Page, directory: string, enabled: boolean) {
|
||||||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
await page.evaluate(
|
||||||
|
({ directory, enabled }: { directory: string; enabled: boolean }) => {
|
||||||
|
const key = "opencode.global.dat:layout"
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
const data = raw ? JSON.parse(raw) : {}
|
||||||
|
const sidebar = data.sidebar && typeof data.sidebar === "object" ? data.sidebar : {}
|
||||||
|
const current =
|
||||||
|
sidebar.workspaces && typeof sidebar.workspaces === "object" && !Array.isArray(sidebar.workspaces)
|
||||||
|
? sidebar.workspaces
|
||||||
|
: {}
|
||||||
|
const next = { ...current }
|
||||||
|
|
||||||
|
if (enabled) next[directory] = true
|
||||||
|
if (!enabled) delete next[directory]
|
||||||
|
|
||||||
|
localStorage.setItem(
|
||||||
|
key,
|
||||||
|
JSON.stringify({
|
||||||
|
...data,
|
||||||
|
sidebar: {
|
||||||
|
...sidebar,
|
||||||
|
workspaces: next,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ directory, enabled },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("can switch between projects from sidebar", async ({ page, withProject }) => {
|
test("can switch between projects from sidebar", async ({ page, withProject }) => {
|
||||||
@@ -51,46 +72,39 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||||||
|
|
||||||
const other = await createTestProject()
|
const other = await createTestProject()
|
||||||
const otherSlug = dirSlug(other)
|
const otherSlug = dirSlug(other)
|
||||||
let rootDir: string | undefined
|
|
||||||
let workspaceDir: string | undefined
|
|
||||||
let sessionID: string | undefined
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withProject(
|
await withProject(
|
||||||
async ({ directory, slug }) => {
|
async ({ directory, slug, trackSession, trackDirectory }) => {
|
||||||
rootDir = directory
|
|
||||||
await defocus(page)
|
await defocus(page)
|
||||||
|
await workspaces(page, directory, true)
|
||||||
|
await page.reload()
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await setWorkspacesEnabled(page, slug, true)
|
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||||
|
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
|
||||||
await expect
|
const raw = await waitSlug(page, [slug])
|
||||||
.poll(
|
const dir = base64Decode(raw)
|
||||||
() => {
|
if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`)
|
||||||
const next = slugFromUrl(page.url())
|
const space = await resolveDirectory(dir)
|
||||||
if (!next) return ""
|
const next = dirSlug(space)
|
||||||
if (next === slug) return ""
|
trackDirectory(space)
|
||||||
return next
|
|
||||||
},
|
|
||||||
{ timeout: 45_000 },
|
|
||||||
)
|
|
||||||
.not.toBe("")
|
|
||||||
|
|
||||||
const workspaceSlug = slugFromUrl(page.url())
|
|
||||||
workspaceDir = base64Decode(workspaceSlug)
|
|
||||||
if (!workspaceDir) throw new Error(`Failed to decode workspace slug: ${workspaceSlug}`)
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
const workspace = page.locator(workspaceItemSelector(workspaceSlug)).first()
|
const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first()
|
||||||
await expect(workspace).toBeVisible()
|
await expect(item).toBeVisible()
|
||||||
await workspace.hover()
|
await item.hover()
|
||||||
|
|
||||||
const newSession = page.locator(workspaceNewSessionSelector(workspaceSlug)).first()
|
const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first()
|
||||||
await expect(newSession).toBeVisible()
|
await expect(btn).toBeVisible()
|
||||||
await newSession.click({ force: true })
|
await btn.click({ force: true })
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session(?:[/?#]|$)`))
|
// A new workspace can be discovered via a transient slug before the route and sidebar
|
||||||
|
// settle to the canonical workspace path on Windows, so interact with either and assert
|
||||||
|
// against the resolved workspace slug.
|
||||||
|
await waitSlug(page)
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
|
||||||
|
|
||||||
// Create a session by sending a prompt
|
// Create a session by sending a prompt
|
||||||
const prompt = page.locator(promptSelector)
|
const prompt = page.locator(promptSelector)
|
||||||
@@ -103,9 +117,9 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||||||
|
|
||||||
const created = sessionIDFromUrl(page.url())
|
const created = sessionIDFromUrl(page.url())
|
||||||
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
|
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
|
||||||
sessionID = created
|
trackSession(created, space)
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
@@ -124,20 +138,6 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||||||
{ extra: [other] },
|
{ extra: [other] },
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
if (sessionID) {
|
|
||||||
const id = sessionID
|
|
||||||
const dirs = [rootDir, workspaceDir].filter((x): x is string => !!x)
|
|
||||||
await Promise.all(
|
|
||||||
dirs.map((directory) =>
|
|
||||||
createSdk(directory)
|
|
||||||
.session.delete({ sessionID: id })
|
|
||||||
.catch(() => undefined),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (workspaceDir) {
|
|
||||||
await cleanupTestProject(workspaceDir)
|
|
||||||
}
|
|
||||||
await cleanupTestProject(other)
|
await cleanupTestProject(other)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
import { base64Decode } from "@opencode-ai/util/encode"
|
||||||
import type { Page } from "@playwright/test"
|
import type { Page } from "@playwright/test"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { cleanupTestProject, openSidebar, sessionIDFromUrl, setWorkspacesEnabled } from "../actions"
|
import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, slugFromUrl, waitSlug } from "../actions"
|
||||||
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||||
import { createSdk } from "../utils"
|
import { createSdk } from "../utils"
|
||||||
|
|
||||||
function slugFromUrl(url: string) {
|
|
||||||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitWorkspaceReady(page: Page, slug: string) {
|
async function waitWorkspaceReady(page: Page, slug: string) {
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await expect
|
await expect
|
||||||
@@ -31,20 +27,7 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
|
|||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
|
||||||
await expect
|
const slug = await waitSlug(page, [root, ...seen])
|
||||||
.poll(
|
|
||||||
() => {
|
|
||||||
const slug = slugFromUrl(page.url())
|
|
||||||
if (!slug) return ""
|
|
||||||
if (slug === root) return ""
|
|
||||||
if (seen.includes(slug)) return ""
|
|
||||||
return slug
|
|
||||||
},
|
|
||||||
{ timeout: 45_000 },
|
|
||||||
)
|
|
||||||
.not.toBe("")
|
|
||||||
|
|
||||||
const slug = slugFromUrl(page.url())
|
|
||||||
const directory = base64Decode(slug)
|
const directory = base64Decode(slug)
|
||||||
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
||||||
return { slug, directory }
|
return { slug, directory }
|
||||||
@@ -60,12 +43,13 @@ async function openWorkspaceNewSession(page: Page, slug: string) {
|
|||||||
await expect(button).toBeVisible()
|
await expect(button).toBeVisible()
|
||||||
await button.click({ force: true })
|
await button.click({ force: true })
|
||||||
|
|
||||||
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
|
const next = await waitSlug(page)
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session(?:[/?#]|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
|
||||||
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
|
async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
|
||||||
await openWorkspaceNewSession(page, slug)
|
const next = await openWorkspaceNewSession(page, slug)
|
||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
const prompt = page.locator(promptSelector)
|
||||||
await expect(prompt).toBeVisible()
|
await expect(prompt).toBeVisible()
|
||||||
@@ -76,13 +60,13 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string
|
|||||||
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
|
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
|
||||||
await prompt.press("Enter")
|
await prompt.press("Enter")
|
||||||
|
|
||||||
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
|
await expect.poll(() => slugFromUrl(page.url())).toBe(next)
|
||||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
||||||
|
|
||||||
const sessionID = sessionIDFromUrl(page.url())
|
const sessionID = sessionIDFromUrl(page.url())
|
||||||
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${sessionID}(?:[/?#]|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`))
|
||||||
return sessionID
|
return { sessionID, slug: next }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sessionDirectory(directory: string, sessionID: string) {
|
async function sessionDirectory(directory: string, sessionID: string) {
|
||||||
@@ -97,48 +81,29 @@ async function sessionDirectory(directory: string, sessionID: string) {
|
|||||||
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
|
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
await withProject(async ({ directory, slug: root }) => {
|
await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => {
|
||||||
const workspaces = [] as { slug: string; directory: string }[]
|
await openSidebar(page)
|
||||||
const sessions = [] as string[]
|
await setWorkspacesEnabled(page, root, true)
|
||||||
|
|
||||||
try {
|
const first = await createWorkspace(page, root, [])
|
||||||
await openSidebar(page)
|
trackDirectory(first.directory)
|
||||||
await setWorkspacesEnabled(page, root, true)
|
await waitWorkspaceReady(page, first.slug)
|
||||||
|
|
||||||
const first = await createWorkspace(page, root, [])
|
const second = await createWorkspace(page, root, [first.slug])
|
||||||
workspaces.push(first)
|
trackDirectory(second.directory)
|
||||||
await waitWorkspaceReady(page, first.slug)
|
await waitWorkspaceReady(page, second.slug)
|
||||||
|
|
||||||
const second = await createWorkspace(page, root, [first.slug])
|
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
|
||||||
workspaces.push(second)
|
trackSession(firstSession.sessionID, first.directory)
|
||||||
await waitWorkspaceReady(page, second.slug)
|
|
||||||
|
|
||||||
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
|
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
|
||||||
sessions.push(firstSession)
|
trackSession(secondSession.sessionID, second.directory)
|
||||||
|
|
||||||
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
|
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
|
||||||
sessions.push(secondSession)
|
trackSession(thirdSession.sessionID, first.directory)
|
||||||
|
|
||||||
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
|
await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
|
||||||
sessions.push(thirdSession)
|
await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
|
||||||
|
await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
|
||||||
await expect.poll(() => sessionDirectory(first.directory, firstSession)).toBe(first.directory)
|
|
||||||
await expect.poll(() => sessionDirectory(second.directory, secondSession)).toBe(second.directory)
|
|
||||||
await expect.poll(() => sessionDirectory(first.directory, thirdSession)).toBe(first.directory)
|
|
||||||
} finally {
|
|
||||||
const dirs = [directory, ...workspaces.map((workspace) => workspace.directory)]
|
|
||||||
await Promise.all(
|
|
||||||
sessions.map((sessionID) =>
|
|
||||||
Promise.all(
|
|
||||||
dirs.map((dir) =>
|
|
||||||
createSdk(dir)
|
|
||||||
.session.delete({ sessionID })
|
|
||||||
.catch(() => undefined),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
await Promise.all(workspaces.map((workspace) => cleanupTestProject(workspace.directory)))
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,14 +14,12 @@ import {
|
|||||||
openSidebar,
|
openSidebar,
|
||||||
openWorkspaceMenu,
|
openWorkspaceMenu,
|
||||||
setWorkspacesEnabled,
|
setWorkspacesEnabled,
|
||||||
|
slugFromUrl,
|
||||||
|
waitSlug,
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
|
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
|
||||||
import { createSdk, dirSlug } from "../utils"
|
import { createSdk, dirSlug } from "../utils"
|
||||||
|
|
||||||
function slugFromUrl(url: string) {
|
|
||||||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
||||||
const rootSlug = project.slug
|
const rootSlug = project.slug
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
@@ -29,17 +27,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
|||||||
await setWorkspacesEnabled(page, rootSlug, true)
|
await setWorkspacesEnabled(page, rootSlug, true)
|
||||||
|
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
await expect
|
const slug = await waitSlug(page, [rootSlug])
|
||||||
.poll(
|
|
||||||
() => {
|
|
||||||
const slug = slugFromUrl(page.url())
|
|
||||||
return slug.length > 0 && slug !== rootSlug
|
|
||||||
},
|
|
||||||
{ timeout: 45_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
|
|
||||||
const slug = slugFromUrl(page.url())
|
|
||||||
const dir = base64Decode(slug)
|
const dir = base64Decode(slug)
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
@@ -91,18 +79,7 @@ test("can create a workspace", async ({ page, withProject }) => {
|
|||||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||||
|
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
const workspaceSlug = await waitSlug(page, [slug])
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
() => {
|
|
||||||
const currentSlug = slugFromUrl(page.url())
|
|
||||||
return currentSlug.length > 0 && currentSlug !== slug
|
|
||||||
},
|
|
||||||
{ timeout: 45_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
|
|
||||||
const workspaceSlug = slugFromUrl(page.url())
|
|
||||||
const workspaceDir = base64Decode(workspaceSlug)
|
const workspaceDir = base64Decode(workspaceSlug)
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
@@ -279,7 +256,7 @@ test("can delete a workspace", async ({ page, withProject }) => {
|
|||||||
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
||||||
await confirmDialog(page, /^Delete workspace$/i)
|
await confirmDialog(page, /^Delete workspace$/i)
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
|
await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
@@ -336,9 +313,6 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
|
|||||||
const src = page.locator(workspaceItemSelector(from)).first()
|
const src = page.locator(workspaceItemSelector(from)).first()
|
||||||
const dst = page.locator(workspaceItemSelector(to)).first()
|
const dst = page.locator(workspaceItemSelector(to)).first()
|
||||||
|
|
||||||
await src.scrollIntoViewIfNeeded()
|
|
||||||
await dst.scrollIntoViewIfNeeded()
|
|
||||||
|
|
||||||
const a = await src.boundingBox()
|
const a = await src.boundingBox()
|
||||||
const b = await dst.boundingBox()
|
const b = await dst.boundingBox()
|
||||||
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
|
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
|
||||||
@@ -357,17 +331,7 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
|
|||||||
for (const _ of [0, 1]) {
|
for (const _ of [0, 1]) {
|
||||||
const prev = slugFromUrl(page.url())
|
const prev = slugFromUrl(page.url())
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
await expect
|
const slug = await waitSlug(page, [rootSlug, prev])
|
||||||
.poll(
|
|
||||||
() => {
|
|
||||||
const slug = slugFromUrl(page.url())
|
|
||||||
return slug.length > 0 && slug !== rootSlug && slug !== prev
|
|
||||||
},
|
|
||||||
{ timeout: 45_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
|
|
||||||
const slug = slugFromUrl(page.url())
|
|
||||||
const dir = base64Decode(slug)
|
const dir = base64Decode(slug)
|
||||||
workspaces.push({ slug, directory: dir })
|
workspaces.push({ slug, directory: dir })
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
import { sessionIDFromUrl } from "../actions"
|
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
|
||||||
|
|
||||||
|
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||||
|
|
||||||
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
|
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
|
||||||
// the connection open while the agent works, causing "Failed to fetch" over
|
// the connection open while the agent works, causing "Failed to fetch" over
|
||||||
@@ -38,6 +40,37 @@ test("prompt succeeds when sync message endpoint is unreachable", async ({ page,
|
|||||||
)
|
)
|
||||||
.toContain(token)
|
.toContain(token)
|
||||||
} finally {
|
} finally {
|
||||||
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
await cleanupSession({ sdk, sessionID })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withSession(sdk, `e2e prompt failure ${Date.now()}`, async (session) => {
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
const value = `restore ${Date.now()}`
|
||||||
|
|
||||||
|
await page.route(`**/session/${session.id}/prompt_async`, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 500,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ message: "e2e prompt failure" }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await gotoSession(session.id)
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type(value)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect.poll(async () => text(await prompt.textContent())).toBe(value)
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const messages = await sdk.session.messages({ sessionID: session.id, limit: 50 }).then((r) => r.data ?? [])
|
||||||
|
return messages.length
|
||||||
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { withSession } from "../actions"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
|
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||||
|
|
||||||
|
const isBash = (part: unknown): part is ToolPart => {
|
||||||
|
if (!part || typeof part !== "object") return false
|
||||||
|
if (!("type" in part) || part.type !== "tool") return false
|
||||||
|
if (!("tool" in part) || part.tool !== "bash") return false
|
||||||
|
return "state" in part
|
||||||
|
}
|
||||||
|
|
||||||
|
async function edge(page: Page, pos: "start" | "end") {
|
||||||
|
await page.locator(promptSelector).evaluate((el: HTMLDivElement, pos: "start" | "end") => {
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection) return
|
||||||
|
|
||||||
|
const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
|
||||||
|
const nodes: Text[] = []
|
||||||
|
for (let node = walk.nextNode(); node; node = walk.nextNode()) {
|
||||||
|
nodes.push(node as Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
const node = document.createTextNode("")
|
||||||
|
el.appendChild(node)
|
||||||
|
nodes.push(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = pos === "start" ? nodes[0]! : nodes[nodes.length - 1]!
|
||||||
|
const range = document.createRange()
|
||||||
|
range.setStart(node, pos === "start" ? 0 : (node.textContent ?? "").length)
|
||||||
|
range.collapse(true)
|
||||||
|
selection.removeAllRanges()
|
||||||
|
selection.addRange(range)
|
||||||
|
}, pos)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wait(page: Page, value: string) {
|
||||||
|
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reply(sdk: Parameters<typeof withSession>[0], sessionID: string, token: string) {
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||||
|
return messages
|
||||||
|
.filter((item) => item.info.role === "assistant")
|
||||||
|
.flatMap((item) => item.parts)
|
||||||
|
.filter((item) => item.type === "text")
|
||||||
|
.map((item) => item.text)
|
||||||
|
.join("\n")
|
||||||
|
},
|
||||||
|
{ timeout: 90_000 },
|
||||||
|
)
|
||||||
|
.toContain(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string, cmd: string, token: string) {
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||||
|
const part = messages
|
||||||
|
.filter((item) => item.info.role === "assistant")
|
||||||
|
.flatMap((item) => item.parts)
|
||||||
|
.filter(isBash)
|
||||||
|
.find((item) => item.state.input?.command === cmd && item.state.status === "completed")
|
||||||
|
|
||||||
|
if (!part || part.state.status !== "completed") return
|
||||||
|
return typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
|
||||||
|
},
|
||||||
|
{ timeout: 90_000 },
|
||||||
|
)
|
||||||
|
.toContain(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("prompt history restores unsent draft with arrow navigation", async ({ page, sdk, gotoSession }) => {
|
||||||
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
|
await withSession(sdk, `e2e prompt history ${Date.now()}`, async (session) => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
|
||||||
|
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
|
||||||
|
const first = `Reply with exactly: ${firstToken}`
|
||||||
|
const second = `Reply with exactly: ${secondToken}`
|
||||||
|
const draft = `draft ${Date.now()}`
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type(first)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await wait(page, "")
|
||||||
|
await reply(sdk, session.id, firstToken)
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type(second)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await wait(page, "")
|
||||||
|
await reply(sdk, session.id, secondToken)
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type(draft)
|
||||||
|
await wait(page, draft)
|
||||||
|
|
||||||
|
await edge(page, "start")
|
||||||
|
await page.keyboard.press("ArrowUp")
|
||||||
|
await wait(page, second)
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowUp")
|
||||||
|
await wait(page, first)
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowDown")
|
||||||
|
await wait(page, second)
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowDown")
|
||||||
|
await wait(page, draft)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
|
||||||
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
|
await withSession(sdk, `e2e shell history ${Date.now()}`, async (session) => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
|
||||||
|
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
|
||||||
|
const normalToken = `E2E_NORMAL_${Date.now()}`
|
||||||
|
const first = `echo ${firstToken}`
|
||||||
|
const second = `echo ${secondToken}`
|
||||||
|
const normal = `Reply with exactly: ${normalToken}`
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("!")
|
||||||
|
await page.keyboard.type(first)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await wait(page, "")
|
||||||
|
await shell(sdk, session.id, first, firstToken)
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("!")
|
||||||
|
await page.keyboard.type(second)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await wait(page, "")
|
||||||
|
await shell(sdk, session.id, second, secondToken)
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("!")
|
||||||
|
await page.keyboard.press("ArrowUp")
|
||||||
|
await wait(page, second)
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowUp")
|
||||||
|
await wait(page, first)
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowDown")
|
||||||
|
await wait(page, second)
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowDown")
|
||||||
|
await wait(page, "")
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await wait(page, "")
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type(normal)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await wait(page, "")
|
||||||
|
await reply(sdk, session.id, normalToken)
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.press("ArrowUp")
|
||||||
|
await wait(page, normal)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { sessionIDFromUrl } from "../actions"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
import { createSdk } from "../utils"
|
||||||
|
|
||||||
|
const isBash = (part: unknown): part is ToolPart => {
|
||||||
|
if (!part || typeof part !== "object") return false
|
||||||
|
if (!("type" in part) || part.type !== "tool") return false
|
||||||
|
if (!("tool" in part) || part.tool !== "bash") return false
|
||||||
|
return "state" in part
|
||||||
|
}
|
||||||
|
|
||||||
|
test("shell mode runs a command in the project directory", async ({ page, withProject }) => {
|
||||||
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
|
await withProject(async ({ directory, gotoSession, trackSession }) => {
|
||||||
|
const sdk = createSdk(directory)
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
const cmd = process.platform === "win32" ? "dir" : "ls"
|
||||||
|
|
||||||
|
await gotoSession()
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("!")
|
||||||
|
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
|
||||||
|
|
||||||
|
await page.keyboard.type(cmd)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||||
|
|
||||||
|
const id = sessionIDFromUrl(page.url())
|
||||||
|
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||||
|
trackSession(id, directory)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const list = await sdk.session.messages({ sessionID: id, limit: 50 }).then((x) => x.data ?? [])
|
||||||
|
const msg = list.findLast(
|
||||||
|
(item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === directory,
|
||||||
|
)
|
||||||
|
if (!msg) return
|
||||||
|
|
||||||
|
const part = msg.parts
|
||||||
|
.filter(isBash)
|
||||||
|
.find((item) => item.state.input?.command === cmd && item.state.status === "completed")
|
||||||
|
|
||||||
|
if (!part || part.state.status !== "completed") return
|
||||||
|
const output =
|
||||||
|
typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
|
||||||
|
if (!output.includes("README.md")) return
|
||||||
|
|
||||||
|
return { cwd: directory, output }
|
||||||
|
},
|
||||||
|
{ timeout: 90_000 },
|
||||||
|
)
|
||||||
|
.toEqual(expect.objectContaining({ cwd: directory, output: expect.stringContaining("README.md") }))
|
||||||
|
|
||||||
|
await expect(prompt).toHaveText("")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
import { withSession } from "../actions"
|
||||||
|
|
||||||
|
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
|
||||||
|
|
||||||
|
async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
|
||||||
|
await sdk.session.promptAsync({
|
||||||
|
sessionID,
|
||||||
|
noReply: true,
|
||||||
|
parts: [{ type: "text", text: "e2e share seed" }],
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
|
||||||
|
return messages.length
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("/share and /unshare update session share state", async ({ page, sdk, gotoSession }) => {
|
||||||
|
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
||||||
|
|
||||||
|
await withSession(sdk, `e2e slash share ${Date.now()}`, async (session) => {
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
|
||||||
|
await seed(sdk, session.id)
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("/share")
|
||||||
|
await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
|
return data?.share?.url || undefined
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.not.toBeUndefined()
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("/unshare")
|
||||||
|
await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
|
return data?.share?.url || undefined
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
import { sessionIDFromUrl, withSession } from "../actions"
|
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
|
||||||
|
|
||||||
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
|
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
@@ -46,7 +46,7 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession })
|
|||||||
.toContain(token)
|
.toContain(token)
|
||||||
} finally {
|
} finally {
|
||||||
page.off("pageerror", onPageError)
|
page.off("pageerror", onPageError)
|
||||||
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
await cleanupSession({ sdk, sessionID })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageErrors.length > 0) {
|
if (pageErrors.length > 0) {
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
|
|||||||
export const projectSwitchSelector = (slug: string) =>
|
export const projectSwitchSelector = (slug: string) =>
|
||||||
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
|
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
|
||||||
|
|
||||||
export const projectCloseHoverSelector = (slug: string) => `[data-action="project-close-hover"][data-project="${slug}"]`
|
|
||||||
|
|
||||||
export const projectMenuTriggerSelector = (slug: string) =>
|
export const projectMenuTriggerSelector = (slug: string) =>
|
||||||
`${sidebarNavSelector} [data-action="project-menu"][data-project="${slug}"]`
|
`${sidebarNavSelector} [data-action="project-menu"][data-project="${slug}"]`
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { seedSessionTask, withSession } from "../actions"
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
|
||||||
|
test("task tool child-session link does not trigger stale show errors", async ({ page, sdk, gotoSession }) => {
|
||||||
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
|
const errs: string[] = []
|
||||||
|
const onError = (err: Error) => {
|
||||||
|
errs.push(err.message)
|
||||||
|
}
|
||||||
|
page.on("pageerror", onError)
|
||||||
|
|
||||||
|
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
|
||||||
|
const child = await seedSessionTask(sdk, {
|
||||||
|
sessionID: session.id,
|
||||||
|
description: "Open child session",
|
||||||
|
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
const link = page
|
||||||
|
.locator("a.subagent-link")
|
||||||
|
.filter({ hasText: /open child session/i })
|
||||||
|
.first()
|
||||||
|
await expect(link).toBeVisible({ timeout: 30_000 })
|
||||||
|
await link.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
expect(errs).toEqual([])
|
||||||
|
} finally {
|
||||||
|
page.off("pageerror", onError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
|
import { cleanupSession, clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
|
||||||
import {
|
import {
|
||||||
permissionDockSelector,
|
permissionDockSelector,
|
||||||
promptSelector,
|
promptSelector,
|
||||||
@@ -26,7 +26,7 @@ async function withDockSession<T>(
|
|||||||
try {
|
try {
|
||||||
return await fn(session)
|
return await fn(session)
|
||||||
} finally {
|
} finally {
|
||||||
await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
|
await cleanupSession({ sdk, sessionID: session.id })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,7 +311,7 @@ test("child session question request blocks parent dock and unblocks after submi
|
|||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
|
await cleanupSession({ sdk, sessionID: child.id })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -358,7 +358,7 @@ test("child session permission request blocks parent dock and supports allow onc
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
|
await cleanupSession({ sdk, sessionID: child.id })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ async function seedConversation(input: {
|
|||||||
.toBe(true)
|
.toBe(true)
|
||||||
|
|
||||||
if (!userMessageID) throw new Error("Expected a user message id")
|
if (!userMessageID) throw new Error("Expected a user message id")
|
||||||
await expect(input.page.locator(`[data-message-id="${userMessageID}"]`).first()).toBeVisible({ timeout: 30_000 })
|
await expect(input.page.locator(`[data-message-id="${userMessageID}"]`)).toHaveCount(1, { timeout: 30_000 })
|
||||||
return { prompt, userMessageID }
|
return { prompt, userMessageID }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ test("slash redo clears revert and restores latest state", async ({ page, withPr
|
|||||||
.toBeUndefined()
|
.toBeUndefined()
|
||||||
|
|
||||||
await expect(seeded.prompt).not.toContainText(token)
|
await expect(seeded.prompt).not.toContainText(token)
|
||||||
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`).first()).toBeVisible()
|
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -158,8 +158,8 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
|
|||||||
const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`)
|
const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`)
|
||||||
const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`)
|
const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`)
|
||||||
|
|
||||||
await expect(firstMessage.first()).toBeVisible()
|
await expect(firstMessage).toHaveCount(1)
|
||||||
await expect(secondMessage.first()).toBeVisible()
|
await expect(secondMessage).toHaveCount(1)
|
||||||
|
|
||||||
await second.prompt.click()
|
await second.prompt.click()
|
||||||
await page.keyboard.press(`${modKey}+A`)
|
await page.keyboard.press(`${modKey}+A`)
|
||||||
@@ -176,7 +176,7 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
|
|||||||
})
|
})
|
||||||
.toBe(second.userMessageID)
|
.toBe(second.userMessageID)
|
||||||
|
|
||||||
await expect(firstMessage.first()).toBeVisible()
|
await expect(firstMessage).toHaveCount(1)
|
||||||
await expect(secondMessage).toHaveCount(0)
|
await expect(secondMessage).toHaveCount(0)
|
||||||
|
|
||||||
await second.prompt.click()
|
await second.prompt.click()
|
||||||
@@ -210,7 +210,7 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
|
|||||||
})
|
})
|
||||||
.toBe(second.userMessageID)
|
.toBe(second.userMessageID)
|
||||||
|
|
||||||
await expect(firstMessage.first()).toBeVisible()
|
await expect(firstMessage).toHaveCount(1)
|
||||||
await expect(secondMessage).toHaveCount(0)
|
await expect(secondMessage).toHaveCount(0)
|
||||||
|
|
||||||
await second.prompt.click()
|
await second.prompt.click()
|
||||||
@@ -226,8 +226,8 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
|
|||||||
})
|
})
|
||||||
.toBeUndefined()
|
.toBeUndefined()
|
||||||
|
|
||||||
await expect(firstMessage.first()).toBeVisible()
|
await expect(firstMessage).toHaveCount(1)
|
||||||
await expect(secondMessage.first()).toBeVisible()
|
await expect(secondMessage).toHaveCount(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -32,22 +32,19 @@ test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
|
|||||||
|
|
||||||
await closeDialog(page, dialog)
|
await closeDialog(page, dialog)
|
||||||
|
|
||||||
const main = page.locator("main")
|
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||||
const initialClasses = (await main.getAttribute("class")) ?? ""
|
const initiallyClosed = (await button.getAttribute("aria-expanded")) !== "true"
|
||||||
const initiallyClosed = initialClasses.includes("xl:border-l")
|
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Shift+H`)
|
await page.keyboard.press(`${modKey}+Shift+H`)
|
||||||
await page.waitForTimeout(100)
|
await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "true" : "false")
|
||||||
|
|
||||||
const afterToggleClasses = (await main.getAttribute("class")) ?? ""
|
const afterToggleClosed = (await button.getAttribute("aria-expanded")) !== "true"
|
||||||
const afterToggleClosed = afterToggleClasses.includes("xl:border-l")
|
|
||||||
expect(afterToggleClosed).toBe(!initiallyClosed)
|
expect(afterToggleClosed).toBe(!initiallyClosed)
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Shift+H`)
|
await page.keyboard.press(`${modKey}+Shift+H`)
|
||||||
await page.waitForTimeout(100)
|
await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "false" : "true")
|
||||||
|
|
||||||
const finalClasses = (await main.getAttribute("class")) ?? ""
|
const finalClosed = (await button.getAttribute("aria-expanded")) !== "true"
|
||||||
const finalClosed = finalClasses.includes("xl:border-l")
|
|
||||||
expect(finalClosed).toBe(initiallyClosed)
|
expect(finalClosed).toBe(initiallyClosed)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -83,16 +83,23 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
|
|||||||
const select = dialog.locator(settingsThemeSelector)
|
const select = dialog.locator(settingsThemeSelector)
|
||||||
await expect(select).toBeVisible()
|
await expect(select).toBeVisible()
|
||||||
|
|
||||||
|
const currentThemeId = await page.evaluate(() => {
|
||||||
|
return document.documentElement.getAttribute("data-theme")
|
||||||
|
})
|
||||||
|
const currentTheme = (await select.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
|
||||||
|
|
||||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||||
|
|
||||||
const items = page.locator('[data-slot="select-select-item"]')
|
const items = page.locator('[data-slot="select-select-item"]')
|
||||||
const count = await items.count()
|
const count = await items.count()
|
||||||
expect(count).toBeGreaterThan(1)
|
expect(count).toBeGreaterThan(1)
|
||||||
|
|
||||||
const firstTheme = await items.nth(1).locator('[data-slot="select-select-item-label"]').textContent()
|
const nextTheme = (await items.locator('[data-slot="select-select-item-label"]').allTextContents())
|
||||||
expect(firstTheme).toBeTruthy()
|
.map((x) => x.trim())
|
||||||
|
.find((x) => x && x !== currentTheme)
|
||||||
|
expect(nextTheme).toBeTruthy()
|
||||||
|
|
||||||
await items.nth(1).click()
|
await items.filter({ hasText: nextTheme! }).first().click()
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
await page.keyboard.press("Escape")
|
||||||
|
|
||||||
@@ -101,7 +108,7 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(storedThemeId).not.toBeNull()
|
expect(storedThemeId).not.toBeNull()
|
||||||
expect(storedThemeId).not.toBe("oc-1")
|
expect(storedThemeId).not.toBe(currentThemeId)
|
||||||
|
|
||||||
const dataTheme = await page.evaluate(() => {
|
const dataTheme = await page.evaluate(() => {
|
||||||
return document.documentElement.getAttribute("data-theme")
|
return document.documentElement.getAttribute("data-theme")
|
||||||
@@ -109,6 +116,42 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
|
|||||||
expect(dataTheme).toBe(storedThemeId)
|
expect(dataTheme).toBe(storedThemeId)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("legacy oc-1 theme migrates to oc-2", async ({ page, gotoSession }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem("opencode-theme-id", "oc-1")
|
||||||
|
localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;")
|
||||||
|
localStorage.setItem("opencode-theme-css-dark", "--background-base:#000;")
|
||||||
|
})
|
||||||
|
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
await expect(page.locator("html")).toHaveAttribute("data-theme", "oc-2")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
return localStorage.getItem("opencode-theme-id")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.toBe("oc-2")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
return localStorage.getItem("opencode-theme-css-light")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.toBeNull()
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
return localStorage.getItem("opencode-theme-css-dark")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
test("changing font persists in localStorage and updates CSS variable", async ({ page, gotoSession }) => {
|
test("changing font persists in localStorage and updates CSS variable", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { closeSidebar, hoverSessionItem } from "../actions"
|
import { cleanupSession, closeSidebar, hoverSessionItem } from "../actions"
|
||||||
import { projectSwitchSelector, sessionItemSelector } from "../selectors"
|
import { projectSwitchSelector } from "../selectors"
|
||||||
|
|
||||||
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
|
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
@@ -15,12 +15,15 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
|
|||||||
await gotoSession(one.id)
|
await gotoSession(one.id)
|
||||||
await closeSidebar(page)
|
await closeSidebar(page)
|
||||||
|
|
||||||
|
const oneItem = page.locator(`[data-session-id="${one.id}"]`).last()
|
||||||
|
const twoItem = page.locator(`[data-session-id="${two.id}"]`).last()
|
||||||
|
|
||||||
const project = page.locator(projectSwitchSelector(slug)).first()
|
const project = page.locator(projectSwitchSelector(slug)).first()
|
||||||
await expect(project).toBeVisible()
|
await expect(project).toBeVisible()
|
||||||
await project.hover()
|
await project.hover()
|
||||||
|
|
||||||
await expect(page.locator(sessionItemSelector(one.id)).first()).toBeVisible()
|
await expect(oneItem).toBeVisible()
|
||||||
await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
|
await expect(twoItem).toBeVisible()
|
||||||
|
|
||||||
const item = await hoverSessionItem(page, one.id)
|
const item = await hoverSessionItem(page, one.id)
|
||||||
await item
|
await item
|
||||||
@@ -28,9 +31,9 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
|
|||||||
.first()
|
.first()
|
||||||
.click()
|
.click()
|
||||||
|
|
||||||
await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
|
await expect(twoItem).toBeVisible()
|
||||||
} finally {
|
} finally {
|
||||||
await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
|
await cleanupSession({ sdk, sessionID: one.id })
|
||||||
await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
|
await cleanupSession({ sdk, sessionID: two.id })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openSidebar, withSession } from "../actions"
|
import { cleanupSession, openSidebar, withSession } from "../actions"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => {
|
test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => {
|
||||||
@@ -18,14 +18,13 @@ test("sidebar session links navigate to the selected session", async ({ page, sl
|
|||||||
|
|
||||||
const target = page.locator(`[data-session-id="${two.id}"] a`).first()
|
const target = page.locator(`[data-session-id="${two.id}"] a`).first()
|
||||||
await expect(target).toBeVisible()
|
await expect(target).toBeVisible()
|
||||||
await target.scrollIntoViewIfNeeded()
|
|
||||||
await target.click()
|
await target.click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
await expect(page.locator(`[data-session-id="${two.id}"] a`).first()).toHaveClass(/\bactive\b/)
|
await expect(page.locator(`[data-session-id="${two.id}"] a`).first()).toHaveClass(/\bactive\b/)
|
||||||
} finally {
|
} finally {
|
||||||
await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
|
await cleanupSession({ sdk, sessionID: one.id })
|
||||||
await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
|
await cleanupSession({ sdk, sessionID: two.id })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
|
|||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||||
|
await expect(button).toHaveAttribute("aria-expanded", "true")
|
||||||
|
|
||||||
await toggleSidebar(page)
|
await toggleSidebar(page)
|
||||||
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
await expect(button).toHaveAttribute("aria-expanded", "false")
|
||||||
|
|
||||||
await toggleSidebar(page)
|
await toggleSidebar(page)
|
||||||
await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
|
await expect(button).toHaveAttribute("aria-expanded", "true")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("sidebar collapsed state persists across navigation and reload", async ({ page, sdk, gotoSession }) => {
|
test("sidebar collapsed state persists across navigation and reload", async ({ page, sdk, gotoSession }) => {
|
||||||
@@ -19,14 +21,15 @@ test("sidebar collapsed state persists across navigation and reload", async ({ p
|
|||||||
await gotoSession(session1.id)
|
await gotoSession(session1.id)
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||||
await toggleSidebar(page)
|
await toggleSidebar(page)
|
||||||
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
await expect(button).toHaveAttribute("aria-expanded", "false")
|
||||||
|
|
||||||
await gotoSession(session2.id)
|
await gotoSession(session2.id)
|
||||||
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
await expect(button).toHaveAttribute("aria-expanded", "false")
|
||||||
|
|
||||||
await page.reload()
|
await page.reload()
|
||||||
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
await expect(button).toHaveAttribute("aria-expanded", "false")
|
||||||
|
|
||||||
const opened = await page.evaluate(
|
const opened = await page.evaluate(
|
||||||
() => JSON.parse(localStorage.getItem("opencode.global.dat:layout") ?? "{}").sidebar?.opened,
|
() => JSON.parse(localStorage.getItem("opencode.global.dat:layout") ?? "{}").sidebar?.opened,
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { terminalSelector } from "../selectors"
|
||||||
|
import { terminalToggleKey, workspacePersistKey } from "../utils"
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
active?: string
|
||||||
|
all: Array<{
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
titleNumber: number
|
||||||
|
buffer?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function open(page: Page) {
|
||||||
|
const terminal = page.locator(terminalSelector)
|
||||||
|
const visible = await terminal.isVisible().catch(() => false)
|
||||||
|
if (!visible) await page.keyboard.press(terminalToggleKey)
|
||||||
|
await expect(terminal).toBeVisible()
|
||||||
|
await expect(terminal.locator("textarea")).toHaveCount(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(page: Page, cmd: string) {
|
||||||
|
const terminal = page.locator(terminalSelector)
|
||||||
|
await expect(terminal).toBeVisible()
|
||||||
|
await terminal.click()
|
||||||
|
await page.keyboard.type(cmd)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function store(page: Page, key: string) {
|
||||||
|
return page.evaluate((key) => {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
if (raw) return JSON.parse(raw) as State
|
||||||
|
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const next = localStorage.key(i)
|
||||||
|
if (!next?.endsWith(":workspace:terminal")) continue
|
||||||
|
const value = localStorage.getItem(next)
|
||||||
|
if (!value) continue
|
||||||
|
return JSON.parse(value) as State
|
||||||
|
}
|
||||||
|
}, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("inactive terminal tab buffers persist across tab switches", async ({ page, withProject }) => {
|
||||||
|
await withProject(async ({ directory, gotoSession }) => {
|
||||||
|
const key = workspacePersistKey(directory, "terminal")
|
||||||
|
const one = `E2E_TERM_ONE_${Date.now()}`
|
||||||
|
const two = `E2E_TERM_TWO_${Date.now()}`
|
||||||
|
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
||||||
|
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
||||||
|
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
||||||
|
|
||||||
|
await gotoSession()
|
||||||
|
await open(page)
|
||||||
|
|
||||||
|
await run(page, `echo ${one}`)
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: /new terminal/i }).click()
|
||||||
|
await expect(tabs).toHaveCount(2)
|
||||||
|
|
||||||
|
await run(page, `echo ${two}`)
|
||||||
|
|
||||||
|
await first.click()
|
||||||
|
await expect(first).toHaveAttribute("aria-selected", "true")
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const state = await store(page, key)
|
||||||
|
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
||||||
|
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
|
||||||
|
return {
|
||||||
|
first: first.includes(one),
|
||||||
|
second: second.includes(two),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toEqual({ first: false, second: true })
|
||||||
|
|
||||||
|
await second.click()
|
||||||
|
await expect(second).toHaveAttribute("aria-selected", "true")
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const state = await store(page, key)
|
||||||
|
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
||||||
|
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
|
||||||
|
return {
|
||||||
|
first: first.includes(one),
|
||||||
|
second: second.includes(two),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toEqual({ first: true, second: false })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("closing the active terminal tab falls back to the previous tab", async ({ page, withProject }) => {
|
||||||
|
await withProject(async ({ directory, gotoSession }) => {
|
||||||
|
const key = workspacePersistKey(directory, "terminal")
|
||||||
|
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
||||||
|
|
||||||
|
await gotoSession()
|
||||||
|
await open(page)
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: /new terminal/i }).click()
|
||||||
|
await expect(tabs).toHaveCount(2)
|
||||||
|
|
||||||
|
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
||||||
|
await second.click()
|
||||||
|
await expect(second).toHaveAttribute("aria-selected", "true")
|
||||||
|
|
||||||
|
await second.hover()
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: /close terminal/i })
|
||||||
|
.nth(1)
|
||||||
|
.click({ force: true })
|
||||||
|
|
||||||
|
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
||||||
|
await expect(tabs).toHaveCount(1)
|
||||||
|
await expect(first).toHaveAttribute("aria-selected", "true")
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const state = await store(page, key)
|
||||||
|
return {
|
||||||
|
count: state?.all.length ?? 0,
|
||||||
|
first: state?.all.some((item) => item.titleNumber === 1) ?? false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.toEqual({ count: 1, first: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||||
import { base64Encode } from "@opencode-ai/util/encode"
|
import { base64Encode, checksum } from "@opencode-ai/util/encode"
|
||||||
|
|
||||||
export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
|
export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
|
||||||
export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||||
@@ -7,6 +7,22 @@ export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
|||||||
export const serverUrl = `http://${serverHost}:${serverPort}`
|
export const serverUrl = `http://${serverHost}:${serverPort}`
|
||||||
export const serverName = `${serverHost}:${serverPort}`
|
export const serverName = `${serverHost}:${serverPort}`
|
||||||
|
|
||||||
|
const localHosts = ["127.0.0.1", "localhost"]
|
||||||
|
|
||||||
|
const serverLabels = (() => {
|
||||||
|
const url = new URL(serverUrl)
|
||||||
|
if (!localHosts.includes(url.hostname)) return [serverName]
|
||||||
|
return localHosts.map((host) => `${host}:${url.port}`)
|
||||||
|
})()
|
||||||
|
|
||||||
|
export const serverNames = [...new Set(serverLabels)]
|
||||||
|
|
||||||
|
export const serverUrls = serverNames.map((name) => `http://${name}`)
|
||||||
|
|
||||||
|
const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||||
|
|
||||||
|
export const serverNamePattern = new RegExp(`(?:${serverNames.map(escape).join("|")})`)
|
||||||
|
|
||||||
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
|
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
|
||||||
export const terminalToggleKey = "Control+Backquote"
|
export const terminalToggleKey = "Control+Backquote"
|
||||||
|
|
||||||
@@ -14,6 +30,12 @@ export function createSdk(directory?: string) {
|
|||||||
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
|
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolveDirectory(directory: string) {
|
||||||
|
return createSdk(directory)
|
||||||
|
.path.get()
|
||||||
|
.then((x) => x.data?.directory ?? directory)
|
||||||
|
}
|
||||||
|
|
||||||
export async function getWorktree() {
|
export async function getWorktree() {
|
||||||
const sdk = createSdk()
|
const sdk = createSdk()
|
||||||
const result = await sdk.path.get()
|
const result = await sdk.path.get()
|
||||||
@@ -33,3 +55,9 @@ export function dirPath(directory: string) {
|
|||||||
export function sessionPath(directory: string, sessionID?: string) {
|
export function sessionPath(directory: string, sessionID?: string) {
|
||||||
return `${dirPath(directory)}/session${sessionID ? `/${sessionID}` : ""}`
|
return `${dirPath(directory)}/session${sessionID ? `/${sessionID}` : ""}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function workspacePersistKey(directory: string, key: string) {
|
||||||
|
const head = (directory.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||||
|
const sum = checksum(directory) ?? "0"
|
||||||
|
return `opencode.workspace.${head}.${sum}.dat:workspace:${key}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.2.17",
|
"version": "1.2.24",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
"@thisbeyond/solid-dnd": "0.7.5",
|
"@thisbeyond/solid-dnd": "0.7.5",
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
"ghostty-web": "0.4.0",
|
"ghostty-web": "github:anomalyco/ghostty-web#main",
|
||||||
"luxon": "catalog:",
|
"luxon": "catalog:",
|
||||||
"marked": "catalog:",
|
"marked": "catalog:",
|
||||||
"marked-shiki": "catalog:",
|
"marked-shiki": "catalog:",
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
;(function () {
|
;(function () {
|
||||||
var themeId = localStorage.getItem("opencode-theme-id")
|
var key = "opencode-theme-id"
|
||||||
if (!themeId) return
|
var themeId = localStorage.getItem(key) || "oc-2"
|
||||||
|
|
||||||
|
if (themeId === "oc-1") {
|
||||||
|
themeId = "oc-2"
|
||||||
|
localStorage.setItem(key, themeId)
|
||||||
|
localStorage.removeItem("opencode-theme-css-light")
|
||||||
|
localStorage.removeItem("opencode-theme-css-dark")
|
||||||
|
}
|
||||||
|
|
||||||
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
|
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
|
||||||
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
|
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
|
||||||
@@ -9,9 +16,9 @@
|
|||||||
document.documentElement.dataset.theme = themeId
|
document.documentElement.dataset.theme = themeId
|
||||||
document.documentElement.dataset.colorScheme = mode
|
document.documentElement.dataset.colorScheme = mode
|
||||||
|
|
||||||
if (themeId === "oc-1") return
|
if (themeId === "oc-2") return
|
||||||
|
|
||||||
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
|
var css = localStorage.getItem("opencode-theme-css-" + mode)
|
||||||
if (css) {
|
if (css) {
|
||||||
var style = document.createElement("style")
|
var style = document.createElement("style")
|
||||||
style.id = "oc-theme-preload"
|
style.id = "oc-theme-preload"
|
||||||
|
|||||||
@@ -0,0 +1,441 @@
|
|||||||
|
import { useIsRouting, useLocation } from "@solidjs/router"
|
||||||
|
import { batch, createEffect, onCleanup, onMount } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
|
|
||||||
|
type Mem = Performance & {
|
||||||
|
memory?: {
|
||||||
|
usedJSHeapSize: number
|
||||||
|
jsHeapSizeLimit: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Evt = PerformanceEntry & {
|
||||||
|
interactionId?: number
|
||||||
|
processingStart?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Shift = PerformanceEntry & {
|
||||||
|
hadRecentInput: boolean
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Obs = PerformanceObserverInit & {
|
||||||
|
durationThreshold?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const span = 5000
|
||||||
|
|
||||||
|
const ms = (n?: number, d = 0) => {
|
||||||
|
if (n === undefined || Number.isNaN(n)) return "n/a"
|
||||||
|
return `${n.toFixed(d)}ms`
|
||||||
|
}
|
||||||
|
|
||||||
|
const time = (n?: number) => {
|
||||||
|
if (n === undefined || Number.isNaN(n)) return "n/a"
|
||||||
|
return `${Math.round(n)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const mb = (n?: number) => {
|
||||||
|
if (n === undefined || Number.isNaN(n)) return "n/a"
|
||||||
|
const v = n / 1024 / 1024
|
||||||
|
return `${v >= 1024 ? v.toFixed(0) : v.toFixed(1)}MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
const bad = (n: number | undefined, limit: number, low = false) => {
|
||||||
|
if (n === undefined || Number.isNaN(n)) return false
|
||||||
|
return low ? n < limit : n > limit
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] bg-white/5 px-0.5 py-1 text-center": true,
|
||||||
|
"col-span-2": !!props.wide,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DebugBar() {
|
||||||
|
const location = useLocation()
|
||||||
|
const routing = useIsRouting()
|
||||||
|
const [state, setState] = createStore({
|
||||||
|
cls: undefined as number | undefined,
|
||||||
|
delay: undefined as number | undefined,
|
||||||
|
fps: undefined as number | undefined,
|
||||||
|
gap: undefined as number | undefined,
|
||||||
|
heap: {
|
||||||
|
limit: undefined as number | undefined,
|
||||||
|
used: undefined as number | undefined,
|
||||||
|
},
|
||||||
|
inp: undefined as number | undefined,
|
||||||
|
jank: undefined as number | undefined,
|
||||||
|
long: {
|
||||||
|
block: undefined as number | undefined,
|
||||||
|
count: undefined as number | undefined,
|
||||||
|
max: undefined as number | undefined,
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
dur: undefined as number | undefined,
|
||||||
|
pending: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined)
|
||||||
|
const heapv = () => {
|
||||||
|
const value = heap()
|
||||||
|
if (value === undefined) return "n/a"
|
||||||
|
return `${Math.round(value * 100)}%`
|
||||||
|
}
|
||||||
|
const longv = () => (state.long.count === undefined ? "n/a" : `${time(state.long.block)}/${state.long.count}`)
|
||||||
|
const navv = () => (state.nav.pending ? "..." : time(state.nav.dur))
|
||||||
|
|
||||||
|
let prev = ""
|
||||||
|
let start = 0
|
||||||
|
let init = false
|
||||||
|
let one = 0
|
||||||
|
let two = 0
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const busy = routing()
|
||||||
|
const next = `${location.pathname}${location.search}`
|
||||||
|
|
||||||
|
if (!init) {
|
||||||
|
init = true
|
||||||
|
prev = next
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (busy) {
|
||||||
|
if (one !== 0) cancelAnimationFrame(one)
|
||||||
|
if (two !== 0) cancelAnimationFrame(two)
|
||||||
|
one = 0
|
||||||
|
two = 0
|
||||||
|
if (start !== 0) return
|
||||||
|
start = performance.now()
|
||||||
|
if (session(prev)) setState("nav", { dur: undefined, pending: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start === 0) {
|
||||||
|
prev = next
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const at = start
|
||||||
|
const from = prev
|
||||||
|
start = 0
|
||||||
|
prev = next
|
||||||
|
|
||||||
|
if (!(session(from) || session(next))) return
|
||||||
|
|
||||||
|
if (one !== 0) cancelAnimationFrame(one)
|
||||||
|
if (two !== 0) cancelAnimationFrame(two)
|
||||||
|
one = requestAnimationFrame(() => {
|
||||||
|
one = 0
|
||||||
|
two = requestAnimationFrame(() => {
|
||||||
|
two = 0
|
||||||
|
setState("nav", { dur: performance.now() - at, pending: false })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const obs: PerformanceObserver[] = []
|
||||||
|
const fps: Array<{ at: number; dur: number }> = []
|
||||||
|
const long: Array<{ at: number; dur: number }> = []
|
||||||
|
const seen = new Map<number | string, { at: number; delay: number; dur: number }>()
|
||||||
|
let hasLong = false
|
||||||
|
let poll: number | undefined
|
||||||
|
let raf = 0
|
||||||
|
let last = 0
|
||||||
|
let snap = 0
|
||||||
|
|
||||||
|
const trim = (list: Array<{ at: number; dur: number }>, span: number, at: number) => {
|
||||||
|
while (list[0] && at - list[0].at > span) list.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncFrame = (at: number) => {
|
||||||
|
trim(fps, span, at)
|
||||||
|
const total = fps.reduce((sum, entry) => sum + entry.dur, 0)
|
||||||
|
const gap = fps.reduce((max, entry) => Math.max(max, entry.dur), 0)
|
||||||
|
const jank = fps.filter((entry) => entry.dur > 32).length
|
||||||
|
batch(() => {
|
||||||
|
setState("fps", total > 0 ? (fps.length * 1000) / total : undefined)
|
||||||
|
setState("gap", gap > 0 ? gap : undefined)
|
||||||
|
setState("jank", jank)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncLong = (at = performance.now()) => {
|
||||||
|
if (!hasLong) return
|
||||||
|
trim(long, span, at)
|
||||||
|
const block = long.reduce((sum, entry) => sum + Math.max(0, entry.dur - 50), 0)
|
||||||
|
const max = long.reduce((hi, entry) => Math.max(hi, entry.dur), 0)
|
||||||
|
setState("long", { block, count: long.length, max })
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncInp = (at = performance.now()) => {
|
||||||
|
for (const [key, entry] of seen) {
|
||||||
|
if (at - entry.at > span) seen.delete(key)
|
||||||
|
}
|
||||||
|
let delay = 0
|
||||||
|
let inp = 0
|
||||||
|
for (const entry of seen.values()) {
|
||||||
|
delay = Math.max(delay, entry.delay)
|
||||||
|
inp = Math.max(inp, entry.dur)
|
||||||
|
}
|
||||||
|
batch(() => {
|
||||||
|
setState("delay", delay > 0 ? delay : undefined)
|
||||||
|
setState("inp", inp > 0 ? inp : undefined)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncHeap = () => {
|
||||||
|
const mem = (performance as Mem).memory
|
||||||
|
if (!mem) return
|
||||||
|
setState("heap", { limit: mem.jsHeapSizeLimit, used: mem.usedJSHeapSize })
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
fps.length = 0
|
||||||
|
long.length = 0
|
||||||
|
seen.clear()
|
||||||
|
last = 0
|
||||||
|
snap = 0
|
||||||
|
batch(() => {
|
||||||
|
setState("fps", undefined)
|
||||||
|
setState("gap", undefined)
|
||||||
|
setState("jank", undefined)
|
||||||
|
setState("delay", undefined)
|
||||||
|
setState("inp", undefined)
|
||||||
|
if (hasLong) setState("long", { block: 0, count: 0, max: 0 })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const watch = (type: string, init: Obs, fn: (entries: PerformanceEntry[]) => void) => {
|
||||||
|
if (typeof PerformanceObserver === "undefined") return false
|
||||||
|
if (!(PerformanceObserver.supportedEntryTypes ?? []).includes(type)) return false
|
||||||
|
const ob = new PerformanceObserver((list) => fn(list.getEntries()))
|
||||||
|
try {
|
||||||
|
ob.observe(init)
|
||||||
|
obs.push(ob)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
ob.disconnect()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
watch("layout-shift", { buffered: true, type: "layout-shift" }, (entries) => {
|
||||||
|
const add = entries.reduce((sum, entry) => {
|
||||||
|
const item = entry as Shift
|
||||||
|
if (item.hadRecentInput) return sum
|
||||||
|
return sum + item.value
|
||||||
|
}, 0)
|
||||||
|
if (add === 0) return
|
||||||
|
setState("cls", (value) => (value ?? 0) + add)
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
setState("cls", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
watch("longtask", { buffered: true, type: "longtask" }, (entries) => {
|
||||||
|
const at = performance.now()
|
||||||
|
long.push(...entries.map((entry) => ({ at: entry.startTime, dur: entry.duration })))
|
||||||
|
syncLong(at)
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
hasLong = true
|
||||||
|
setState("long", { block: 0, count: 0, max: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
watch("event", { buffered: true, durationThreshold: 16, type: "event" }, (entries) => {
|
||||||
|
for (const raw of entries) {
|
||||||
|
const entry = raw as Evt
|
||||||
|
if (entry.duration < 16) continue
|
||||||
|
const key =
|
||||||
|
entry.interactionId && entry.interactionId > 0
|
||||||
|
? entry.interactionId
|
||||||
|
: `${entry.name}:${Math.round(entry.startTime)}`
|
||||||
|
const prev = seen.get(key)
|
||||||
|
const delay = Math.max(0, (entry.processingStart ?? entry.startTime) - entry.startTime)
|
||||||
|
seen.set(key, {
|
||||||
|
at: entry.startTime,
|
||||||
|
delay: Math.max(prev?.delay ?? 0, delay),
|
||||||
|
dur: Math.max(prev?.dur ?? 0, entry.duration),
|
||||||
|
})
|
||||||
|
if (seen.size <= 200) continue
|
||||||
|
const first = seen.keys().next().value
|
||||||
|
if (first !== undefined) seen.delete(first)
|
||||||
|
}
|
||||||
|
syncInp()
|
||||||
|
})
|
||||||
|
|
||||||
|
const loop = (at: number) => {
|
||||||
|
if (document.visibilityState !== "visible") {
|
||||||
|
raf = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (last === 0) {
|
||||||
|
last = at
|
||||||
|
raf = requestAnimationFrame(loop)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fps.push({ at, dur: at - last })
|
||||||
|
last = at
|
||||||
|
|
||||||
|
if (at - snap >= 250) {
|
||||||
|
snap = at
|
||||||
|
syncFrame(at)
|
||||||
|
}
|
||||||
|
|
||||||
|
raf = requestAnimationFrame(loop)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (raf !== 0) cancelAnimationFrame(raf)
|
||||||
|
raf = 0
|
||||||
|
if (poll === undefined) return
|
||||||
|
clearInterval(poll)
|
||||||
|
poll = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
if (document.visibilityState !== "visible") return
|
||||||
|
if (poll === undefined) {
|
||||||
|
poll = window.setInterval(() => {
|
||||||
|
syncLong()
|
||||||
|
syncInp()
|
||||||
|
syncHeap()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
if (raf !== 0) return
|
||||||
|
raf = requestAnimationFrame(loop)
|
||||||
|
}
|
||||||
|
|
||||||
|
const vis = () => {
|
||||||
|
if (document.visibilityState !== "visible") {
|
||||||
|
stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reset()
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
|
||||||
|
syncHeap()
|
||||||
|
start()
|
||||||
|
document.addEventListener("visibilitychange", vis)
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (one !== 0) cancelAnimationFrame(one)
|
||||||
|
if (two !== 0) cancelAnimationFrame(two)
|
||||||
|
stop()
|
||||||
|
document.removeEventListener("visibilitychange", vis)
|
||||||
|
for (const ob of obs) ob.disconnect()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
aria-label="Development performance diagnostics"
|
||||||
|
class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border p-0.5 text-text-on-interactive-base shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
|
||||||
|
style={{
|
||||||
|
"background-color": "color-mix(in srgb, var(--icon-interactive-base) 42%, black)",
|
||||||
|
"border-color": "color-mix(in srgb, white 14%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-5 gap-px font-mono">
|
||||||
|
<Cell
|
||||||
|
label="NAV"
|
||||||
|
tip="Last completed route transition touching a session page, measured from router start until the first paint after it settles."
|
||||||
|
value={navv()}
|
||||||
|
bad={bad(state.nav.dur, 400)}
|
||||||
|
dim={state.nav.dur === undefined && !state.nav.pending}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label="FPS"
|
||||||
|
tip="Rolling frames per second over the last 5 seconds."
|
||||||
|
value={state.fps === undefined ? "n/a" : `${Math.round(state.fps)}`}
|
||||||
|
bad={bad(state.fps, 50, true)}
|
||||||
|
dim={state.fps === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label="FRM"
|
||||||
|
tip="Worst frame time over the last 5 seconds."
|
||||||
|
value={time(state.gap)}
|
||||||
|
bad={bad(state.gap, 50)}
|
||||||
|
dim={state.gap === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label="JNK"
|
||||||
|
tip="Frames over 32ms in the last 5 seconds."
|
||||||
|
value={state.jank === undefined ? "n/a" : `${state.jank}`}
|
||||||
|
bad={bad(state.jank, 8)}
|
||||||
|
dim={state.jank === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label="LNG"
|
||||||
|
tip={`Blocked time and long-task count in the last 5 seconds. Max task: ${ms(state.long.max)}.`}
|
||||||
|
value={longv()}
|
||||||
|
bad={bad(state.long.block, 200)}
|
||||||
|
dim={state.long.count === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label="DLY"
|
||||||
|
tip="Worst observed input delay in the last 5 seconds."
|
||||||
|
value={time(state.delay)}
|
||||||
|
bad={bad(state.delay, 100)}
|
||||||
|
dim={state.delay === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label="INP"
|
||||||
|
tip="Approximate interaction duration over the last 5 seconds. This is INP-like, not the official Web Vitals INP."
|
||||||
|
value={time(state.inp)}
|
||||||
|
bad={bad(state.inp, 200)}
|
||||||
|
dim={state.inp === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label="CLS"
|
||||||
|
tip="Cumulative layout shift for the current app lifetime."
|
||||||
|
value={state.cls === undefined ? "n/a" : state.cls.toFixed(2)}
|
||||||
|
bad={bad(state.cls, 0.1)}
|
||||||
|
dim={state.cls === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label="MEM"
|
||||||
|
tip={
|
||||||
|
state.heap.used === undefined
|
||||||
|
? "Used JS heap vs heap limit. Chromium only."
|
||||||
|
: `Used JS heap vs heap limit. ${mb(state.heap.used)} of ${mb(state.heap.limit)}.`
|
||||||
|
}
|
||||||
|
value={heapv()}
|
||||||
|
bad={bad(heap(), 0.8)}
|
||||||
|
dim={state.heap.used === undefined}
|
||||||
|
wide
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -244,7 +244,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
draggingType: "image" | "@mention" | null
|
draggingType: "image" | "@mention" | null
|
||||||
mode: "normal" | "shell"
|
mode: "normal" | "shell"
|
||||||
applyingHistory: boolean
|
applyingHistory: boolean
|
||||||
pendingAutoAccept: boolean
|
|
||||||
}>({
|
}>({
|
||||||
popover: null,
|
popover: null,
|
||||||
historyIndex: -1,
|
historyIndex: -1,
|
||||||
@@ -253,7 +252,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
draggingType: null,
|
draggingType: null,
|
||||||
mode: "normal",
|
mode: "normal",
|
||||||
applyingHistory: false,
|
applyingHistory: false,
|
||||||
pendingAutoAccept: false,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
|
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
|
||||||
@@ -306,12 +304,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(sessionKey, () => {
|
|
||||||
setStore("pendingAutoAccept", false)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const historyComments = () => {
|
const historyComments = () => {
|
||||||
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
|
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
|
||||||
return prompt.context.items().flatMap((item) => {
|
return prompt.context.items().flatMap((item) => {
|
||||||
@@ -961,7 +953,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const variants = createMemo(() => ["default", ...local.model.variant.list()])
|
const variants = createMemo(() => ["default", ...local.model.variant.list()])
|
||||||
const accepting = createMemo(() => {
|
const accepting = createMemo(() => {
|
||||||
const id = params.id
|
const id = params.id
|
||||||
if (!id) return store.pendingAutoAccept
|
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
|
||||||
return permission.isAutoAccepting(id, sdk.directory)
|
return permission.isAutoAccepting(id, sdk.directory)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1211,9 +1203,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
aria-multiline="true"
|
aria-multiline="true"
|
||||||
aria-label={placeholder()}
|
aria-label={placeholder()}
|
||||||
contenteditable="true"
|
contenteditable="true"
|
||||||
autocapitalize="off"
|
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
|
||||||
autocorrect="off"
|
autocorrect={store.mode === "normal" ? "on" : "off"}
|
||||||
spellcheck={false}
|
spellcheck={store.mode === "normal"}
|
||||||
onInput={handleInput}
|
onInput={handleInput}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
onCompositionStart={() => setComposing(true)}
|
onCompositionStart={() => setComposing(true)}
|
||||||
@@ -1336,7 +1328,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!params.id) {
|
if (!params.id) {
|
||||||
setStore("pendingAutoAccept", (value) => !value)
|
permission.toggleAutoAcceptDirectory(sdk.directory)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
permission.toggleAutoAccept(params.id, sdk.directory)
|
permission.toggleAutoAccept(params.id, sdk.directory)
|
||||||
|
|||||||
@@ -6,10 +6,19 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
|||||||
const createdClients: string[] = []
|
const createdClients: string[] = []
|
||||||
const createdSessions: string[] = []
|
const createdSessions: string[] = []
|
||||||
const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
|
const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
|
||||||
|
const optimistic: Array<{
|
||||||
|
message: {
|
||||||
|
agent: string
|
||||||
|
model: { providerID: string; modelID: string }
|
||||||
|
variant?: string
|
||||||
|
}
|
||||||
|
}> = []
|
||||||
const sentShell: string[] = []
|
const sentShell: string[] = []
|
||||||
const syncedDirectories: string[] = []
|
const syncedDirectories: string[] = []
|
||||||
|
|
||||||
|
let params: { id?: string } = {}
|
||||||
let selected = "/repo/worktree-a"
|
let selected = "/repo/worktree-a"
|
||||||
|
let variant: string | undefined
|
||||||
|
|
||||||
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||||
|
|
||||||
@@ -26,6 +35,7 @@ const clientFor = (directory: string) => {
|
|||||||
return { data: undefined }
|
return { data: undefined }
|
||||||
},
|
},
|
||||||
prompt: async () => ({ data: undefined }),
|
prompt: async () => ({ data: undefined }),
|
||||||
|
promptAsync: async () => ({ data: undefined }),
|
||||||
command: async () => ({ data: undefined }),
|
command: async () => ({ data: undefined }),
|
||||||
abort: async () => ({ data: undefined }),
|
abort: async () => ({ data: undefined }),
|
||||||
},
|
},
|
||||||
@@ -40,7 +50,7 @@ beforeAll(async () => {
|
|||||||
|
|
||||||
mock.module("@solidjs/router", () => ({
|
mock.module("@solidjs/router", () => ({
|
||||||
useNavigate: () => () => undefined,
|
useNavigate: () => () => undefined,
|
||||||
useParams: () => ({}),
|
useParams: () => params,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@opencode-ai/sdk/v2/client", () => ({
|
mock.module("@opencode-ai/sdk/v2/client", () => ({
|
||||||
@@ -62,7 +72,7 @@ beforeAll(async () => {
|
|||||||
useLocal: () => ({
|
useLocal: () => ({
|
||||||
model: {
|
model: {
|
||||||
current: () => ({ id: "model", provider: { id: "provider" } }),
|
current: () => ({ id: "model", provider: { id: "provider" } }),
|
||||||
variant: { current: () => undefined },
|
variant: { current: () => variant },
|
||||||
},
|
},
|
||||||
agent: {
|
agent: {
|
||||||
current: () => ({ name: "agent" }),
|
current: () => ({ name: "agent" }),
|
||||||
@@ -118,7 +128,11 @@ beforeAll(async () => {
|
|||||||
data: { command: [] },
|
data: { command: [] },
|
||||||
session: {
|
session: {
|
||||||
optimistic: {
|
optimistic: {
|
||||||
add: () => undefined,
|
add: (value: {
|
||||||
|
message: { agent: string; model: { providerID: string; modelID: string }; variant?: string }
|
||||||
|
}) => {
|
||||||
|
optimistic.push(value)
|
||||||
|
},
|
||||||
remove: () => undefined,
|
remove: () => undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -155,9 +169,12 @@ beforeEach(() => {
|
|||||||
createdClients.length = 0
|
createdClients.length = 0
|
||||||
createdSessions.length = 0
|
createdSessions.length = 0
|
||||||
enabledAutoAccept.length = 0
|
enabledAutoAccept.length = 0
|
||||||
|
optimistic.length = 0
|
||||||
|
params = {}
|
||||||
sentShell.length = 0
|
sentShell.length = 0
|
||||||
syncedDirectories.length = 0
|
syncedDirectories.length = 0
|
||||||
selected = "/repo/worktree-a"
|
selected = "/repo/worktree-a"
|
||||||
|
variant = undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("prompt submit worktree selection", () => {
|
describe("prompt submit worktree selection", () => {
|
||||||
@@ -219,4 +236,39 @@ describe("prompt submit worktree selection", () => {
|
|||||||
|
|
||||||
expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }])
|
expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("includes the selected variant on optimistic prompts", async () => {
|
||||||
|
params = { id: "session-1" }
|
||||||
|
variant = "high"
|
||||||
|
|
||||||
|
const submit = createPromptSubmit({
|
||||||
|
info: () => ({ id: "session-1" }),
|
||||||
|
imageAttachments: () => [],
|
||||||
|
commentCount: () => 0,
|
||||||
|
autoAccept: () => false,
|
||||||
|
mode: () => "normal",
|
||||||
|
working: () => false,
|
||||||
|
editor: () => undefined,
|
||||||
|
queueScroll: () => undefined,
|
||||||
|
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||||
|
addToHistory: () => undefined,
|
||||||
|
resetHistoryNavigation: () => undefined,
|
||||||
|
setMode: () => undefined,
|
||||||
|
setPopover: () => undefined,
|
||||||
|
onSubmit: () => undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = { preventDefault: () => undefined } as unknown as Event
|
||||||
|
|
||||||
|
await submit.handleSubmit(event)
|
||||||
|
|
||||||
|
expect(optimistic).toHaveLength(1)
|
||||||
|
expect(optimistic[0]).toMatchObject({
|
||||||
|
message: {
|
||||||
|
agent: "agent",
|
||||||
|
model: { providerID: "provider", modelID: "model" },
|
||||||
|
variant: "high",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Identifier } from "@/utils/id"
|
|||||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||||
import { buildRequestParts } from "./build-request-parts"
|
import { buildRequestParts } from "./build-request-parts"
|
||||||
import { setCursorPosition } from "./editor-dom"
|
import { setCursorPosition } from "./editor-dom"
|
||||||
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
|
|
||||||
type PendingPrompt = {
|
type PendingPrompt = {
|
||||||
abort: AbortController
|
abort: AbortController
|
||||||
@@ -286,7 +287,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||||
description: errorMessage(err),
|
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||||
})
|
})
|
||||||
restoreInput()
|
restoreInput()
|
||||||
})
|
})
|
||||||
@@ -315,6 +316,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
time: { created: Date.now() },
|
time: { created: Date.now() },
|
||||||
agent,
|
agent,
|
||||||
model,
|
model,
|
||||||
|
variant,
|
||||||
}
|
}
|
||||||
|
|
||||||
const addOptimisticMessage = () =>
|
const addOptimisticMessage = () =>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
|
|
||||||
const usd = createMemo(
|
const usd = createMemo(
|
||||||
() =>
|
() =>
|
||||||
new Intl.NumberFormat(language.locale(), {
|
new Intl.NumberFormat(language.intl(), {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "USD",
|
currency: "USD",
|
||||||
}),
|
}),
|
||||||
@@ -77,7 +77,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
{(ctx) => (
|
{(ctx) => (
|
||||||
<>
|
<>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-text-invert-strong">{ctx().total.toLocaleString(language.locale())}</span>
|
<span class="text-text-invert-strong">{ctx().total.toLocaleString(language.intl())}</span>
|
||||||
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
|
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export function SessionContextTab() {
|
|||||||
|
|
||||||
const usd = createMemo(
|
const usd = createMemo(
|
||||||
() =>
|
() =>
|
||||||
new Intl.NumberFormat(language.locale(), {
|
new Intl.NumberFormat(language.intl(), {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "USD",
|
currency: "USD",
|
||||||
}),
|
}),
|
||||||
@@ -136,7 +136,7 @@ export function SessionContextTab() {
|
|||||||
|
|
||||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
|
const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
|
||||||
const ctx = createMemo(() => metrics().context)
|
const ctx = createMemo(() => metrics().context)
|
||||||
const formatter = createMemo(() => createSessionContextFormatter(language.locale()))
|
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
|
||||||
|
|
||||||
const cost = createMemo(() => {
|
const cost = createMemo(() => {
|
||||||
return usd().format(metrics().totalCost)
|
return usd().format(metrics().totalCost)
|
||||||
@@ -200,7 +200,7 @@ export function SessionContextTab() {
|
|||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{ label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" },
|
{ label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" },
|
||||||
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.locale()) },
|
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.intl()) },
|
||||||
{ label: "context.stats.provider", value: providerLabel },
|
{ label: "context.stats.provider", value: providerLabel },
|
||||||
{ label: "context.stats.model", value: modelLabel },
|
{ label: "context.stats.model", value: modelLabel },
|
||||||
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
|
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
|
||||||
@@ -213,8 +213,8 @@ export function SessionContextTab() {
|
|||||||
label: "context.stats.cacheTokens",
|
label: "context.stats.cacheTokens",
|
||||||
value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`,
|
value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`,
|
||||||
},
|
},
|
||||||
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.locale()) },
|
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
|
||||||
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.locale()) },
|
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
|
||||||
{ label: "context.stats.totalCost", value: cost },
|
{ label: "context.stats.totalCost", value: cost },
|
||||||
{ label: "context.stats.sessionCreated", value: () => formatter().time(info()?.time.created) },
|
{ label: "context.stats.sessionCreated", value: () => formatter().time(info()?.time.created) },
|
||||||
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
|
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
|
||||||
@@ -307,7 +307,7 @@ export function SessionContextTab() {
|
|||||||
<div class="flex items-center gap-1 text-11-regular text-text-weak">
|
<div class="flex items-center gap-1 text-11-regular text-text-weak">
|
||||||
<div class="size-2 rounded-sm" style={{ "background-color": BREAKDOWN_COLOR[segment.key] }} />
|
<div class="size-2 rounded-sm" style={{ "background-color": BREAKDOWN_COLOR[segment.key] }} />
|
||||||
<div>{breakdownLabel(segment.key)}</div>
|
<div>{breakdownLabel(segment.key)}</div>
|
||||||
<div class="text-text-weaker">{segment.percent.toLocaleString(language.locale())}%</div>
|
<div class="text-text-weaker">{segment.percent.toLocaleString(language.intl())}%</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import { useLayout } from "@/context/layout"
|
|||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useServer } from "@/context/server"
|
import { useServer } from "@/context/server"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
|
import { useTerminal } from "@/context/terminal"
|
||||||
|
import { focusTerminalById } from "@/pages/session/helpers"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { StatusPopover } from "../status-popover"
|
import { StatusPopover } from "../status-popover"
|
||||||
@@ -229,6 +231,7 @@ export function SessionHeader() {
|
|||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const terminal = useTerminal()
|
||||||
|
|
||||||
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||||
const project = createMemo(() => {
|
const project = createMemo(() => {
|
||||||
@@ -296,6 +299,16 @@ export function SessionHeader() {
|
|||||||
] as const
|
] as const
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const toggleTerminal = () => {
|
||||||
|
const next = !view().terminal.opened()
|
||||||
|
view().terminal.toggle()
|
||||||
|
if (!next) return
|
||||||
|
|
||||||
|
const id = terminal.active()
|
||||||
|
if (!id) return
|
||||||
|
focusTerminalById(id)
|
||||||
|
}
|
||||||
|
|
||||||
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
|
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
|
||||||
const [menu, setMenu] = createStore({ open: false })
|
const [menu, setMenu] = createStore({ open: false })
|
||||||
const [openRequest, setOpenRequest] = createStore({
|
const [openRequest, setOpenRequest] = createStore({
|
||||||
@@ -303,7 +316,12 @@ export function SessionHeader() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
|
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
|
||||||
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
|
const current = createMemo(
|
||||||
|
() =>
|
||||||
|
options().find((o) => o.id === prefs.app) ??
|
||||||
|
options()[0] ??
|
||||||
|
({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
|
||||||
|
)
|
||||||
const opening = createMemo(() => openRequest.app !== undefined)
|
const opening = createMemo(() => openRequest.app !== undefined)
|
||||||
|
|
||||||
const selectApp = (app: OpenApp) => {
|
const selectApp = (app: OpenApp) => {
|
||||||
@@ -612,39 +630,39 @@ export function SessionHeader() {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<div class="hidden md:flex items-center gap-1 shrink-0">
|
<TooltipKeybind
|
||||||
<TooltipKeybind
|
title={language.t("command.terminal.toggle")}
|
||||||
title={language.t("command.terminal.toggle")}
|
keybind={command.keybind("terminal.toggle")}
|
||||||
keybind={command.keybind("terminal.toggle")}
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
|
||||||
|
onClick={toggleTerminal}
|
||||||
|
aria-label={language.t("command.terminal.toggle")}
|
||||||
|
aria-expanded={view().terminal.opened()}
|
||||||
|
aria-controls="terminal-panel"
|
||||||
>
|
>
|
||||||
<Button
|
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||||
variant="ghost"
|
<Icon
|
||||||
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border"
|
size="small"
|
||||||
onClick={() => view().terminal.toggle()}
|
name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"}
|
||||||
aria-label={language.t("command.terminal.toggle")}
|
class="group-hover/terminal-toggle:hidden"
|
||||||
aria-expanded={view().terminal.opened()}
|
/>
|
||||||
aria-controls="terminal-panel"
|
<Icon
|
||||||
>
|
size="small"
|
||||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
name="layout-bottom-partial"
|
||||||
<Icon
|
class="hidden group-hover/terminal-toggle:inline-block"
|
||||||
size="small"
|
/>
|
||||||
name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"}
|
<Icon
|
||||||
class="group-hover/terminal-toggle:hidden"
|
size="small"
|
||||||
/>
|
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-partial"}
|
||||||
<Icon
|
class="hidden group-active/terminal-toggle:inline-block"
|
||||||
size="small"
|
/>
|
||||||
name="layout-bottom-partial"
|
</div>
|
||||||
class="hidden group-hover/terminal-toggle:inline-block"
|
</Button>
|
||||||
/>
|
</TooltipKeybind>
|
||||||
<Icon
|
|
||||||
size="small"
|
|
||||||
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-partial"}
|
|
||||||
class="hidden group-active/terminal-toggle:inline-block"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</TooltipKeybind>
|
|
||||||
|
|
||||||
|
<div class="hidden md:flex items-center gap-1 shrink-0">
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
title={language.t("command.review.toggle")}
|
title={language.t("command.review.toggle")}
|
||||||
keybind={command.keybind("review.toggle")}
|
keybind={command.keybind("review.toggle")}
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { useSync } from "@/context/sync"
|
|||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { Mark } from "@opencode-ai/ui/logo"
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||||
|
|
||||||
const MAIN_WORKTREE = "main"
|
const MAIN_WORKTREE = "main"
|
||||||
const CREATE_WORKTREE = "create"
|
const CREATE_WORKTREE = "create"
|
||||||
const ROOT_CLASS =
|
const ROOT_CLASS = "size-full flex flex-col"
|
||||||
"size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-16"
|
|
||||||
|
|
||||||
interface NewSessionViewProps {
|
interface NewSessionViewProps {
|
||||||
worktree: string
|
worktree: string
|
||||||
@@ -50,33 +50,43 @@ export function NewSessionView(props: NewSessionViewProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={ROOT_CLASS}>
|
<div class={ROOT_CLASS}>
|
||||||
<div class="text-20-medium text-text-weaker">{language.t("command.session.new")}</div>
|
<div class="h-12 shrink-0" aria-hidden />
|
||||||
<div class="flex justify-center items-center gap-3">
|
<div class="flex-1 px-6 pb-30 flex items-center justify-center text-center">
|
||||||
<Icon name="folder" size="small" />
|
<div class="w-full max-w-200 flex flex-col items-center text-center gap-4">
|
||||||
<div class="text-12-medium text-text-weak select-text">
|
<div class="flex flex-col items-center gap-6">
|
||||||
{getDirectory(projectRoot())}
|
<Mark class="w-10" />
|
||||||
<span class="text-text-strong">{getFilename(projectRoot())}</span>
|
<div class="text-20-medium text-text-strong">{language.t("session.new.title")}</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full flex flex-col gap-4 items-center">
|
||||||
|
<div class="flex items-start justify-center gap-3 min-h-5">
|
||||||
|
<div class="text-12-medium text-text-weak select-text leading-5 min-w-0 max-w-160 break-words text-center">
|
||||||
|
{getDirectory(projectRoot())}
|
||||||
|
<span class="text-text-strong">{getFilename(projectRoot())}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start justify-center gap-1.5 min-h-5">
|
||||||
|
<Icon name="branch" size="small" class="mt-0.5 shrink-0" />
|
||||||
|
<div class="text-12-medium text-text-weak select-text leading-5 min-w-0 max-w-160 break-words text-center">
|
||||||
|
{label(current())}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={sync.project}>
|
||||||
|
{(project) => (
|
||||||
|
<div class="flex items-start justify-center gap-3 min-h-5">
|
||||||
|
<div class="text-12-medium text-text-weak leading-5 min-w-0 max-w-160 break-words text-center">
|
||||||
|
{language.t("session.new.lastModified")}
|
||||||
|
<span class="text-text-strong">
|
||||||
|
{DateTime.fromMillis(project().time.updated ?? project().time.created)
|
||||||
|
.setLocale(language.intl())
|
||||||
|
.toRelative()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-center items-center gap-1">
|
|
||||||
<Icon name="branch" size="small" />
|
|
||||||
<div class="text-12-medium text-text-weak select-text ml-2">{label(current())}</div>
|
|
||||||
</div>
|
|
||||||
<Show when={sync.project}>
|
|
||||||
{(project) => (
|
|
||||||
<div class="flex justify-center items-center gap-3">
|
|
||||||
<Icon name="pencil-line" size="small" />
|
|
||||||
<div class="text-12-medium text-text-weak">
|
|
||||||
{language.t("session.new.lastModified")}
|
|
||||||
<span class="text-text-strong">
|
|
||||||
{DateTime.fromMillis(project().time.updated ?? project().time.created)
|
|
||||||
.setLocale(language.locale())
|
|
||||||
.toRelative()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[num
|
|||||||
|
|
||||||
const PROVIDER_NOTES = [
|
const PROVIDER_NOTES = [
|
||||||
{ match: (id: string) => id === "opencode", key: "dialog.provider.opencode.note" },
|
{ match: (id: string) => id === "opencode", key: "dialog.provider.opencode.note" },
|
||||||
|
{ match: (id: string) => id === "opencode-go", key: "dialog.provider.opencodeGo.tagline" },
|
||||||
{ match: (id: string) => id === "anthropic", key: "dialog.provider.anthropic.note" },
|
{ match: (id: string) => id === "anthropic", key: "dialog.provider.anthropic.note" },
|
||||||
{ match: (id: string) => id.startsWith("github-copilot"), key: "dialog.provider.copilot.note" },
|
{ match: (id: string) => id.startsWith("github-copilot"), key: "dialog.provider.copilot.note" },
|
||||||
{ match: (id: string) => id === "openai", key: "dialog.provider.openai.note" },
|
{ match: (id: string) => id === "openai", key: "dialog.provider.openai.note" },
|
||||||
@@ -181,21 +182,11 @@ export const SettingsProviders: Component = () => {
|
|||||||
<div class="flex items-center gap-x-3">
|
<div class="flex items-center gap-x-3">
|
||||||
<ProviderIcon id={item.id} class="size-5 shrink-0 icon-strong-base" />
|
<ProviderIcon id={item.id} class="size-5 shrink-0 icon-strong-base" />
|
||||||
<span class="text-14-medium text-text-strong">{item.name}</span>
|
<span class="text-14-medium text-text-strong">{item.name}</span>
|
||||||
<Show when={item.id === "opencode"}>
|
|
||||||
<span class="text-14-regular text-text-weak">
|
|
||||||
{language.t("dialog.provider.opencode.tagline")}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={item.id === "opencode"}>
|
<Show when={item.id === "opencode"}>
|
||||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={item.id === "opencode-go"}>
|
<Show when={item.id === "opencode-go"}>
|
||||||
<>
|
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||||
<span class="text-14-regular text-text-weak">
|
|
||||||
{language.t("dialog.provider.opencodeGo.tagline")}
|
|
||||||
</span>
|
|
||||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
|
||||||
</>
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show when={note(item.id)}>
|
<Show when={note(item.id)}>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const TOGGLE_TERMINAL_ID = "terminal.toggle"
|
|||||||
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
|
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
|
||||||
export interface TerminalProps extends ComponentProps<"div"> {
|
export interface TerminalProps extends ComponentProps<"div"> {
|
||||||
pty: LocalPTY
|
pty: LocalPTY
|
||||||
|
autoFocus?: boolean
|
||||||
onSubmit?: () => void
|
onSubmit?: () => void
|
||||||
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
|
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
|
||||||
onConnect?: () => void
|
onConnect?: () => void
|
||||||
@@ -157,7 +158,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
let container!: HTMLDivElement
|
let container!: HTMLDivElement
|
||||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"])
|
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
|
||||||
const id = local.pty.id
|
const id = local.pty.id
|
||||||
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
|
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
|
||||||
const restoreSize =
|
const restoreSize =
|
||||||
@@ -217,7 +218,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
const currentTheme = theme.themes()[theme.themeId()]
|
const currentTheme = theme.themes()[theme.themeId()]
|
||||||
if (!currentTheme) return fallback
|
if (!currentTheme) return fallback
|
||||||
const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
|
const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
|
||||||
if (!variant?.seeds) return fallback
|
if (!variant?.seeds && !variant?.palette) return fallback
|
||||||
const resolved = resolveThemeVariant(variant, mode === "dark")
|
const resolved = resolveThemeVariant(variant, mode === "dark")
|
||||||
const text = resolved["text-stronger"] ?? fallback.foreground
|
const text = resolved["text-stronger"] ?? fallback.foreground
|
||||||
const background = resolved["background-stronger"] ?? fallback.background
|
const background = resolved["background-stronger"] ?? fallback.background
|
||||||
@@ -386,7 +387,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
handleLinkClick,
|
handleLinkClick,
|
||||||
})
|
})
|
||||||
|
|
||||||
focusTerminal()
|
if (local.autoFocus !== false) focusTerminal()
|
||||||
|
|
||||||
if (typeof document !== "undefined" && document.fonts) {
|
if (typeof document !== "undefined" && document.fonts) {
|
||||||
document.fonts.ready.then(scheduleFit)
|
document.fonts.ready.then(scheduleFit)
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export function Titlebar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
|
class="h-10 shrink-0 bg-background-base relative grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center"
|
||||||
style={{ "min-height": minHeight() }}
|
style={{ "min-height": minHeight() }}
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
onMouseDown={drag}
|
onMouseDown={drag}
|
||||||
@@ -269,7 +269,7 @@ export function Titlebar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="min-w-0 flex items-center justify-center pointer-events-none">
|
<div class="min-w-0 flex items-center justify-center pointer-events-none">
|
||||||
<div id="opencode-titlebar-center" class="pointer-events-auto w-full min-w-0 flex justify-center lg:w-fit" />
|
<div id="opencode-titlebar-center" class="pointer-events-auto min-w-0 flex justify-center w-fit max-w-full" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import type { InitError } from "../pages/error"
|
|||||||
import { useGlobalSDK } from "./global-sdk"
|
import { useGlobalSDK } from "./global-sdk"
|
||||||
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
|
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
|
||||||
import { createChildStoreManager } from "./global-sync/child-store"
|
import { createChildStoreManager } from "./global-sync/child-store"
|
||||||
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
|
||||||
import { createRefreshQueue } from "./global-sync/queue"
|
import { createRefreshQueue } from "./global-sync/queue"
|
||||||
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||||
import { trimSessions } from "./global-sync/session-trim"
|
import { trimSessions } from "./global-sync/session-trim"
|
||||||
@@ -189,6 +189,7 @@ function createGlobalSync() {
|
|||||||
})
|
})
|
||||||
if (next.length !== store.session.length) {
|
if (next.length !== store.session.length) {
|
||||||
setStore("session", reconcile(next, { key: "id" }))
|
setStore("session", reconcile(next, { key: "id" }))
|
||||||
|
cleanupDroppedSessionCaches(store, setStore, next, setSessionTodo)
|
||||||
}
|
}
|
||||||
children.unpin(directory)
|
children.unpin(directory)
|
||||||
return
|
return
|
||||||
@@ -220,6 +221,7 @@ function createGlobalSync() {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
setStore("session", reconcile(sessions, { key: "id" }))
|
setStore("session", reconcile(sessions, { key: "id" }))
|
||||||
|
cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
|
||||||
sessionMeta.set(directory, { limit })
|
sessionMeta.set(directory, { limit })
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -228,10 +230,7 @@ function createGlobalSync() {
|
|||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
title: language.t("toast.session.listFailed.title", { project }),
|
title: language.t("toast.session.listFailed.title", { project }),
|
||||||
description: formatServerError(err, {
|
description: formatServerError(err, language.t),
|
||||||
unknown: language.t("error.chain.unknown"),
|
|
||||||
invalidConfiguration: language.t("error.server.invalidConfiguration"),
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -261,8 +260,7 @@ function createGlobalSync() {
|
|||||||
setStore: child[1],
|
setStore: child[1],
|
||||||
vcsCache: cache,
|
vcsCache: cache,
|
||||||
loadSessions,
|
loadSessions,
|
||||||
unknownError: language.t("error.chain.unknown"),
|
translate: language.t,
|
||||||
invalidConfigurationError: language.t("error.server.invalidConfiguration"),
|
|
||||||
})
|
})
|
||||||
})()
|
})()
|
||||||
|
|
||||||
@@ -331,8 +329,7 @@ function createGlobalSync() {
|
|||||||
url: globalSDK.url,
|
url: globalSDK.url,
|
||||||
}),
|
}),
|
||||||
requestFailedTitle: language.t("common.requestFailed"),
|
requestFailedTitle: language.t("common.requestFailed"),
|
||||||
unknownError: language.t("error.chain.unknown"),
|
translate: language.t,
|
||||||
invalidConfigurationError: language.t("error.server.invalidConfiguration"),
|
|
||||||
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
||||||
setGlobalStore: setBootStore,
|
setGlobalStore: setBootStore,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -36,8 +36,7 @@ export async function bootstrapGlobal(input: {
|
|||||||
connectErrorTitle: string
|
connectErrorTitle: string
|
||||||
connectErrorDescription: string
|
connectErrorDescription: string
|
||||||
requestFailedTitle: string
|
requestFailedTitle: string
|
||||||
unknownError: string
|
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||||
invalidConfigurationError: string
|
|
||||||
formatMoreCount: (count: number) => string
|
formatMoreCount: (count: number) => string
|
||||||
setGlobalStore: SetStoreFunction<GlobalStore>
|
setGlobalStore: SetStoreFunction<GlobalStore>
|
||||||
}) {
|
}) {
|
||||||
@@ -91,10 +90,7 @@ export async function bootstrapGlobal(input: {
|
|||||||
const results = await Promise.allSettled(tasks)
|
const results = await Promise.allSettled(tasks)
|
||||||
const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
|
const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
|
||||||
if (errors.length) {
|
if (errors.length) {
|
||||||
const message = formatServerError(errors[0], {
|
const message = formatServerError(errors[0], input.translate)
|
||||||
unknown: input.unknownError,
|
|
||||||
invalidConfiguration: input.invalidConfigurationError,
|
|
||||||
})
|
|
||||||
const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : ""
|
const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : ""
|
||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
@@ -122,8 +118,7 @@ export async function bootstrapDirectory(input: {
|
|||||||
setStore: SetStoreFunction<State>
|
setStore: SetStoreFunction<State>
|
||||||
vcsCache: VcsCache
|
vcsCache: VcsCache
|
||||||
loadSessions: (directory: string) => Promise<void> | void
|
loadSessions: (directory: string) => Promise<void> | void
|
||||||
unknownError: string
|
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||||
invalidConfigurationError: string
|
|
||||||
}) {
|
}) {
|
||||||
if (input.store.status !== "complete") input.setStore("status", "loading")
|
if (input.store.status !== "complete") input.setStore("status", "loading")
|
||||||
|
|
||||||
@@ -145,10 +140,7 @@ export async function bootstrapDirectory(input: {
|
|||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
title: `Failed to reload ${project}`,
|
title: `Failed to reload ${project}`,
|
||||||
description: formatServerError(err, {
|
description: formatServerError(err, input.translate),
|
||||||
unknown: input.unknownError,
|
|
||||||
invalidConfiguration: input.invalidConfigurationError,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
input.setStore("status", "partial")
|
input.setStore("status", "partial")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
|||||||
import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import type { State } from "./types"
|
import type { State } from "./types"
|
||||||
import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer"
|
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
|
||||||
|
|
||||||
const rootSession = (input: { id: string; parentID?: string; archived?: number }) =>
|
const rootSession = (input: { id: string; parentID?: string; archived?: number }) =>
|
||||||
({
|
({
|
||||||
@@ -248,6 +248,62 @@ describe("applyDirectoryEvent", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("cleans caches for trimmed sessions on session.created", () => {
|
||||||
|
const dropped = rootSession({ id: "ses_b" })
|
||||||
|
const kept = rootSession({ id: "ses_a" })
|
||||||
|
const message = userMessage("msg_1", dropped.id)
|
||||||
|
const todos: string[] = []
|
||||||
|
const [store, setStore] = createStore(
|
||||||
|
baseState({
|
||||||
|
limit: 1,
|
||||||
|
session: [dropped],
|
||||||
|
message: { [dropped.id]: [message] },
|
||||||
|
part: { [message.id]: [textPart("prt_1", dropped.id, message.id)] },
|
||||||
|
session_diff: { [dropped.id]: [] },
|
||||||
|
todo: { [dropped.id]: [] },
|
||||||
|
permission: { [dropped.id]: [] },
|
||||||
|
question: { [dropped.id]: [] },
|
||||||
|
session_status: { [dropped.id]: { type: "busy" } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
applyDirectoryEvent({
|
||||||
|
event: { type: "session.created", properties: { info: kept } },
|
||||||
|
store,
|
||||||
|
setStore,
|
||||||
|
push() {},
|
||||||
|
directory: "/tmp",
|
||||||
|
loadLsp() {},
|
||||||
|
setSessionTodo(sessionID, value) {
|
||||||
|
if (value !== undefined) return
|
||||||
|
todos.push(sessionID)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(store.session.map((x) => x.id)).toEqual([kept.id])
|
||||||
|
expect(store.message[dropped.id]).toBeUndefined()
|
||||||
|
expect(store.part[message.id]).toBeUndefined()
|
||||||
|
expect(store.session_diff[dropped.id]).toBeUndefined()
|
||||||
|
expect(store.todo[dropped.id]).toBeUndefined()
|
||||||
|
expect(store.permission[dropped.id]).toBeUndefined()
|
||||||
|
expect(store.question[dropped.id]).toBeUndefined()
|
||||||
|
expect(store.session_status[dropped.id]).toBeUndefined()
|
||||||
|
expect(todos).toEqual([dropped.id])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("cleanupDroppedSessionCaches clears part-only orphan state", () => {
|
||||||
|
const [store, setStore] = createStore(
|
||||||
|
baseState({
|
||||||
|
session: [rootSession({ id: "ses_keep" })],
|
||||||
|
part: { msg_1: [textPart("prt_1", "ses_drop", "msg_1")] },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
cleanupDroppedSessionCaches(store, setStore, store.session)
|
||||||
|
|
||||||
|
expect(store.part.msg_1).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
test("upserts and removes messages while clearing orphaned parts", () => {
|
test("upserts and removes messages while clearing orphaned parts", () => {
|
||||||
const sessionID = "ses_1"
|
const sessionID = "ses_1"
|
||||||
const [store, setStore] = createStore(
|
const [store, setStore] = createStore(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
import type { State, VcsCache } from "./types"
|
import type { State, VcsCache } from "./types"
|
||||||
import { trimSessions } from "./session-trim"
|
import { trimSessions } from "./session-trim"
|
||||||
|
import { dropSessionCaches } from "./session-cache"
|
||||||
|
|
||||||
export function applyGlobalEvent(input: {
|
export function applyGlobalEvent(input: {
|
||||||
event: { type: string; properties?: unknown }
|
event: { type: string; properties?: unknown }
|
||||||
@@ -40,37 +41,44 @@ export function applyGlobalEvent(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cleanupSessionCaches(
|
function cleanupSessionCaches(
|
||||||
store: Store<State>,
|
|
||||||
setStore: SetStoreFunction<State>,
|
setStore: SetStoreFunction<State>,
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
|
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
|
||||||
) {
|
) {
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
const hasAny =
|
|
||||||
store.message[sessionID] !== undefined ||
|
|
||||||
store.session_diff[sessionID] !== undefined ||
|
|
||||||
store.todo[sessionID] !== undefined ||
|
|
||||||
store.permission[sessionID] !== undefined ||
|
|
||||||
store.question[sessionID] !== undefined ||
|
|
||||||
store.session_status[sessionID] !== undefined
|
|
||||||
setSessionTodo?.(sessionID, undefined)
|
setSessionTodo?.(sessionID, undefined)
|
||||||
if (!hasAny) return
|
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
const messages = draft.message[sessionID]
|
dropSessionCaches(draft, [sessionID])
|
||||||
if (messages) {
|
}),
|
||||||
for (const message of messages) {
|
)
|
||||||
const id = message?.id
|
}
|
||||||
if (!id) continue
|
|
||||||
delete draft.part[id]
|
export function cleanupDroppedSessionCaches(
|
||||||
}
|
store: Store<State>,
|
||||||
}
|
setStore: SetStoreFunction<State>,
|
||||||
delete draft.message[sessionID]
|
next: Session[],
|
||||||
delete draft.session_diff[sessionID]
|
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
|
||||||
delete draft.todo[sessionID]
|
) {
|
||||||
delete draft.permission[sessionID]
|
const keep = new Set(next.map((item) => item.id))
|
||||||
delete draft.question[sessionID]
|
const stale = [
|
||||||
delete draft.session_status[sessionID]
|
...Object.keys(store.message),
|
||||||
|
...Object.keys(store.session_diff),
|
||||||
|
...Object.keys(store.todo),
|
||||||
|
...Object.keys(store.permission),
|
||||||
|
...Object.keys(store.question),
|
||||||
|
...Object.keys(store.session_status),
|
||||||
|
...Object.values(store.part)
|
||||||
|
.map((parts) => parts?.find((part) => !!part?.sessionID)?.sessionID)
|
||||||
|
.filter((sessionID): sessionID is string => !!sessionID),
|
||||||
|
].filter((sessionID, index, list) => !keep.has(sessionID) && list.indexOf(sessionID) === index)
|
||||||
|
if (stale.length === 0) return
|
||||||
|
for (const sessionID of stale) {
|
||||||
|
setSessionTodo?.(sessionID, undefined)
|
||||||
|
}
|
||||||
|
setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
dropSessionCaches(draft, stale)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -102,6 +110,7 @@ export function applyDirectoryEvent(input: {
|
|||||||
next.splice(result.index, 0, info)
|
next.splice(result.index, 0, info)
|
||||||
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
|
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
|
||||||
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
||||||
|
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
|
||||||
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
|
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -117,7 +126,7 @@ export function applyDirectoryEvent(input: {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
|
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
|
||||||
if (info.parentID) break
|
if (info.parentID) break
|
||||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||||
break
|
break
|
||||||
@@ -130,6 +139,7 @@ export function applyDirectoryEvent(input: {
|
|||||||
next.splice(result.index, 0, info)
|
next.splice(result.index, 0, info)
|
||||||
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
|
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
|
||||||
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
||||||
|
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "session.deleted": {
|
case "session.deleted": {
|
||||||
@@ -143,7 +153,7 @@ export function applyDirectoryEvent(input: {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
|
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
|
||||||
if (info.parentID) break
|
if (info.parentID) break
|
||||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type {
|
||||||
|
FileDiff,
|
||||||
|
Message,
|
||||||
|
Part,
|
||||||
|
PermissionRequest,
|
||||||
|
QuestionRequest,
|
||||||
|
SessionStatus,
|
||||||
|
Todo,
|
||||||
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
||||||
|
|
||||||
|
const msg = (id: string, sessionID: string) =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
sessionID,
|
||||||
|
role: "user",
|
||||||
|
time: { created: 1 },
|
||||||
|
agent: "assistant",
|
||||||
|
model: { providerID: "openai", modelID: "gpt" },
|
||||||
|
}) as Message
|
||||||
|
|
||||||
|
const part = (id: string, sessionID: string, messageID: string) =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
sessionID,
|
||||||
|
messageID,
|
||||||
|
type: "text",
|
||||||
|
text: id,
|
||||||
|
}) as Part
|
||||||
|
|
||||||
|
describe("app session cache", () => {
|
||||||
|
test("dropSessionCaches clears orphaned parts without message rows", () => {
|
||||||
|
const store: {
|
||||||
|
session_status: Record<string, SessionStatus | undefined>
|
||||||
|
session_diff: Record<string, FileDiff[] | undefined>
|
||||||
|
todo: Record<string, Todo[] | undefined>
|
||||||
|
message: Record<string, Message[] | undefined>
|
||||||
|
part: Record<string, Part[] | undefined>
|
||||||
|
permission: Record<string, PermissionRequest[] | undefined>
|
||||||
|
question: Record<string, QuestionRequest[] | undefined>
|
||||||
|
} = {
|
||||||
|
session_status: { ses_1: { type: "busy" } as SessionStatus },
|
||||||
|
session_diff: { ses_1: [] },
|
||||||
|
todo: { ses_1: [] as Todo[] },
|
||||||
|
message: {},
|
||||||
|
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
|
||||||
|
permission: { ses_1: [] as PermissionRequest[] },
|
||||||
|
question: { ses_1: [] as QuestionRequest[] },
|
||||||
|
}
|
||||||
|
|
||||||
|
dropSessionCaches(store, ["ses_1"])
|
||||||
|
|
||||||
|
expect(store.message.ses_1).toBeUndefined()
|
||||||
|
expect(store.part.msg_1).toBeUndefined()
|
||||||
|
expect(store.todo.ses_1).toBeUndefined()
|
||||||
|
expect(store.session_diff.ses_1).toBeUndefined()
|
||||||
|
expect(store.session_status.ses_1).toBeUndefined()
|
||||||
|
expect(store.permission.ses_1).toBeUndefined()
|
||||||
|
expect(store.question.ses_1).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("dropSessionCaches clears message-backed parts", () => {
|
||||||
|
const m = msg("msg_1", "ses_1")
|
||||||
|
const store: {
|
||||||
|
session_status: Record<string, SessionStatus | undefined>
|
||||||
|
session_diff: Record<string, FileDiff[] | undefined>
|
||||||
|
todo: Record<string, Todo[] | undefined>
|
||||||
|
message: Record<string, Message[] | undefined>
|
||||||
|
part: Record<string, Part[] | undefined>
|
||||||
|
permission: Record<string, PermissionRequest[] | undefined>
|
||||||
|
question: Record<string, QuestionRequest[] | undefined>
|
||||||
|
} = {
|
||||||
|
session_status: {},
|
||||||
|
session_diff: {},
|
||||||
|
todo: {},
|
||||||
|
message: { ses_1: [m] },
|
||||||
|
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
|
||||||
|
permission: {},
|
||||||
|
question: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
dropSessionCaches(store, ["ses_1"])
|
||||||
|
|
||||||
|
expect(store.message.ses_1).toBeUndefined()
|
||||||
|
expect(store.part[m.id]).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("pickSessionCacheEvictions preserves requested sessions", () => {
|
||||||
|
const seen = new Set(["ses_1", "ses_2", "ses_3"])
|
||||||
|
|
||||||
|
const stale = pickSessionCacheEvictions({
|
||||||
|
seen,
|
||||||
|
keep: "ses_4",
|
||||||
|
limit: 2,
|
||||||
|
preserve: ["ses_1"],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(stale).toEqual(["ses_2", "ses_3"])
|
||||||
|
expect([...seen]).toEqual(["ses_1", "ses_4"])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type {
|
||||||
|
FileDiff,
|
||||||
|
Message,
|
||||||
|
Part,
|
||||||
|
PermissionRequest,
|
||||||
|
QuestionRequest,
|
||||||
|
SessionStatus,
|
||||||
|
Todo,
|
||||||
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
|
export const SESSION_CACHE_LIMIT = 40
|
||||||
|
|
||||||
|
type SessionCache = {
|
||||||
|
session_status: Record<string, SessionStatus | undefined>
|
||||||
|
session_diff: Record<string, FileDiff[] | undefined>
|
||||||
|
todo: Record<string, Todo[] | undefined>
|
||||||
|
message: Record<string, Message[] | undefined>
|
||||||
|
part: Record<string, Part[] | undefined>
|
||||||
|
permission: Record<string, PermissionRequest[] | undefined>
|
||||||
|
question: Record<string, QuestionRequest[] | undefined>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<string>) {
|
||||||
|
const stale = new Set(Array.from(sessionIDs).filter(Boolean))
|
||||||
|
if (stale.size === 0) return
|
||||||
|
|
||||||
|
for (const key of Object.keys(store.part)) {
|
||||||
|
const parts = store.part[key]
|
||||||
|
if (!parts?.some((part) => stale.has(part?.sessionID ?? ""))) continue
|
||||||
|
delete store.part[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sessionID of stale) {
|
||||||
|
delete store.message[sessionID]
|
||||||
|
delete store.todo[sessionID]
|
||||||
|
delete store.session_diff[sessionID]
|
||||||
|
delete store.session_status[sessionID]
|
||||||
|
delete store.permission[sessionID]
|
||||||
|
delete store.question[sessionID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickSessionCacheEvictions(input: {
|
||||||
|
seen: Set<string>
|
||||||
|
keep: string
|
||||||
|
limit: number
|
||||||
|
preserve?: Iterable<string>
|
||||||
|
}) {
|
||||||
|
const stale: string[] = []
|
||||||
|
const keep = new Set([input.keep, ...Array.from(input.preserve ?? [])])
|
||||||
|
if (input.seen.has(input.keep)) input.seen.delete(input.keep)
|
||||||
|
input.seen.add(input.keep)
|
||||||
|
for (const id of input.seen) {
|
||||||
|
if (input.seen.size - stale.length <= input.limit) break
|
||||||
|
if (keep.has(id)) continue
|
||||||
|
stale.push(id)
|
||||||
|
}
|
||||||
|
for (const id of stale) {
|
||||||
|
input.seen.delete(id)
|
||||||
|
}
|
||||||
|
return stale
|
||||||
|
}
|
||||||
@@ -84,6 +84,26 @@ const LOCALES: readonly Locale[] = [
|
|||||||
"tr",
|
"tr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const INTL: Record<Locale, string> = {
|
||||||
|
en: "en",
|
||||||
|
zh: "zh-Hans",
|
||||||
|
zht: "zh-Hant",
|
||||||
|
ko: "ko",
|
||||||
|
de: "de",
|
||||||
|
es: "es",
|
||||||
|
fr: "fr",
|
||||||
|
da: "da",
|
||||||
|
ja: "ja",
|
||||||
|
pl: "pl",
|
||||||
|
ru: "ru",
|
||||||
|
ar: "ar",
|
||||||
|
no: "nb-NO",
|
||||||
|
br: "pt-BR",
|
||||||
|
th: "th",
|
||||||
|
bs: "bs",
|
||||||
|
tr: "tr",
|
||||||
|
}
|
||||||
|
|
||||||
const LABEL_KEY: Record<Locale, keyof Dictionary> = {
|
const LABEL_KEY: Record<Locale, keyof Dictionary> = {
|
||||||
en: "language.en",
|
en: "language.en",
|
||||||
zh: "language.zh",
|
zh: "language.zh",
|
||||||
@@ -126,6 +146,7 @@ const DICT: Record<Locale, Dictionary> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
|
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
|
||||||
|
{ locale: "en", match: (language) => language.startsWith("en") },
|
||||||
{ locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") },
|
{ locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") },
|
||||||
{ locale: "zh", match: (language) => language.startsWith("zh") },
|
{ locale: "zh", match: (language) => language.startsWith("zh") },
|
||||||
{ locale: "ko", match: (language) => language.startsWith("ko") },
|
{ locale: "ko", match: (language) => language.startsWith("ko") },
|
||||||
@@ -197,6 +218,8 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
|||||||
)
|
)
|
||||||
|
|
||||||
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
|
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
|
||||||
|
console.log("locale", locale())
|
||||||
|
const intl = createMemo(() => INTL[locale()])
|
||||||
|
|
||||||
const dict = createMemo<Dictionary>(() => DICT[locale()])
|
const dict = createMemo<Dictionary>(() => DICT[locale()])
|
||||||
|
|
||||||
@@ -213,6 +236,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
|||||||
return {
|
return {
|
||||||
ready,
|
ready,
|
||||||
locale,
|
locale,
|
||||||
|
intl,
|
||||||
locales: LOCALES,
|
locales: LOCALES,
|
||||||
label,
|
label,
|
||||||
t,
|
t,
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
|
|
||||||
const agent = (() => {
|
const agent = (() => {
|
||||||
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
|
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
|
||||||
|
const models = useModels()
|
||||||
|
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
current?: string
|
current?: string
|
||||||
}>({
|
}>({
|
||||||
@@ -53,11 +55,17 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
setStore("current", undefined)
|
setStore("current", undefined)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (name && available.some((x) => x.name === name)) {
|
const match = name ? available.find((x) => x.name === name) : undefined
|
||||||
setStore("current", name)
|
const value = match ?? available[0]
|
||||||
return
|
if (!value) return
|
||||||
}
|
setStore("current", value.name)
|
||||||
setStore("current", available[0].name)
|
if (!value.model) return
|
||||||
|
setModel({
|
||||||
|
providerID: value.model.providerID,
|
||||||
|
modelID: value.model.modelID,
|
||||||
|
})
|
||||||
|
if (value.variant)
|
||||||
|
models.variant.set({ providerID: value.model.providerID, modelID: value.model.modelID }, value.variant)
|
||||||
},
|
},
|
||||||
move(direction: 1 | -1) {
|
move(direction: 1 | -1) {
|
||||||
const available = list()
|
const available = list()
|
||||||
@@ -71,11 +79,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const value = available[next]
|
const value = available[next]
|
||||||
if (!value) return
|
if (!value) return
|
||||||
setStore("current", value.name)
|
setStore("current", value.name)
|
||||||
if (value.model)
|
if (!value.model) return
|
||||||
setModel({
|
setModel({
|
||||||
providerID: value.model.providerID,
|
providerID: value.model.providerID,
|
||||||
modelID: value.model.modelID,
|
modelID: value.model.modelID,
|
||||||
})
|
})
|
||||||
|
if (value.variant)
|
||||||
|
models.variant.set({ providerID: value.model.providerID, modelID: value.model.modelID }, value.variant)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import { base64Encode } from "@opencode-ai/util/encode"
|
import { base64Encode } from "@opencode-ai/util/encode"
|
||||||
import { autoRespondsPermission } from "./permission-auto-respond"
|
import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond"
|
||||||
|
|
||||||
const session = (input: { id: string; parentID?: string }) =>
|
const session = (input: { id: string; parentID?: string }) =>
|
||||||
({
|
({
|
||||||
@@ -60,4 +60,43 @@ describe("autoRespondsPermission", () => {
|
|||||||
|
|
||||||
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
|
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("falls back to directory-level auto-accept", () => {
|
||||||
|
const directory = "/tmp/project"
|
||||||
|
const sessions = [session({ id: "root" })]
|
||||||
|
const autoAccept = {
|
||||||
|
[`${base64Encode(directory)}/*`]: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("session-level override takes precedence over directory-level", () => {
|
||||||
|
const directory = "/tmp/project"
|
||||||
|
const sessions = [session({ id: "root" })]
|
||||||
|
const autoAccept = {
|
||||||
|
[`${base64Encode(directory)}/*`]: true,
|
||||||
|
[`${base64Encode(directory)}/root`]: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("isDirectoryAutoAccepting", () => {
|
||||||
|
test("returns true when directory key is set", () => {
|
||||||
|
const directory = "/tmp/project"
|
||||||
|
const autoAccept = { [`${base64Encode(directory)}/*`]: true }
|
||||||
|
expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns false when directory key is not set", () => {
|
||||||
|
expect(isDirectoryAutoAccepting({}, "/tmp/project")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns false when directory key is explicitly false", () => {
|
||||||
|
const directory = "/tmp/project"
|
||||||
|
const autoAccept = { [`${base64Encode(directory)}/*`]: false }
|
||||||
|
expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,9 +5,19 @@ export function acceptKey(sessionID: string, directory?: string) {
|
|||||||
return `${base64Encode(directory)}/${sessionID}`
|
return `${base64Encode(directory)}/${sessionID}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function directoryAcceptKey(directory: string) {
|
||||||
|
return `${base64Encode(directory)}/*`
|
||||||
|
}
|
||||||
|
|
||||||
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
|
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
|
||||||
const key = acceptKey(sessionID, directory)
|
const key = acceptKey(sessionID, directory)
|
||||||
return autoAccept[key] ?? autoAccept[sessionID]
|
const directoryKey = directory ? directoryAcceptKey(directory) : undefined
|
||||||
|
return autoAccept[key] ?? autoAccept[sessionID] ?? (directoryKey ? autoAccept[directoryKey] : undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
|
||||||
|
const key = directoryAcceptKey(directory)
|
||||||
|
return autoAccept[key] ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
function sessionLineage(session: { id: string; parentID?: string }[], sessionID: string) {
|
function sessionLineage(session: { id: string; parentID?: string }[], sessionID: string) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createMemo, onCleanup } from "solid-js"
|
import { createEffect, createMemo, onCleanup } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
||||||
@@ -7,7 +7,12 @@ import { useGlobalSDK } from "@/context/global-sdk"
|
|||||||
import { useGlobalSync } from "./global-sync"
|
import { useGlobalSync } from "./global-sync"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { acceptKey, autoRespondsPermission } from "./permission-auto-respond"
|
import {
|
||||||
|
acceptKey,
|
||||||
|
directoryAcceptKey,
|
||||||
|
isDirectoryAutoAccepting,
|
||||||
|
autoRespondsPermission,
|
||||||
|
} from "./permission-auto-respond"
|
||||||
|
|
||||||
type PermissionRespondFn = (input: {
|
type PermissionRespondFn = (input: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
@@ -76,6 +81,25 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// When config has permission: "allow", auto-enable directory-level auto-accept
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready()) return
|
||||||
|
const directory = decode64(params.dir)
|
||||||
|
if (!directory) return
|
||||||
|
const [childStore] = globalSync.child(directory)
|
||||||
|
const perm = childStore.config.permission
|
||||||
|
if (typeof perm === "string" && perm === "allow") {
|
||||||
|
const key = directoryAcceptKey(directory)
|
||||||
|
if (store.autoAccept[key] === undefined) {
|
||||||
|
setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
draft.autoAccept[key] = true
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const MAX_RESPONDED = 1000
|
const MAX_RESPONDED = 1000
|
||||||
const RESPONDED_TTL_MS = 60 * 60 * 1000
|
const RESPONDED_TTL_MS = 60 * 60 * 1000
|
||||||
const responded = new Map<string, number>()
|
const responded = new Map<string, number>()
|
||||||
@@ -119,6 +143,10 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
|
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAutoAcceptingDirectory(directory: string) {
|
||||||
|
return isDirectoryAutoAccepting(store.autoAccept, directory)
|
||||||
|
}
|
||||||
|
|
||||||
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
|
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
|
||||||
const session = directory ? globalSync.child(directory, { bootstrap: false })[0].session : []
|
const session = directory ? globalSync.child(directory, { bootstrap: false })[0].session : []
|
||||||
return autoRespondsPermission(store.autoAccept, session, permission, directory)
|
return autoRespondsPermission(store.autoAccept, session, permission, directory)
|
||||||
@@ -142,6 +170,36 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
})
|
})
|
||||||
onCleanup(unsubscribe)
|
onCleanup(unsubscribe)
|
||||||
|
|
||||||
|
function enableDirectory(directory: string) {
|
||||||
|
const key = directoryAcceptKey(directory)
|
||||||
|
setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
draft.autoAccept[key] = true
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
globalSDK.client.permission
|
||||||
|
.list({ directory })
|
||||||
|
.then((x) => {
|
||||||
|
if (!isAutoAcceptingDirectory(directory)) return
|
||||||
|
for (const perm of x.data ?? []) {
|
||||||
|
if (!perm?.id) continue
|
||||||
|
if (!shouldAutoRespond(perm, directory)) continue
|
||||||
|
respondOnce(perm, directory)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableDirectory(directory: string) {
|
||||||
|
const key = directoryAcceptKey(directory)
|
||||||
|
setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
draft.autoAccept[key] = false
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function enable(sessionID: string, directory: string) {
|
function enable(sessionID: string, directory: string) {
|
||||||
const key = acceptKey(sessionID, directory)
|
const key = acceptKey(sessionID, directory)
|
||||||
const version = bumpEnableVersion(sessionID, directory)
|
const version = bumpEnableVersion(sessionID, directory)
|
||||||
@@ -185,6 +243,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
return shouldAutoRespond(permission, directory)
|
return shouldAutoRespond(permission, directory)
|
||||||
},
|
},
|
||||||
isAutoAccepting,
|
isAutoAccepting,
|
||||||
|
isAutoAcceptingDirectory,
|
||||||
toggleAutoAccept(sessionID: string, directory: string) {
|
toggleAutoAccept(sessionID: string, directory: string) {
|
||||||
if (isAutoAccepting(sessionID, directory)) {
|
if (isAutoAccepting(sessionID, directory)) {
|
||||||
disable(sessionID, directory)
|
disable(sessionID, directory)
|
||||||
@@ -193,6 +252,13 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
|
|
||||||
enable(sessionID, directory)
|
enable(sessionID, directory)
|
||||||
},
|
},
|
||||||
|
toggleAutoAcceptDirectory(directory: string) {
|
||||||
|
if (isAutoAcceptingDirectory(directory)) {
|
||||||
|
disableDirectory(directory)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enableDirectory(directory)
|
||||||
|
},
|
||||||
enableAutoAccept(sessionID: string, directory: string) {
|
enableAutoAccept(sessionID: string, directory: string) {
|
||||||
if (isAutoAccepting(sessionID, directory)) return
|
if (isAutoAccepting(sessionID, directory)) return
|
||||||
enable(sessionID, directory)
|
enable(sessionID, directory)
|
||||||
@@ -201,6 +267,11 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
disable(sessionID, directory)
|
disable(sessionID, directory)
|
||||||
},
|
},
|
||||||
permissionsEnabled,
|
permissionsEnabled,
|
||||||
|
isPermissionAllowAll(directory: string) {
|
||||||
|
const [childStore] = globalSync.child(directory)
|
||||||
|
const perm = childStore.config.permission
|
||||||
|
return typeof perm === "string" && perm === "allow"
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
|||||||
import { useGlobalSync } from "./global-sync"
|
import { useGlobalSync } from "./global-sync"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
|
||||||
|
|
||||||
function sortParts(parts: Part[]) {
|
function sortParts(parts: Part[]) {
|
||||||
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
||||||
@@ -108,6 +109,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const inflight = new Map<string, Promise<void>>()
|
const inflight = new Map<string, Promise<void>>()
|
||||||
const inflightDiff = new Map<string, Promise<void>>()
|
const inflightDiff = new Map<string, Promise<void>>()
|
||||||
const inflightTodo = new Map<string, Promise<void>>()
|
const inflightTodo = new Map<string, Promise<void>>()
|
||||||
|
const maxDirs = 30
|
||||||
|
const seen = new Map<string, Set<string>>()
|
||||||
const [meta, setMeta] = createStore({
|
const [meta, setMeta] = createStore({
|
||||||
limit: {} as Record<string, number>,
|
limit: {} as Record<string, number>,
|
||||||
complete: {} as Record<string, boolean>,
|
complete: {} as Record<string, boolean>,
|
||||||
@@ -121,6 +124,62 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const seenFor = (directory: string) => {
|
||||||
|
const existing = seen.get(directory)
|
||||||
|
if (existing) {
|
||||||
|
seen.delete(directory)
|
||||||
|
seen.set(directory, existing)
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
const created = new Set<string>()
|
||||||
|
seen.set(directory, created)
|
||||||
|
while (seen.size > maxDirs) {
|
||||||
|
const first = seen.keys().next().value
|
||||||
|
if (!first) break
|
||||||
|
const stale = [...(seen.get(first) ?? [])]
|
||||||
|
seen.delete(first)
|
||||||
|
const [, setStore] = globalSync.child(first, { bootstrap: false })
|
||||||
|
evict(first, setStore, stale)
|
||||||
|
}
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearMeta = (directory: string, sessionIDs: string[]) => {
|
||||||
|
if (sessionIDs.length === 0) return
|
||||||
|
setMeta(
|
||||||
|
produce((draft) => {
|
||||||
|
for (const sessionID of sessionIDs) {
|
||||||
|
const key = keyFor(directory, sessionID)
|
||||||
|
delete draft.limit[key]
|
||||||
|
delete draft.complete[key]
|
||||||
|
delete draft.loading[key]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
|
||||||
|
if (sessionIDs.length === 0) return
|
||||||
|
for (const sessionID of sessionIDs) {
|
||||||
|
globalSync.todo.set(sessionID, undefined)
|
||||||
|
}
|
||||||
|
setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
dropSessionCaches(draft, sessionIDs)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
clearMeta(directory, sessionIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
const touch = (directory: string, setStore: Setter, sessionID: string) => {
|
||||||
|
const stale = pickSessionCacheEvictions({
|
||||||
|
seen: seenFor(directory),
|
||||||
|
keep: sessionID,
|
||||||
|
limit: SESSION_CACHE_LIMIT,
|
||||||
|
})
|
||||||
|
evict(directory, setStore, stale)
|
||||||
|
}
|
||||||
|
|
||||||
const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => {
|
const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => {
|
||||||
const messages = await retry(() =>
|
const messages = await retry(() =>
|
||||||
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
|
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
|
||||||
@@ -135,6 +194,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false
|
||||||
|
|
||||||
const loadMessages = async (input: {
|
const loadMessages = async (input: {
|
||||||
directory: string
|
directory: string
|
||||||
client: typeof sdk.client
|
client: typeof sdk.client
|
||||||
@@ -148,6 +209,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
setMeta("loading", key, true)
|
setMeta("loading", key, true)
|
||||||
await fetchMessages(input)
|
await fetchMessages(input)
|
||||||
.then((next) => {
|
.then((next) => {
|
||||||
|
if (!tracked(input.directory, input.sessionID)) return
|
||||||
batch(() => {
|
batch(() => {
|
||||||
input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
|
input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
|
||||||
for (const p of next.part) {
|
for (const p of next.part) {
|
||||||
@@ -158,6 +220,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
if (!tracked(input.directory, input.sessionID)) return
|
||||||
setMeta("loading", key, false)
|
setMeta("loading", key, false)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -199,6 +262,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
parts: Part[]
|
parts: Part[]
|
||||||
agent: string
|
agent: string
|
||||||
model: { providerID: string; modelID: string }
|
model: { providerID: string; modelID: string }
|
||||||
|
variant?: string
|
||||||
}) {
|
}) {
|
||||||
const message: Message = {
|
const message: Message = {
|
||||||
id: input.messageID,
|
id: input.messageID,
|
||||||
@@ -207,6 +271,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
time: { created: Date.now() },
|
time: { created: Date.now() },
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
model: input.model,
|
model: input.model,
|
||||||
|
variant: input.variant,
|
||||||
}
|
}
|
||||||
const [, setStore] = target()
|
const [, setStore] = target()
|
||||||
setOptimisticAdd(setStore as (...args: unknown[]) => void, {
|
setOptimisticAdd(setStore as (...args: unknown[]) => void, {
|
||||||
@@ -222,11 +287,16 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const key = keyFor(directory, sessionID)
|
const key = keyFor(directory, sessionID)
|
||||||
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
|
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
|
||||||
|
|
||||||
|
touch(directory, setStore, sessionID)
|
||||||
|
|
||||||
|
if (store.message[sessionID] !== undefined && hasSession && meta.limit[key] !== undefined) return
|
||||||
|
|
||||||
const limit = meta.limit[key] ?? messagePageSize
|
const limit = meta.limit[key] ?? messagePageSize
|
||||||
|
|
||||||
const sessionReq = hasSession
|
const sessionReq = hasSession
|
||||||
? Promise.resolve()
|
? Promise.resolve()
|
||||||
: retry(() => client.session.get({ sessionID })).then((session) => {
|
: retry(() => client.session.get({ sessionID })).then((session) => {
|
||||||
|
if (!tracked(directory, sessionID)) return
|
||||||
const data = session.data
|
const data = session.data
|
||||||
if (!data) return
|
if (!data) return
|
||||||
setStore(
|
setStore(
|
||||||
@@ -256,11 +326,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const directory = sdk.directory
|
const directory = sdk.directory
|
||||||
const client = sdk.client
|
const client = sdk.client
|
||||||
const [store, setStore] = globalSync.child(directory)
|
const [store, setStore] = globalSync.child(directory)
|
||||||
|
touch(directory, setStore, sessionID)
|
||||||
if (store.session_diff[sessionID] !== undefined) return
|
if (store.session_diff[sessionID] !== undefined) return
|
||||||
|
|
||||||
const key = keyFor(directory, sessionID)
|
const key = keyFor(directory, sessionID)
|
||||||
return runInflight(inflightDiff, key, () =>
|
return runInflight(inflightDiff, key, () =>
|
||||||
retry(() => client.session.diff({ sessionID })).then((diff) => {
|
retry(() => client.session.diff({ sessionID })).then((diff) => {
|
||||||
|
if (!tracked(directory, sessionID)) return
|
||||||
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
|
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -269,6 +341,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const directory = sdk.directory
|
const directory = sdk.directory
|
||||||
const client = sdk.client
|
const client = sdk.client
|
||||||
const [store, setStore] = globalSync.child(directory)
|
const [store, setStore] = globalSync.child(directory)
|
||||||
|
touch(directory, setStore, sessionID)
|
||||||
const existing = store.todo[sessionID]
|
const existing = store.todo[sessionID]
|
||||||
const cached = globalSync.data.session_todo[sessionID]
|
const cached = globalSync.data.session_todo[sessionID]
|
||||||
if (existing !== undefined) {
|
if (existing !== undefined) {
|
||||||
@@ -285,6 +358,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const key = keyFor(directory, sessionID)
|
const key = keyFor(directory, sessionID)
|
||||||
return runInflight(inflightTodo, key, () =>
|
return runInflight(inflightTodo, key, () =>
|
||||||
retry(() => client.session.todo({ sessionID })).then((todo) => {
|
retry(() => client.session.todo({ sessionID })).then((todo) => {
|
||||||
|
if (!tracked(directory, sessionID)) return
|
||||||
const list = todo.data ?? []
|
const list = todo.data ?? []
|
||||||
setStore("todo", sessionID, reconcile(list, { key: "id" }))
|
setStore("todo", sessionID, reconcile(list, { key: "id" }))
|
||||||
globalSync.todo.set(sessionID, list)
|
globalSync.todo.set(sessionID, list)
|
||||||
@@ -308,6 +382,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const directory = sdk.directory
|
const directory = sdk.directory
|
||||||
const client = sdk.client
|
const client = sdk.client
|
||||||
const [, setStore] = globalSync.child(directory)
|
const [, setStore] = globalSync.child(directory)
|
||||||
|
touch(directory, setStore, sessionID)
|
||||||
const key = keyFor(directory, sessionID)
|
const key = keyFor(directory, sessionID)
|
||||||
const step = count ?? messagePageSize
|
const step = count ?? messagePageSize
|
||||||
if (meta.loading[key]) return
|
if (meta.loading[key]) return
|
||||||
@@ -323,6 +398,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
evict(sessionID: string, directory = sdk.directory) {
|
||||||
|
const [, setStore] = globalSync.child(directory)
|
||||||
|
seenFor(directory).delete(sessionID)
|
||||||
|
evict(directory, setStore, [sessionID])
|
||||||
|
},
|
||||||
fetch: async (count = 10) => {
|
fetch: async (count = 10) => {
|
||||||
const directory = sdk.directory
|
const directory = sdk.directory
|
||||||
const client = sdk.client
|
const client = sdk.client
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { beforeAll, describe, expect, mock, test } from "bun:test"
|
|||||||
|
|
||||||
let getWorkspaceTerminalCacheKey: (dir: string) => string
|
let getWorkspaceTerminalCacheKey: (dir: string) => string
|
||||||
let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
|
let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
|
||||||
|
let migrateTerminalState: (value: unknown) => unknown
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
mock.module("@solidjs/router", () => ({
|
mock.module("@solidjs/router", () => ({
|
||||||
@@ -17,6 +18,7 @@ beforeAll(async () => {
|
|||||||
const mod = await import("./terminal")
|
const mod = await import("./terminal")
|
||||||
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
|
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
|
||||||
getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys
|
getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys
|
||||||
|
migrateTerminalState = mod.migrateTerminalState
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("getWorkspaceTerminalCacheKey", () => {
|
describe("getWorkspaceTerminalCacheKey", () => {
|
||||||
@@ -37,3 +39,44 @@ describe("getLegacyTerminalStorageKeys", () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("migrateTerminalState", () => {
|
||||||
|
test("drops invalid terminals and restores a valid active terminal", () => {
|
||||||
|
expect(
|
||||||
|
migrateTerminalState({
|
||||||
|
active: "missing",
|
||||||
|
all: [
|
||||||
|
null,
|
||||||
|
{ id: "one", title: "Terminal 2" },
|
||||||
|
{ id: "one", title: "duplicate", titleNumber: 9 },
|
||||||
|
{ id: "two", title: "logs", titleNumber: 4, rows: 24, cols: 80 },
|
||||||
|
{ title: "no-id" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
active: "one",
|
||||||
|
all: [
|
||||||
|
{ id: "one", title: "Terminal 2", titleNumber: 2 },
|
||||||
|
{ id: "two", title: "logs", titleNumber: 4, rows: 24, cols: 80 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps a valid active id", () => {
|
||||||
|
expect(
|
||||||
|
migrateTerminalState({
|
||||||
|
active: "two",
|
||||||
|
all: [
|
||||||
|
{ id: "one", title: "Terminal 1" },
|
||||||
|
{ id: "two", title: "shell", titleNumber: 7 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
active: "two",
|
||||||
|
all: [
|
||||||
|
{ id: "one", title: "Terminal 1", titleNumber: 1 },
|
||||||
|
{ id: "two", title: "shell", titleNumber: 7 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js"
|
import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "solid-js"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import type { Platform } from "./platform"
|
import type { Platform } from "./platform"
|
||||||
@@ -20,6 +20,71 @@ export type LocalPTY = {
|
|||||||
const WORKSPACE_KEY = "__workspace__"
|
const WORKSPACE_KEY = "__workspace__"
|
||||||
const MAX_TERMINAL_SESSIONS = 20
|
const MAX_TERMINAL_SESSIONS = 20
|
||||||
|
|
||||||
|
function record(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown) {
|
||||||
|
return typeof value === "string" ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(value: unknown) {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberFromTitle(title: string) {
|
||||||
|
const match = title.match(/^Terminal (\d+)$/)
|
||||||
|
if (!match) return
|
||||||
|
const value = Number(match[1])
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function pty(value: unknown): LocalPTY | undefined {
|
||||||
|
if (!record(value)) return
|
||||||
|
|
||||||
|
const id = text(value.id)
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
const title = text(value.title) ?? ""
|
||||||
|
const number = num(value.titleNumber)
|
||||||
|
const rows = num(value.rows)
|
||||||
|
const cols = num(value.cols)
|
||||||
|
const buffer = text(value.buffer)
|
||||||
|
const scrollY = num(value.scrollY)
|
||||||
|
const cursor = num(value.cursor)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
titleNumber: number && number > 0 ? number : (numberFromTitle(title) ?? 0),
|
||||||
|
...(rows !== undefined ? { rows } : {}),
|
||||||
|
...(cols !== undefined ? { cols } : {}),
|
||||||
|
...(buffer !== undefined ? { buffer } : {}),
|
||||||
|
...(scrollY !== undefined ? { scrollY } : {}),
|
||||||
|
...(cursor !== undefined ? { cursor } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function migrateTerminalState(value: unknown) {
|
||||||
|
if (!record(value)) return value
|
||||||
|
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const all = (Array.isArray(value.all) ? value.all : []).flatMap((item) => {
|
||||||
|
const next = pty(item)
|
||||||
|
if (!next || seen.has(next.id)) return []
|
||||||
|
seen.add(next.id)
|
||||||
|
return [next]
|
||||||
|
})
|
||||||
|
|
||||||
|
const active = text(value.active)
|
||||||
|
|
||||||
|
return {
|
||||||
|
active: active && seen.has(active) ? active : all[0]?.id,
|
||||||
|
all,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getWorkspaceTerminalCacheKey(dir: string) {
|
export function getWorkspaceTerminalCacheKey(dir: string) {
|
||||||
return `${dir}:${WORKSPACE_KEY}`
|
return `${dir}:${WORKSPACE_KEY}`
|
||||||
}
|
}
|
||||||
@@ -38,6 +103,16 @@ type TerminalCacheEntry = {
|
|||||||
|
|
||||||
const caches = new Set<Map<string, TerminalCacheEntry>>()
|
const caches = new Set<Map<string, TerminalCacheEntry>>()
|
||||||
|
|
||||||
|
const trimTerminal = (pty: LocalPTY) => {
|
||||||
|
if (!pty.buffer && pty.cursor === undefined && pty.scrollY === undefined) return pty
|
||||||
|
return {
|
||||||
|
...pty,
|
||||||
|
buffer: undefined,
|
||||||
|
cursor: undefined,
|
||||||
|
scrollY: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) {
|
export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) {
|
||||||
const key = getWorkspaceTerminalCacheKey(dir)
|
const key = getWorkspaceTerminalCacheKey(dir)
|
||||||
for (const cache of caches) {
|
for (const cache of caches) {
|
||||||
@@ -61,16 +136,11 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat
|
|||||||
function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, legacySessionID?: string) {
|
function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, legacySessionID?: string) {
|
||||||
const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
|
const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
|
||||||
|
|
||||||
const numberFromTitle = (title: string) => {
|
|
||||||
const match = title.match(/^Terminal (\d+)$/)
|
|
||||||
if (!match) return
|
|
||||||
const value = Number(match[1])
|
|
||||||
if (!Number.isFinite(value) || value <= 0) return
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
Persist.workspace(dir, "terminal", legacy),
|
{
|
||||||
|
...Persist.workspace(dir, "terminal", legacy),
|
||||||
|
migrate: migrateTerminalState,
|
||||||
|
},
|
||||||
createStore<{
|
createStore<{
|
||||||
active?: string
|
active?: string
|
||||||
all: LocalPTY[]
|
all: LocalPTY[]
|
||||||
@@ -118,26 +188,6 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
|
|||||||
})
|
})
|
||||||
onCleanup(unsub)
|
onCleanup(unsub)
|
||||||
|
|
||||||
const meta = { migrated: false }
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!ready()) return
|
|
||||||
if (meta.migrated) return
|
|
||||||
meta.migrated = true
|
|
||||||
|
|
||||||
setStore("all", (all) => {
|
|
||||||
const next = all.map((pty) => {
|
|
||||||
const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined
|
|
||||||
if (direct !== undefined) return pty
|
|
||||||
const parsed = numberFromTitle(pty.title)
|
|
||||||
if (parsed === undefined) return pty
|
|
||||||
return { ...pty, titleNumber: parsed }
|
|
||||||
})
|
|
||||||
if (next.every((pty, index) => pty === all[index])) return all
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready,
|
||||||
all: createMemo(() => store.all),
|
all: createMemo(() => store.all),
|
||||||
@@ -188,6 +238,18 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
|
|||||||
console.error("Failed to update terminal", error)
|
console.error("Failed to update terminal", error)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
trim(id: string) {
|
||||||
|
const index = store.all.findIndex((x) => x.id === id)
|
||||||
|
if (index === -1) return
|
||||||
|
setStore("all", index, (pty) => trimTerminal(pty))
|
||||||
|
},
|
||||||
|
trimAll() {
|
||||||
|
setStore("all", (all) => {
|
||||||
|
const next = all.map(trimTerminal)
|
||||||
|
if (next.every((pty, index) => pty === all[index])) return all
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
},
|
||||||
async clone(id: string) {
|
async clone(id: string) {
|
||||||
const index = store.all.findIndex((x) => x.id === id)
|
const index = store.all.findIndex((x) => x.id === id)
|
||||||
const pty = store.all[index]
|
const pty = store.all[index]
|
||||||
@@ -322,12 +384,27 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
|||||||
|
|
||||||
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
|
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => ({ dir: params.dir, id: params.id }),
|
||||||
|
(next, prev) => {
|
||||||
|
if (!prev?.dir) return
|
||||||
|
if (next.dir === prev.dir && next.id === prev.id) return
|
||||||
|
if (next.dir === prev.dir && next.id) return
|
||||||
|
loadWorkspace(prev.dir, prev.id).trimAll()
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready: () => workspace().ready(),
|
ready: () => workspace().ready(),
|
||||||
all: () => workspace().all(),
|
all: () => workspace().all(),
|
||||||
active: () => workspace().active(),
|
active: () => workspace().active(),
|
||||||
new: () => workspace().new(),
|
new: () => workspace().new(),
|
||||||
update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty),
|
update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty),
|
||||||
|
trim: (id: string) => workspace().trim(id),
|
||||||
|
trimAll: () => workspace().trimAll(),
|
||||||
clone: (id: string) => workspace().clone(id),
|
clone: (id: string) => workspace().clone(id),
|
||||||
open: (id: string) => workspace().open(id),
|
open: (id: string) => workspace().open(id),
|
||||||
close: (id: string) => workspace().close(id),
|
close: (id: string) => workspace().close(id),
|
||||||
|
|||||||
@@ -456,6 +456,7 @@ export const dict = {
|
|||||||
"session.todo.title": "المهام",
|
"session.todo.title": "المهام",
|
||||||
"session.todo.collapse": "طي",
|
"session.todo.collapse": "طي",
|
||||||
"session.todo.expand": "توسيع",
|
"session.todo.expand": "توسيع",
|
||||||
|
"session.new.title": "ابنِ أي شيء",
|
||||||
"session.new.worktree.main": "الفرع الرئيسي",
|
"session.new.worktree.main": "الفرع الرئيسي",
|
||||||
"session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})",
|
"session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})",
|
||||||
"session.new.worktree.create": "إنشاء شجرة عمل جديدة",
|
"session.new.worktree.create": "إنشاء شجرة عمل جديدة",
|
||||||
|
|||||||
@@ -459,6 +459,7 @@ export const dict = {
|
|||||||
"session.todo.title": "Tarefas",
|
"session.todo.title": "Tarefas",
|
||||||
"session.todo.collapse": "Recolher",
|
"session.todo.collapse": "Recolher",
|
||||||
"session.todo.expand": "Expandir",
|
"session.todo.expand": "Expandir",
|
||||||
|
"session.new.title": "Crie qualquer coisa",
|
||||||
"session.new.worktree.main": "Branch principal",
|
"session.new.worktree.main": "Branch principal",
|
||||||
"session.new.worktree.mainWithBranch": "Branch principal ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Branch principal ({{branch}})",
|
||||||
"session.new.worktree.create": "Criar novo worktree",
|
"session.new.worktree.create": "Criar novo worktree",
|
||||||
|
|||||||
@@ -515,6 +515,7 @@ export const dict = {
|
|||||||
"session.todo.collapse": "Sažmi",
|
"session.todo.collapse": "Sažmi",
|
||||||
"session.todo.expand": "Proširi",
|
"session.todo.expand": "Proširi",
|
||||||
|
|
||||||
|
"session.new.title": "Napravi bilo šta",
|
||||||
"session.new.worktree.main": "Glavna grana",
|
"session.new.worktree.main": "Glavna grana",
|
||||||
"session.new.worktree.mainWithBranch": "Glavna grana ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Glavna grana ({{branch}})",
|
||||||
"session.new.worktree.create": "Kreiraj novi worktree",
|
"session.new.worktree.create": "Kreiraj novi worktree",
|
||||||
|
|||||||
@@ -510,6 +510,7 @@ export const dict = {
|
|||||||
"session.todo.collapse": "Skjul",
|
"session.todo.collapse": "Skjul",
|
||||||
"session.todo.expand": "Udvid",
|
"session.todo.expand": "Udvid",
|
||||||
|
|
||||||
|
"session.new.title": "Byg hvad som helst",
|
||||||
"session.new.worktree.main": "Hovedgren",
|
"session.new.worktree.main": "Hovedgren",
|
||||||
"session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})",
|
||||||
"session.new.worktree.create": "Opret nyt worktree",
|
"session.new.worktree.create": "Opret nyt worktree",
|
||||||
|
|||||||
@@ -467,6 +467,7 @@ export const dict = {
|
|||||||
"session.todo.title": "Aufgaben",
|
"session.todo.title": "Aufgaben",
|
||||||
"session.todo.collapse": "Einklappen",
|
"session.todo.collapse": "Einklappen",
|
||||||
"session.todo.expand": "Ausklappen",
|
"session.todo.expand": "Ausklappen",
|
||||||
|
"session.new.title": "Baue, was du willst",
|
||||||
"session.new.worktree.main": "Haupt-Branch",
|
"session.new.worktree.main": "Haupt-Branch",
|
||||||
"session.new.worktree.mainWithBranch": "Haupt-Branch ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Haupt-Branch ({{branch}})",
|
||||||
"session.new.worktree.create": "Neuen Worktree erstellen",
|
"session.new.worktree.create": "Neuen Worktree erstellen",
|
||||||
|
|||||||
@@ -511,11 +511,13 @@ export const dict = {
|
|||||||
"session.review.change.other": "Changes",
|
"session.review.change.other": "Changes",
|
||||||
"session.review.loadingChanges": "Loading changes...",
|
"session.review.loadingChanges": "Loading changes...",
|
||||||
"session.review.empty": "No changes in this session yet",
|
"session.review.empty": "No changes in this session yet",
|
||||||
"session.review.noVcs": "No git VCS detected, so session changes will not be detected",
|
"session.review.noVcs": "No Git Version Control System detected, changes not displayed",
|
||||||
|
"session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
|
||||||
"session.review.noChanges": "No changes",
|
"session.review.noChanges": "No changes",
|
||||||
|
|
||||||
"session.files.selectToOpen": "Select a file to open",
|
"session.files.selectToOpen": "Select a file to open",
|
||||||
"session.files.all": "All files",
|
"session.files.all": "All files",
|
||||||
|
"session.files.empty": "No files",
|
||||||
"session.files.binaryContent": "Binary file (content cannot be displayed)",
|
"session.files.binaryContent": "Binary file (content cannot be displayed)",
|
||||||
|
|
||||||
"session.messages.renderEarlier": "Render earlier messages",
|
"session.messages.renderEarlier": "Render earlier messages",
|
||||||
@@ -529,6 +531,7 @@ export const dict = {
|
|||||||
"session.todo.collapse": "Collapse",
|
"session.todo.collapse": "Collapse",
|
||||||
"session.todo.expand": "Expand",
|
"session.todo.expand": "Expand",
|
||||||
|
|
||||||
|
"session.new.title": "Build anything",
|
||||||
"session.new.worktree.main": "Main branch",
|
"session.new.worktree.main": "Main branch",
|
||||||
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
|
||||||
"session.new.worktree.create": "Create new worktree",
|
"session.new.worktree.create": "Create new worktree",
|
||||||
|
|||||||
@@ -516,6 +516,7 @@ export const dict = {
|
|||||||
"session.todo.collapse": "Contraer",
|
"session.todo.collapse": "Contraer",
|
||||||
"session.todo.expand": "Expandir",
|
"session.todo.expand": "Expandir",
|
||||||
|
|
||||||
|
"session.new.title": "Construye lo que quieras",
|
||||||
"session.new.worktree.main": "Rama principal",
|
"session.new.worktree.main": "Rama principal",
|
||||||
"session.new.worktree.mainWithBranch": "Rama principal ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Rama principal ({{branch}})",
|
||||||
"session.new.worktree.create": "Crear nuevo árbol de trabajo",
|
"session.new.worktree.create": "Crear nuevo árbol de trabajo",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user