Compare commits
214
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e13d44684d | ||
|
|
9bbc874927 | ||
|
|
dce7eceb28 | ||
|
|
0e077f7483 | ||
|
|
776e7a9c15 | ||
|
|
c455d41876 | ||
|
|
a776a3ee12 | ||
|
|
64fb9233bf | ||
|
|
3533f33ecb | ||
|
|
1cb7df7159 | ||
|
|
a4f8d66a9b | ||
|
|
12efbbfa4c | ||
|
|
13402529ce | ||
|
|
fc678ef36c | ||
|
|
03cd891ea9 | ||
|
|
6314d741e7 | ||
|
|
c45467964c | ||
|
|
2eeba53b07 | ||
|
|
d4107d51f1 | ||
|
|
d8fbe0af01 | ||
|
|
b76ead3fe8 | ||
|
|
51835ecf90 | ||
|
|
328c6de80d | ||
|
|
c9c0318e0e | ||
|
|
d481f64bde | ||
|
|
54e7baa6cf | ||
|
|
1d7fcd40b4 | ||
|
|
db7bafe917 | ||
|
|
b1ef501207 | ||
|
|
9fb12a906e | ||
|
|
fafbc29316 | ||
|
|
7b0def4b81 | ||
|
|
1d9c83b576 | ||
|
|
2c825c3223 | ||
|
|
2a4dedc210 | ||
|
|
b0bca6342e | ||
|
|
547eb7676d | ||
|
|
83f083ee0d | ||
|
|
090f636354 | ||
|
|
d26c6f80e1 | ||
|
|
16a6d6feba | ||
|
|
f1c3a44190 | ||
|
|
34fa5de9c5 | ||
|
|
cb67465675 | ||
|
|
4e73473119 | ||
|
|
cc18fa599c | ||
|
|
aa81c1c4cb | ||
|
|
8569fc1f0e | ||
|
|
78de287bcc | ||
|
|
bbc7052c7a | ||
|
|
502d6db6d0 | ||
|
|
0b0ad5de99 | ||
|
|
9e6c4a01aa | ||
|
|
4a81df190c | ||
|
|
75cae81f75 | ||
|
|
ed3bb3ea8f | ||
|
|
fac23a1afc | ||
|
|
f89696509e | ||
|
|
604ab1bde1 | ||
|
|
fbd9b7cf4f | ||
|
|
58f45ae22b | ||
|
|
440405dbdd | ||
|
|
a1cda29012 | ||
|
|
f96e2d4222 | ||
|
|
387ab78bf6 | ||
|
|
dbc00aa8e0 | ||
|
|
c37f7b9d99 | ||
|
|
cf7ca9b2f7 | ||
|
|
981c7b9e37 | ||
|
|
2aae0d3493 | ||
|
|
bcc0d19867 | ||
|
|
9c585bb58b | ||
|
|
0f6bc8ae71 | ||
|
|
7291e28273 | ||
|
|
db57fe6193 | ||
|
|
802416639b | ||
|
|
7ec398d855 | ||
|
|
4ab35d2c5c | ||
|
|
b4ae030fc2 | ||
|
|
0843964eb3 | ||
|
|
a1b06d63c9 | ||
|
|
1b6820bab5 | ||
|
|
89bf199c07 | ||
|
|
5acfdd1c5d | ||
|
|
556703f8ab | ||
|
|
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 |
@@ -3,14 +3,6 @@ description: "Setup Bun with caching and install dependencies"
|
|||||||
runs:
|
runs:
|
||||||
using: "composite"
|
using: "composite"
|
||||||
steps:
|
steps:
|
||||||
- name: Cache Bun dependencies
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.bun/install/cache
|
|
||||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-bun-
|
|
||||||
|
|
||||||
- name: Get baseline download URL
|
- name: Get baseline download URL
|
||||||
id: bun-url
|
id: bun-url
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -31,6 +23,23 @@ 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: Get cache directory
|
||||||
|
id: cache
|
||||||
|
shell: bash
|
||||||
|
run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Cache Bun dependencies
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ steps.cache.outputs.dir }}
|
||||||
|
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|||||||
@@ -115,6 +115,9 @@ jobs:
|
|||||||
target: x86_64-apple-darwin
|
target: x86_64-apple-darwin
|
||||||
- host: macos-latest
|
- host: macos-latest
|
||||||
target: aarch64-apple-darwin
|
target: aarch64-apple-darwin
|
||||||
|
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
|
||||||
|
- host: windows-2025
|
||||||
|
target: aarch64-pc-windows-msvc
|
||||||
- host: blacksmith-4vcpu-windows-2025
|
- host: blacksmith-4vcpu-windows-2025
|
||||||
target: x86_64-pc-windows-msvc
|
target: x86_64-pc-windows-msvc
|
||||||
- host: blacksmith-4vcpu-ubuntu-2404
|
- host: blacksmith-4vcpu-ubuntu-2404
|
||||||
@@ -149,6 +152,10 @@ jobs:
|
|||||||
|
|
||||||
- uses: ./.github/actions/setup-bun
|
- uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
|
||||||
- name: Cache apt packages
|
- name: Cache apt packages
|
||||||
if: contains(matrix.settings.host, 'ubuntu')
|
if: contains(matrix.settings.host, 'ubuntu')
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -254,6 +261,10 @@ jobs:
|
|||||||
- host: macos-latest
|
- host: macos-latest
|
||||||
target: aarch64-apple-darwin
|
target: aarch64-apple-darwin
|
||||||
platform_flag: --mac --arm64
|
platform_flag: --mac --arm64
|
||||||
|
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
|
||||||
|
- host: "windows-2025"
|
||||||
|
target: aarch64-pc-windows-msvc
|
||||||
|
platform_flag: --win --arm64
|
||||||
- host: "blacksmith-4vcpu-windows-2025"
|
- host: "blacksmith-4vcpu-windows-2025"
|
||||||
target: x86_64-pc-windows-msvc
|
target: x86_64-pc-windows-msvc
|
||||||
platform_flag: --win
|
platform_flag: --win
|
||||||
|
|||||||
@@ -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,4 @@
|
|||||||
plans/
|
plans/
|
||||||
bun.lock
|
bun.lock
|
||||||
package.json
|
package.json
|
||||||
|
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,
|
||||||
|
|||||||
@@ -5,16 +5,8 @@ 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
|
||||||
@@ -50,7 +42,10 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
|||||||
export default tool({
|
export default tool({
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
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 +68,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'")
|
||||||
|
|||||||
@@ -4,5 +4,3 @@ Choose labels and assignee using the current triage policy and ownership rules.
|
|||||||
Pick the most fitting labels for the issue and assign one owner.
|
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.
|
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.
|
||||||
|
|||||||
+1
-1
@@ -137,4 +137,4 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**加入我们的社区** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)
|
||||||
|
|||||||
+1
-1
@@ -137,4 +137,4 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**加入我們的社群** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)
|
||||||
|
|||||||
@@ -103,6 +103,12 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
|
|||||||
const zenLiteProduct = new stripe.Product("ZenLite", {
|
const zenLiteProduct = new stripe.Product("ZenLite", {
|
||||||
name: "OpenCode Go",
|
name: "OpenCode Go",
|
||||||
})
|
})
|
||||||
|
const zenLiteCouponFirstMonth50 = new stripe.Coupon("ZenLiteCouponFirstMonth50", {
|
||||||
|
name: "First month 50% off",
|
||||||
|
percentOff: 50,
|
||||||
|
appliesToProducts: [zenLiteProduct.id],
|
||||||
|
duration: "once",
|
||||||
|
})
|
||||||
const zenLitePrice = new stripe.Price("ZenLitePrice", {
|
const zenLitePrice = new stripe.Price("ZenLitePrice", {
|
||||||
product: zenLiteProduct.id,
|
product: zenLiteProduct.id,
|
||||||
currency: "usd",
|
currency: "usd",
|
||||||
@@ -116,6 +122,7 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
|
|||||||
properties: {
|
properties: {
|
||||||
product: zenLiteProduct.id,
|
product: zenLiteProduct.id,
|
||||||
price: zenLitePrice.id,
|
price: zenLitePrice.id,
|
||||||
|
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-4kjoJ06VNvHltPHfzQRBG0bC6R39jao10ffGzrNZ230=",
|
"x86_64-linux": "sha256-WJgo6UclmtQOEubnKMZybdIEhZ1uRTucF61yojjd+l0=",
|
||||||
"aarch64-linux": "sha256-6Uio+S2rcyBWbBEeOZb9N1CCKgkbKi68lOIKi3Ws/pQ=",
|
"aarch64-linux": "sha256-QfZ/g7EZFpe6ndR3dG8WvVfMj5Kyd/R/4kkTJfGJxL4=",
|
||||||
"aarch64-darwin": "sha256-8ngN5KVN4vhdsk0QJ11BGgSVBrcaEbwSj23c77HBpgs=",
|
"aarch64-darwin": "sha256-ezr/R70XJr9eN5l3mgb7HzLF6QsofNEKUOtuxbfli80=",
|
||||||
"x86_64-darwin": "sha256-v/ueYGb9a0Nymzy+mkO4uQr78DAuJnES1qOT0onFgnQ="
|
"x86_64-darwin": "sha256-MbsBGS415uEU/n1RQ/5H5pqh+udLY3+oimJ+eS5uJVI="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
||||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||||
|
"effect": "4.0.0-beta.31",
|
||||||
"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",
|
||||||
|
|||||||
@@ -70,8 +70,13 @@ test("test description", async ({ page, sdk, gotoSession }) => {
|
|||||||
- `openSettings(page)` - Open settings dialog
|
- `openSettings(page)` - Open settings dialog
|
||||||
- `closeDialog(page, dialog)` - Close any dialog
|
- `closeDialog(page, dialog)` - Close any dialog
|
||||||
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
|
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
|
||||||
|
- `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output
|
||||||
|
- `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output
|
||||||
- `withSession(sdk, title, callback)` - Create temp session
|
- `withSession(sdk, title, callback)` - Create temp session
|
||||||
- `withProject(...)` - Create temp project/workspace
|
- `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
|
- `trackSession(sessionID, directory?)` - Register session for fixture cleanup
|
||||||
- `trackDirectory(directory)` - Register directory 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
|
||||||
@@ -164,14 +169,22 @@ await page.keyboard.press(`${modKey}+B`) // Toggle sidebar
|
|||||||
await page.keyboard.press(`${modKey}+Comma`) // Open settings
|
await page.keyboard.press(`${modKey}+Comma`) // Open settings
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Terminal Tests
|
||||||
|
|
||||||
|
- In terminal tests, type through the browser. Do not write to the PTY through the SDK.
|
||||||
|
- Use `waitTerminalReady(page, { term? })` and `runTerminal(page, { cmd, token, term?, timeout? })` from `actions.ts`.
|
||||||
|
- These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles.
|
||||||
|
- Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks.
|
||||||
|
|
||||||
## Writing New Tests
|
## Writing New Tests
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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 { terminalAttr, type E2EWindow } from "../src/testing/terminal"
|
||||||
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
||||||
import {
|
import {
|
||||||
dropdownMenuTriggerSelector,
|
dropdownMenuTriggerSelector,
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
listItemSelector,
|
listItemSelector,
|
||||||
listItemKeySelector,
|
listItemKeySelector,
|
||||||
listItemKeyStartsWithSelector,
|
listItemKeyStartsWithSelector,
|
||||||
|
terminalSelector,
|
||||||
workspaceItemSelector,
|
workspaceItemSelector,
|
||||||
workspaceMenuTriggerSelector,
|
workspaceMenuTriggerSelector,
|
||||||
} from "./selectors"
|
} from "./selectors"
|
||||||
@@ -28,6 +30,53 @@ export async function defocus(page: Page) {
|
|||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function terminalID(term: Locator) {
|
||||||
|
const id = await term.getAttribute(terminalAttr)
|
||||||
|
if (id) return id
|
||||||
|
throw new Error(`Active terminal missing ${terminalAttr}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function terminalReady(page: Page, term?: Locator) {
|
||||||
|
const next = term ?? page.locator(terminalSelector).first()
|
||||||
|
const id = await terminalID(next)
|
||||||
|
return page.evaluate((id) => {
|
||||||
|
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
|
||||||
|
return !!state?.connected && (state.settled ?? 0) > 0
|
||||||
|
}, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function terminalHas(page: Page, input: { term?: Locator; token: string }) {
|
||||||
|
const next = input.term ?? page.locator(terminalSelector).first()
|
||||||
|
const id = await terminalID(next)
|
||||||
|
return page.evaluate(
|
||||||
|
(input) => {
|
||||||
|
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[input.id]
|
||||||
|
return state?.rendered.includes(input.token) ?? false
|
||||||
|
},
|
||||||
|
{ id, token: input.token },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) {
|
||||||
|
const term = input?.term ?? page.locator(terminalSelector).first()
|
||||||
|
const timeout = input?.timeout ?? 10_000
|
||||||
|
await expect(term).toBeVisible()
|
||||||
|
await expect(term.locator("textarea")).toHaveCount(1)
|
||||||
|
await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) {
|
||||||
|
const term = input.term ?? page.locator(terminalSelector).first()
|
||||||
|
const timeout = input.timeout ?? 10_000
|
||||||
|
await waitTerminalReady(page, { term, timeout })
|
||||||
|
const textarea = term.locator("textarea")
|
||||||
|
await term.click()
|
||||||
|
await expect(textarea).toBeFocused()
|
||||||
|
await page.keyboard.type(input.cmd)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
export async function openPalette(page: Page) {
|
export async function openPalette(page: Page) {
|
||||||
await defocus(page)
|
await defocus(page)
|
||||||
await page.keyboard.press(`${modKey}+P`)
|
await page.keyboard.press(`${modKey}+P`)
|
||||||
@@ -199,6 +248,33 @@ export async function cleanupTestProject(directory: string) {
|
|||||||
await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined)
|
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) {
|
||||||
const match = /\/session\/([^/?#]+)/.exec(url)
|
const match = /\/session\/([^/?#]+)/.exec(url)
|
||||||
return match?.[1]
|
return match?.[1]
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { test as base, expect, type Page } from "@playwright/test"
|
import { test as base, expect, type Page } from "@playwright/test"
|
||||||
|
import type { E2EWindow } from "../src/testing/terminal"
|
||||||
import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } 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"
|
||||||
@@ -91,6 +92,14 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
|||||||
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
|
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
|
||||||
await seedProjects(page, input)
|
await seedProjects(page, input)
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
|
const win = window as E2EWindow
|
||||||
|
win.__opencode_e2e = {
|
||||||
|
...win.__opencode_e2e,
|
||||||
|
terminal: {
|
||||||
|
enabled: true,
|
||||||
|
terminals: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.global.dat:model",
|
"opencode.global.dat:model",
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|||||||
@@ -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,13 +1,9 @@
|
|||||||
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 { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl } from "../actions"
|
import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions"
|
||||||
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||||
import { dirSlug } from "../utils"
|
import { dirSlug, resolveDirectory } from "../utils"
|
||||||
|
|
||||||
function slugFromUrl(url: string) {
|
|
||||||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
async function workspaces(page: Page, directory: string, enabled: boolean) {
|
async function workspaces(page: Page, directory: string, enabled: boolean) {
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
@@ -76,7 +72,6 @@ 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 workspaceDir: string | undefined
|
|
||||||
try {
|
try {
|
||||||
await withProject(
|
await withProject(
|
||||||
async ({ directory, slug, trackSession, trackDirectory }) => {
|
async ({ directory, slug, trackSession, trackDirectory }) => {
|
||||||
@@ -89,33 +84,27 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||||||
|
|
||||||
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}`)
|
|
||||||
trackDirectory(workspaceDir)
|
|
||||||
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)
|
||||||
@@ -128,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()}`)
|
||||||
trackSession(created, workspaceDir)
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +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 { 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 waitSlug(page: Page, skip: string[] = []) {
|
|
||||||
let prev = ""
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
() => {
|
|
||||||
const slug = slugFromUrl(page.url())
|
|
||||||
if (!slug) return ""
|
|
||||||
if (skip.includes(slug)) return ""
|
|
||||||
if (slug !== prev) {
|
|
||||||
prev = slug
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return slug
|
|
||||||
},
|
|
||||||
{ timeout: 45_000 },
|
|
||||||
)
|
|
||||||
.not.toBe("")
|
|
||||||
return slugFromUrl(page.url())
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitWorkspaceReady(page: Page, slug: string) {
|
async function waitWorkspaceReady(page: Page, slug: string) {
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await expect
|
await expect
|
||||||
|
|||||||
@@ -14,34 +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 waitSlug(page: Page, skip: string[] = []) {
|
|
||||||
let prev = ""
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
() => {
|
|
||||||
const slug = slugFromUrl(page.url())
|
|
||||||
if (!slug) return ""
|
|
||||||
if (skip.includes(slug)) return ""
|
|
||||||
if (slug !== prev) {
|
|
||||||
prev = slug
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return slug
|
|
||||||
},
|
|
||||||
{ timeout: 45_000 },
|
|
||||||
)
|
|
||||||
.not.toBe("")
|
|
||||||
return slugFromUrl(page.url())
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
||||||
@@ -353,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,4 +1,5 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
|
import { waitTerminalReady } from "../actions"
|
||||||
import { promptSelector, terminalSelector } from "../selectors"
|
import { promptSelector, terminalSelector } from "../selectors"
|
||||||
|
|
||||||
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
|
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
|
||||||
@@ -6,18 +7,29 @@ test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
|
|||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
const prompt = page.locator(promptSelector)
|
||||||
const terminal = page.locator(terminalSelector)
|
const terminal = page.locator(terminalSelector)
|
||||||
|
const slash = page.locator('[data-slash-id="terminal.toggle"]').first()
|
||||||
|
|
||||||
await expect(terminal).not.toBeVisible()
|
await expect(terminal).not.toBeVisible()
|
||||||
|
|
||||||
await prompt.click()
|
await prompt.fill("/terminal")
|
||||||
await page.keyboard.type("/terminal")
|
await expect(slash).toBeVisible()
|
||||||
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
|
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.press("Enter")
|
||||||
await expect(terminal).toBeVisible()
|
await waitTerminalReady(page, { term: terminal })
|
||||||
|
|
||||||
await prompt.click()
|
// Terminal panel retries focus (immediate, RAF, 120ms, 240ms) after opening,
|
||||||
await page.keyboard.type("/terminal")
|
// which can steal focus from the prompt and prevent fill() from triggering
|
||||||
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
|
// the slash popover. Re-attempt click+fill until all retries are exhausted
|
||||||
|
// and the popover appears.
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
await prompt.click().catch(() => false)
|
||||||
|
await prompt.fill("/terminal").catch(() => false)
|
||||||
|
return slash.isVisible().catch(() => false)
|
||||||
|
},
|
||||||
|
{ timeout: 10_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.press("Enter")
|
||||||
await expect(terminal).not.toBeVisible()
|
await expect(terminal).not.toBeVisible()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export const promptSelector = '[data-component="prompt-input"]'
|
export const promptSelector = '[data-component="prompt-input"]'
|
||||||
export const terminalSelector = '[data-component="terminal"]'
|
export const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]'
|
||||||
|
export const terminalSelector = `${terminalPanelSelector} [data-component="terminal"]`
|
||||||
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
|
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
|
||||||
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
|
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
|
||||||
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
|
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
|
||||||
@@ -30,8 +31,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,217 @@
|
|||||||
|
import { waitSessionIdle, withSession } from "../actions"
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { createSdk } from "../utils"
|
||||||
|
|
||||||
|
const count = 14
|
||||||
|
|
||||||
|
function body(mark: string) {
|
||||||
|
return [
|
||||||
|
`title ${mark}`,
|
||||||
|
`mark ${mark}`,
|
||||||
|
...Array.from({ length: 32 }, (_, i) => `line ${String(i + 1).padStart(2, "0")} ${mark}`),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function files(tag: string) {
|
||||||
|
return Array.from({ length: count }, (_, i) => {
|
||||||
|
const id = String(i).padStart(2, "0")
|
||||||
|
return {
|
||||||
|
file: `review-scroll-${id}.txt`,
|
||||||
|
mark: `${tag}-${id}`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function seed(list: ReturnType<typeof files>) {
|
||||||
|
const out = ["*** Begin Patch"]
|
||||||
|
|
||||||
|
for (const item of list) {
|
||||||
|
out.push(`*** Add File: ${item.file}`)
|
||||||
|
for (const line of body(item.mark)) out.push(`+${line}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push("*** End Patch")
|
||||||
|
return out.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
function edit(file: string, prev: string, next: string) {
|
||||||
|
return ["*** Begin Patch", `*** Update File: ${file}`, "@@", `-mark ${prev}`, `+mark ${next}`, "*** End Patch"].join(
|
||||||
|
"\n",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patch(sdk: ReturnType<typeof createSdk>, sessionID: string, patchText: string) {
|
||||||
|
await sdk.session.promptAsync({
|
||||||
|
sessionID,
|
||||||
|
agent: "build",
|
||||||
|
system: [
|
||||||
|
"You are seeding deterministic e2e UI state.",
|
||||||
|
"Your only valid response is one apply_patch tool call.",
|
||||||
|
`Use this JSON input: ${JSON.stringify({ patchText })}`,
|
||||||
|
"Do not call any other tools.",
|
||||||
|
"Do not output plain text.",
|
||||||
|
].join("\n"),
|
||||||
|
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitSessionIdle(sdk, sessionID, 120_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function show(page: Parameters<typeof test>[0]["page"]) {
|
||||||
|
const btn = page.getByRole("button", { name: "Toggle review" }).first()
|
||||||
|
await expect(btn).toBeVisible()
|
||||||
|
if ((await btn.getAttribute("aria-expanded")) !== "true") await btn.click()
|
||||||
|
await expect(btn).toHaveAttribute("aria-expanded", "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expand(page: Parameters<typeof test>[0]["page"]) {
|
||||||
|
const close = page.getByRole("button", { name: /^Collapse all$/i }).first()
|
||||||
|
const open = await close
|
||||||
|
.isVisible()
|
||||||
|
.then((value) => value)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
const btn = page.getByRole("button", { name: /^Expand all$/i }).first()
|
||||||
|
if (open) {
|
||||||
|
await close.click()
|
||||||
|
await expect(btn).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(btn).toBeVisible()
|
||||||
|
await btn.click()
|
||||||
|
await expect(close).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitMark(page: Parameters<typeof test>[0]["page"], file: string, mark: string) {
|
||||||
|
await page.waitForFunction(
|
||||||
|
({ file, mark }) => {
|
||||||
|
const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport')
|
||||||
|
if (!(view instanceof HTMLElement)) return false
|
||||||
|
|
||||||
|
const head = Array.from(view.querySelectorAll("h3")).find(
|
||||||
|
(node) => node instanceof HTMLElement && node.textContent?.includes(file),
|
||||||
|
)
|
||||||
|
if (!(head instanceof HTMLElement)) return false
|
||||||
|
|
||||||
|
return Array.from(head.parentElement?.querySelectorAll("diffs-container") ?? []).some((host) => {
|
||||||
|
if (!(host instanceof HTMLElement)) return false
|
||||||
|
const root = host.shadowRoot
|
||||||
|
return root?.textContent?.includes(`mark ${mark}`) ?? false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ file, mark },
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function spot(page: Parameters<typeof test>[0]["page"], file: string) {
|
||||||
|
return page.evaluate((file) => {
|
||||||
|
const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport')
|
||||||
|
if (!(view instanceof HTMLElement)) return null
|
||||||
|
|
||||||
|
const row = Array.from(view.querySelectorAll("h3")).find(
|
||||||
|
(node) => node instanceof HTMLElement && node.textContent?.includes(file),
|
||||||
|
)
|
||||||
|
if (!(row instanceof HTMLElement)) return null
|
||||||
|
|
||||||
|
const a = row.getBoundingClientRect()
|
||||||
|
const b = view.getBoundingClientRect()
|
||||||
|
return {
|
||||||
|
top: a.top - b.top,
|
||||||
|
y: view.scrollTop,
|
||||||
|
}
|
||||||
|
}, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
|
||||||
|
test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
|
||||||
|
test.setTimeout(180_000)
|
||||||
|
|
||||||
|
const tag = `review-${Date.now()}`
|
||||||
|
const list = files(tag)
|
||||||
|
const hit = list[list.length - 4]!
|
||||||
|
const next = `${tag}-live`
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1600, height: 1000 })
|
||||||
|
|
||||||
|
await withProject(async (project) => {
|
||||||
|
const sdk = createSdk(project.directory)
|
||||||
|
|
||||||
|
await withSession(sdk, `e2e review ${tag}`, async (session) => {
|
||||||
|
await patch(sdk, session.id, seed(list))
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const info = await sdk.session.get({ sessionID: session.id }).then((res) => res.data)
|
||||||
|
return info?.summary?.files ?? 0
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(list.length)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||||
|
return diff.length
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(list.length)
|
||||||
|
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
await show(page)
|
||||||
|
|
||||||
|
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
||||||
|
await expect(tab).toBeVisible()
|
||||||
|
await tab.click()
|
||||||
|
|
||||||
|
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
|
||||||
|
await expect(view).toBeVisible()
|
||||||
|
const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
|
||||||
|
await expect(heads).toHaveCount(list.length, {
|
||||||
|
timeout: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
await expand(page)
|
||||||
|
await waitMark(page, hit.file, hit.mark)
|
||||||
|
|
||||||
|
const row = page
|
||||||
|
.getByRole("heading", { level: 3, name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) })
|
||||||
|
.first()
|
||||||
|
await expect(row).toBeVisible()
|
||||||
|
await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
|
||||||
|
|
||||||
|
await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200)
|
||||||
|
const prev = await spot(page, hit.file)
|
||||||
|
if (!prev) throw new Error(`missing review row for ${hit.file}`)
|
||||||
|
|
||||||
|
await patch(sdk, session.id, edit(hit.file, hit.mark, next))
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||||
|
const item = diff.find((item) => item.file === hit.file)
|
||||||
|
return typeof item?.after === "string" ? item.after : ""
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toContain(`mark ${next}`)
|
||||||
|
|
||||||
|
await waitMark(page, hit.file, next)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const next = await spot(page, hit.file)
|
||||||
|
if (!next) return Number.POSITIVE_INFINITY
|
||||||
|
return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y))
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBeLessThanOrEqual(32)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openSettings, closeDialog, withSession } from "../actions"
|
import { openSettings, closeDialog, waitTerminalReady, withSession } from "../actions"
|
||||||
import { keybindButtonSelector, terminalSelector } from "../selectors"
|
import { keybindButtonSelector, terminalSelector } from "../selectors"
|
||||||
import { modKey } from "../utils"
|
import { modKey } from "../utils"
|
||||||
|
|
||||||
@@ -302,7 +302,7 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) =>
|
|||||||
await expect(terminal).not.toBeVisible()
|
await expect(terminal).not.toBeVisible()
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Y`)
|
await page.keyboard.press(`${modKey}+Y`)
|
||||||
await expect(terminal).toBeVisible()
|
await waitTerminalReady(page, { term: terminal })
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Y`)
|
await page.keyboard.press(`${modKey}+Y`)
|
||||||
await expect(terminal).not.toBeVisible()
|
await expect(terminal).not.toBeVisible()
|
||||||
|
|||||||
@@ -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,4 +1,5 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
|
import { waitTerminalReady } from "../actions"
|
||||||
import { promptSelector, terminalSelector } from "../selectors"
|
import { promptSelector, terminalSelector } from "../selectors"
|
||||||
import { terminalToggleKey } from "../utils"
|
import { terminalToggleKey } from "../utils"
|
||||||
|
|
||||||
@@ -13,8 +14,7 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
|
|||||||
await page.keyboard.press(terminalToggleKey)
|
await page.keyboard.press(terminalToggleKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
await expect(terminals.first()).toBeVisible()
|
await waitTerminalReady(page, { term: terminals.first() })
|
||||||
await expect(terminals.first().locator("textarea")).toHaveCount(1)
|
|
||||||
await expect(terminals).toHaveCount(1)
|
await expect(terminals).toHaveCount(1)
|
||||||
|
|
||||||
// Ghostty captures a lot of keybinds when focused; move focus back
|
// Ghostty captures a lot of keybinds when focused; move focus back
|
||||||
@@ -24,5 +24,5 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
|
|||||||
|
|
||||||
await expect(tabs).toHaveCount(2)
|
await expect(tabs).toHaveCount(2)
|
||||||
await expect(terminals).toHaveCount(1)
|
await expect(terminals).toHaveCount(1)
|
||||||
await expect(terminals.first().locator("textarea")).toHaveCount(1)
|
await waitTerminalReady(page, { term: terminals.first() })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Page } from "@playwright/test"
|
import type { Page } from "@playwright/test"
|
||||||
|
import { runTerminal, waitTerminalReady } from "../actions"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { terminalSelector } from "../selectors"
|
import { terminalSelector } from "../selectors"
|
||||||
import { terminalToggleKey, workspacePersistKey } from "../utils"
|
import { terminalToggleKey, workspacePersistKey } from "../utils"
|
||||||
@@ -17,16 +18,7 @@ async function open(page: Page) {
|
|||||||
const terminal = page.locator(terminalSelector)
|
const terminal = page.locator(terminalSelector)
|
||||||
const visible = await terminal.isVisible().catch(() => false)
|
const visible = await terminal.isVisible().catch(() => false)
|
||||||
if (!visible) await page.keyboard.press(terminalToggleKey)
|
if (!visible) await page.keyboard.press(terminalToggleKey)
|
||||||
await expect(terminal).toBeVisible()
|
await waitTerminalReady(page, { term: terminal })
|
||||||
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) {
|
async function store(page: Page, key: string) {
|
||||||
@@ -44,27 +36,27 @@ async function store(page: Page, key: string) {
|
|||||||
}, key)
|
}, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("terminal tab buffers persist across tab switches", async ({ page, withProject }) => {
|
test("inactive terminal tab buffers persist across tab switches", async ({ page, withProject }) => {
|
||||||
await withProject(async ({ directory, gotoSession }) => {
|
await withProject(async ({ directory, gotoSession }) => {
|
||||||
const key = workspacePersistKey(directory, "terminal")
|
const key = workspacePersistKey(directory, "terminal")
|
||||||
const one = `E2E_TERM_ONE_${Date.now()}`
|
const one = `E2E_TERM_ONE_${Date.now()}`
|
||||||
const two = `E2E_TERM_TWO_${Date.now()}`
|
const two = `E2E_TERM_TWO_${Date.now()}`
|
||||||
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
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 gotoSession()
|
||||||
await open(page)
|
await open(page)
|
||||||
|
|
||||||
await run(page, `echo ${one}`)
|
await runTerminal(page, { cmd: `echo ${one}`, token: one })
|
||||||
|
|
||||||
await page.getByRole("button", { name: /new terminal/i }).click()
|
await page.getByRole("button", { name: /new terminal/i }).click()
|
||||||
await expect(tabs).toHaveCount(2)
|
await expect(tabs).toHaveCount(2)
|
||||||
|
|
||||||
await run(page, `echo ${two}`)
|
await runTerminal(page, { cmd: `echo ${two}`, token: two })
|
||||||
|
|
||||||
await tabs
|
await first.click()
|
||||||
.filter({ hasText: /Terminal 1/ })
|
await expect(first).toHaveAttribute("aria-selected", "true")
|
||||||
.first()
|
|
||||||
.click()
|
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
@@ -72,11 +64,31 @@ test("terminal tab buffers persist across tab switches", async ({ page, withProj
|
|||||||
const state = await store(page, key)
|
const state = await store(page, key)
|
||||||
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
||||||
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
|
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
|
||||||
return first.includes(one) && second.includes(two)
|
return {
|
||||||
|
first: first.includes(one),
|
||||||
|
second: second.includes(two),
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 5_000 },
|
||||||
)
|
)
|
||||||
.toBe(true)
|
.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: 5_000 },
|
||||||
|
)
|
||||||
|
.toEqual({ first: true, second: false })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
|
import { waitTerminalReady } from "../actions"
|
||||||
import { terminalSelector } from "../selectors"
|
import { terminalSelector } from "../selectors"
|
||||||
import { terminalToggleKey } from "../utils"
|
import { terminalToggleKey } from "../utils"
|
||||||
|
|
||||||
@@ -13,5 +14,5 @@ test("terminal panel can be toggled", async ({ page, gotoSession }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await page.keyboard.press(terminalToggleKey)
|
await page.keyboard.press(terminalToggleKey)
|
||||||
await expect(terminal).toBeVisible()
|
await waitTerminalReady(page, { term: terminal })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
@@ -41,7 +57,7 @@ export function sessionPath(directory: string, sessionID?: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function workspacePersistKey(directory: string, key: string) {
|
export function workspacePersistKey(directory: string, key: string) {
|
||||||
const head = directory.slice(0, 12) || "workspace"
|
const head = (directory.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||||
const sum = checksum(directory) ?? "0"
|
const sum = checksum(directory) ?? "0"
|
||||||
return `opencode.workspace.${head}.${sum}.dat:workspace:${key}`
|
return `opencode.workspace.${head}.${sum}.dat:workspace:${key}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.2.20",
|
"version": "1.2.24",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -45,8 +45,8 @@
|
|||||||
"@shikijs/transformers": "3.9.2",
|
"@shikijs/transformers": "3.9.2",
|
||||||
"@solid-primitives/active-element": "2.1.3",
|
"@solid-primitives/active-element": "2.1.3",
|
||||||
"@solid-primitives/audio": "1.4.2",
|
"@solid-primitives/audio": "1.4.2",
|
||||||
"@solid-primitives/i18n": "2.2.1",
|
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
|
"@solid-primitives/i18n": "2.2.1",
|
||||||
"@solid-primitives/media": "2.3.3",
|
"@solid-primitives/media": "2.3.3",
|
||||||
"@solid-primitives/resize-observer": "2.1.3",
|
"@solid-primitives/resize-observer": "2.1.3",
|
||||||
"@solid-primitives/scroll": "2.1.3",
|
"@solid-primitives/scroll": "2.1.3",
|
||||||
@@ -56,6 +56,7 @@
|
|||||||
"@solidjs/router": "catalog:",
|
"@solidjs/router": "catalog:",
|
||||||
"@thisbeyond/solid-dnd": "0.7.5",
|
"@thisbeyond/solid-dnd": "0.7.5",
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
|
"effect": "4.0.0-beta.31",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
"ghostty-web": "github:anomalyco/ghostty-web#main",
|
"ghostty-web": "github:anomalyco/ghostty-web#main",
|
||||||
"luxon": "catalog:",
|
"luxon": "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"
|
||||||
|
|||||||
+126
-11
@@ -1,14 +1,29 @@
|
|||||||
import "@/index.css"
|
import "@/index.css"
|
||||||
import { File } from "@opencode-ai/ui/file"
|
|
||||||
import { I18nProvider } from "@opencode-ai/ui/context"
|
import { I18nProvider } from "@opencode-ai/ui/context"
|
||||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||||
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||||
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
||||||
|
import { File } from "@opencode-ai/ui/file"
|
||||||
import { Font } from "@opencode-ai/ui/font"
|
import { Font } from "@opencode-ai/ui/font"
|
||||||
|
import { Splash } from "@opencode-ai/ui/logo"
|
||||||
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
||||||
import { MetaProvider } from "@solidjs/meta"
|
import { MetaProvider } from "@solidjs/meta"
|
||||||
import { BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
|
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
|
||||||
import { Component, ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
|
import { type Duration, Effect } from "effect"
|
||||||
|
import {
|
||||||
|
type Component,
|
||||||
|
createResource,
|
||||||
|
createSignal,
|
||||||
|
ErrorBoundary,
|
||||||
|
For,
|
||||||
|
type JSX,
|
||||||
|
lazy,
|
||||||
|
onCleanup,
|
||||||
|
type ParentProps,
|
||||||
|
Show,
|
||||||
|
Suspense,
|
||||||
|
} from "solid-js"
|
||||||
|
import { Dynamic } from "solid-js/web"
|
||||||
import { CommandProvider } from "@/context/command"
|
import { CommandProvider } from "@/context/command"
|
||||||
import { CommentsProvider } from "@/context/comments"
|
import { CommentsProvider } from "@/context/comments"
|
||||||
import { FileProvider } from "@/context/file"
|
import { FileProvider } from "@/context/file"
|
||||||
@@ -22,13 +37,13 @@ import { NotificationProvider } from "@/context/notification"
|
|||||||
import { PermissionProvider } from "@/context/permission"
|
import { PermissionProvider } from "@/context/permission"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { PromptProvider } from "@/context/prompt"
|
import { PromptProvider } from "@/context/prompt"
|
||||||
import { type ServerConnection, ServerProvider, useServer } from "@/context/server"
|
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||||
import { SettingsProvider } from "@/context/settings"
|
import { SettingsProvider } from "@/context/settings"
|
||||||
import { TerminalProvider } from "@/context/terminal"
|
import { TerminalProvider } from "@/context/terminal"
|
||||||
import DirectoryLayout from "@/pages/directory-layout"
|
import DirectoryLayout from "@/pages/directory-layout"
|
||||||
import Layout from "@/pages/layout"
|
import Layout from "@/pages/layout"
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { useCheckServerHealth } from "./utils/server-health"
|
||||||
|
|
||||||
const Home = lazy(() => import("@/pages/home"))
|
const Home = lazy(() => import("@/pages/home"))
|
||||||
const Session = lazy(() => import("@/pages/session"))
|
const Session = lazy(() => import("@/pages/session"))
|
||||||
@@ -62,6 +77,9 @@ declare global {
|
|||||||
deepLinks?: string[]
|
deepLinks?: string[]
|
||||||
wsl?: boolean
|
wsl?: boolean
|
||||||
}
|
}
|
||||||
|
api?: {
|
||||||
|
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +133,11 @@ export function AppBaseProviders(props: ParentProps) {
|
|||||||
return (
|
return (
|
||||||
<MetaProvider>
|
<MetaProvider>
|
||||||
<Font />
|
<Font />
|
||||||
<ThemeProvider>
|
<ThemeProvider
|
||||||
|
onThemeApplied={(_, mode) => {
|
||||||
|
void window.api?.setTitlebar?.({ mode })
|
||||||
|
}}
|
||||||
|
>
|
||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<UiI18nBridge>
|
<UiI18nBridge>
|
||||||
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
||||||
@@ -132,15 +154,108 @@ export function AppBaseProviders(props: ParentProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ServerKey(props: ParentProps) {
|
const effectMinDuration =
|
||||||
|
(duration: Duration.Input) =>
|
||||||
|
<A, E, R>(e: Effect.Effect<A, E, R>) =>
|
||||||
|
Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0]))
|
||||||
|
|
||||||
|
function ConnectionGate(props: ParentProps) {
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
|
const checkServerHealth = useCheckServerHealth()
|
||||||
|
|
||||||
|
const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking")
|
||||||
|
|
||||||
|
// performs repeated health check with a grace period for
|
||||||
|
// non-http connections, otherwise fails instantly
|
||||||
|
const [startupHealthCheck, healthCheckActions] = createResource(() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (!server.current) return true
|
||||||
|
const { http, type } = server.current
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const res = yield* Effect.promise(() => checkServerHealth(http))
|
||||||
|
if (res.healthy) return true
|
||||||
|
if (checkMode() === "background" || type === "http") return false
|
||||||
|
}
|
||||||
|
}).pipe(
|
||||||
|
effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
|
||||||
|
Effect.timeoutOrElse({ duration: "10 seconds", onTimeout: () => Effect.succeed(false) }),
|
||||||
|
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
|
||||||
|
Effect.runPromise,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={server.key} keyed>
|
<Show
|
||||||
{props.children}
|
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
|
||||||
|
fallback={
|
||||||
|
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
|
||||||
|
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={startupHealthCheck()}
|
||||||
|
fallback={
|
||||||
|
<ConnectionError
|
||||||
|
onRetry={() => {
|
||||||
|
if (checkMode() === "background") healthCheckActions.refetch()
|
||||||
|
}}
|
||||||
|
onServerSelected={(key) => {
|
||||||
|
setCheckMode("blocking")
|
||||||
|
server.setActive(key)
|
||||||
|
healthCheckActions.refetch()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) {
|
||||||
|
const server = useServer()
|
||||||
|
const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key)
|
||||||
|
|
||||||
|
const timer = setInterval(() => props.onRetry?.(), 1000)
|
||||||
|
onCleanup(() => clearInterval(timer))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base gap-6 p-6">
|
||||||
|
<div class="flex flex-col items-center max-w-md text-center">
|
||||||
|
<Splash class="w-12 h-15 mb-4" />
|
||||||
|
<p class="text-14-regular text-text-base">
|
||||||
|
Could not reach <span class="text-text-strong font-medium">{server.name || server.key}</span>
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-12-regular text-text-weak">Retrying automatically...</p>
|
||||||
|
</div>
|
||||||
|
<Show when={others().length > 0}>
|
||||||
|
<div class="flex flex-col gap-2 w-full max-w-sm">
|
||||||
|
<span class="text-12-regular text-text-base text-center">Other servers</span>
|
||||||
|
<div class="flex flex-col gap-1 bg-surface-base rounded-lg p-2">
|
||||||
|
<For each={others()}>
|
||||||
|
{(conn) => {
|
||||||
|
const key = ServerConnection.key(conn)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-3 w-full px-3 py-2 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
||||||
|
onClick={() => props.onServerSelected?.(key)}
|
||||||
|
>
|
||||||
|
<span class="text-14-regular text-text-strong truncate">{serverName(conn)}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function AppInterface(props: {
|
export function AppInterface(props: {
|
||||||
children?: JSX.Element
|
children?: JSX.Element
|
||||||
defaultServer: ServerConnection.Key
|
defaultServer: ServerConnection.Key
|
||||||
@@ -149,7 +264,7 @@ export function AppInterface(props: {
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
|
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
|
||||||
<ServerKey>
|
<ConnectionGate>
|
||||||
<GlobalSDKProvider>
|
<GlobalSDKProvider>
|
||||||
<GlobalSyncProvider>
|
<GlobalSyncProvider>
|
||||||
<Dynamic
|
<Dynamic
|
||||||
@@ -164,7 +279,7 @@ export function AppInterface(props: {
|
|||||||
</Dynamic>
|
</Dynamic>
|
||||||
</GlobalSyncProvider>
|
</GlobalSyncProvider>
|
||||||
</GlobalSDKProvider>
|
</GlobalSDKProvider>
|
||||||
</ServerKey>
|
</ConnectionGate>
|
||||||
</ServerProvider>
|
</ServerProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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="FRAME"
|
||||||
|
tip="Worst frame time over the last 5 seconds."
|
||||||
|
value={time(state.gap)}
|
||||||
|
bad={bad(state.gap, 50)}
|
||||||
|
dim={state.gap === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label="JANK"
|
||||||
|
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="LONG"
|
||||||
|
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="DELAY"
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
|
||||||
|
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
|
||||||
|
|
||||||
|
type Translator = (key: string, vars?: Record<string, string | number | boolean>) => string
|
||||||
|
|
||||||
|
export type ModelErr = {
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HeaderErr = {
|
||||||
|
key?: string
|
||||||
|
value?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModelRow = {
|
||||||
|
row: string
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
err: ModelErr
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HeaderRow = {
|
||||||
|
row: string
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
err: HeaderErr
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FormState = {
|
||||||
|
providerID: string
|
||||||
|
name: string
|
||||||
|
baseURL: string
|
||||||
|
apiKey: string
|
||||||
|
models: ModelRow[]
|
||||||
|
headers: HeaderRow[]
|
||||||
|
saving: boolean
|
||||||
|
err: {
|
||||||
|
providerID?: string
|
||||||
|
name?: string
|
||||||
|
baseURL?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ValidateArgs = {
|
||||||
|
form: FormState
|
||||||
|
t: Translator
|
||||||
|
disabledProviders: string[]
|
||||||
|
existingProviderIDs: Set<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateCustomProvider(input: ValidateArgs) {
|
||||||
|
const providerID = input.form.providerID.trim()
|
||||||
|
const name = input.form.name.trim()
|
||||||
|
const baseURL = input.form.baseURL.trim()
|
||||||
|
const apiKey = input.form.apiKey.trim()
|
||||||
|
|
||||||
|
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
|
||||||
|
const key = apiKey && !env ? apiKey : undefined
|
||||||
|
|
||||||
|
const idError = !providerID
|
||||||
|
? input.t("provider.custom.error.providerID.required")
|
||||||
|
: !PROVIDER_ID.test(providerID)
|
||||||
|
? input.t("provider.custom.error.providerID.format")
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
|
||||||
|
const urlError = !baseURL
|
||||||
|
? input.t("provider.custom.error.baseURL.required")
|
||||||
|
: !/^https?:\/\//.test(baseURL)
|
||||||
|
? input.t("provider.custom.error.baseURL.format")
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const disabled = input.disabledProviders.includes(providerID)
|
||||||
|
const existsError = idError
|
||||||
|
? undefined
|
||||||
|
: input.existingProviderIDs.has(providerID) && !disabled
|
||||||
|
? input.t("provider.custom.error.providerID.exists")
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const seenModels = new Set<string>()
|
||||||
|
const models = input.form.models.map((m) => {
|
||||||
|
const id = m.id.trim()
|
||||||
|
const idError = !id
|
||||||
|
? input.t("provider.custom.error.required")
|
||||||
|
: seenModels.has(id)
|
||||||
|
? input.t("provider.custom.error.duplicate")
|
||||||
|
: (() => {
|
||||||
|
seenModels.add(id)
|
||||||
|
return undefined
|
||||||
|
})()
|
||||||
|
const nameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
|
||||||
|
return { id: idError, name: nameError }
|
||||||
|
})
|
||||||
|
const modelsValid = models.every((m) => !m.id && !m.name)
|
||||||
|
const modelConfig = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
|
||||||
|
|
||||||
|
const seenHeaders = new Set<string>()
|
||||||
|
const headers = input.form.headers.map((h) => {
|
||||||
|
const key = h.key.trim()
|
||||||
|
const value = h.value.trim()
|
||||||
|
|
||||||
|
if (!key && !value) return {}
|
||||||
|
const keyError = !key
|
||||||
|
? input.t("provider.custom.error.required")
|
||||||
|
: seenHeaders.has(key.toLowerCase())
|
||||||
|
? input.t("provider.custom.error.duplicate")
|
||||||
|
: (() => {
|
||||||
|
seenHeaders.add(key.toLowerCase())
|
||||||
|
return undefined
|
||||||
|
})()
|
||||||
|
const valueError = !value ? input.t("provider.custom.error.required") : undefined
|
||||||
|
return { key: keyError, value: valueError }
|
||||||
|
})
|
||||||
|
const headersValid = headers.every((h) => !h.key && !h.value)
|
||||||
|
const headerConfig = Object.fromEntries(
|
||||||
|
input.form.headers
|
||||||
|
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
|
||||||
|
.filter((h) => !!h.key && !!h.value)
|
||||||
|
.map((h) => [h.key, h.value]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const err = {
|
||||||
|
providerID: idError ?? existsError,
|
||||||
|
name: nameError,
|
||||||
|
baseURL: urlError,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
|
||||||
|
if (!ok) return { err, models, headers }
|
||||||
|
|
||||||
|
return {
|
||||||
|
err,
|
||||||
|
models,
|
||||||
|
headers,
|
||||||
|
result: {
|
||||||
|
providerID,
|
||||||
|
name,
|
||||||
|
key,
|
||||||
|
config: {
|
||||||
|
npm: OPENAI_COMPATIBLE,
|
||||||
|
name,
|
||||||
|
...(env ? { env: [env] } : {}),
|
||||||
|
options: {
|
||||||
|
baseURL,
|
||||||
|
...(Object.keys(headerConfig).length ? { headers: headerConfig } : {}),
|
||||||
|
},
|
||||||
|
models: modelConfig,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let row = 0
|
||||||
|
|
||||||
|
const nextRow = () => `row-${row++}`
|
||||||
|
|
||||||
|
export const modelRow = (): ModelRow => ({ row: nextRow(), id: "", name: "", err: {} })
|
||||||
|
export const headerRow = (): HeaderRow => ({ row: nextRow(), key: "", value: "", err: {} })
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { validateCustomProvider } from "./dialog-custom-provider-form"
|
||||||
|
|
||||||
|
const t = (key: string) => key
|
||||||
|
|
||||||
|
describe("validateCustomProvider", () => {
|
||||||
|
test("builds trimmed config payload", () => {
|
||||||
|
const result = validateCustomProvider({
|
||||||
|
form: {
|
||||||
|
providerID: "custom-provider",
|
||||||
|
name: " Custom Provider ",
|
||||||
|
baseURL: "https://api.example.com ",
|
||||||
|
apiKey: " {env: CUSTOM_PROVIDER_KEY} ",
|
||||||
|
models: [{ row: "m0", id: " model-a ", name: " Model A ", err: {} }],
|
||||||
|
headers: [
|
||||||
|
{ row: "h0", key: " X-Test ", value: " enabled ", err: {} },
|
||||||
|
{ row: "h1", key: "", value: "", err: {} },
|
||||||
|
],
|
||||||
|
saving: false,
|
||||||
|
err: {},
|
||||||
|
},
|
||||||
|
t,
|
||||||
|
disabledProviders: [],
|
||||||
|
existingProviderIDs: new Set(),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.result).toEqual({
|
||||||
|
providerID: "custom-provider",
|
||||||
|
name: "Custom Provider",
|
||||||
|
key: undefined,
|
||||||
|
config: {
|
||||||
|
npm: "@ai-sdk/openai-compatible",
|
||||||
|
name: "Custom Provider",
|
||||||
|
env: ["CUSTOM_PROVIDER_KEY"],
|
||||||
|
options: {
|
||||||
|
baseURL: "https://api.example.com",
|
||||||
|
headers: {
|
||||||
|
"X-Test": "enabled",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
models: {
|
||||||
|
"model-a": { name: "Model A" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("flags duplicate rows and allows reconnecting disabled providers", () => {
|
||||||
|
const result = validateCustomProvider({
|
||||||
|
form: {
|
||||||
|
providerID: "custom-provider",
|
||||||
|
name: "Provider",
|
||||||
|
baseURL: "https://api.example.com",
|
||||||
|
apiKey: "secret",
|
||||||
|
models: [
|
||||||
|
{ row: "m0", id: "model-a", name: "Model A", err: {} },
|
||||||
|
{ row: "m1", id: "model-a", name: "Model A 2", err: {} },
|
||||||
|
],
|
||||||
|
headers: [
|
||||||
|
{ row: "h0", key: "Authorization", value: "one", err: {} },
|
||||||
|
{ row: "h1", key: "authorization", value: "two", err: {} },
|
||||||
|
],
|
||||||
|
saving: false,
|
||||||
|
err: {},
|
||||||
|
},
|
||||||
|
t,
|
||||||
|
disabledProviders: ["custom-provider"],
|
||||||
|
existingProviderIDs: new Set(["custom-provider"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.result).toBeUndefined()
|
||||||
|
expect(result.err.providerID).toBeUndefined()
|
||||||
|
expect(result.models[1]).toEqual({
|
||||||
|
id: "provider.custom.error.duplicate",
|
||||||
|
name: undefined,
|
||||||
|
})
|
||||||
|
expect(result.headers[1]).toEqual({
|
||||||
|
key: "provider.custom.error.duplicate",
|
||||||
|
value: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,158 +5,15 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
|||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { For } from "solid-js"
|
import { batch, For } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { Link } from "@/components/link"
|
import { Link } from "@/components/link"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
|
||||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||||
|
|
||||||
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
|
|
||||||
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
|
|
||||||
|
|
||||||
type Translator = ReturnType<typeof useLanguage>["t"]
|
|
||||||
|
|
||||||
type ModelRow = {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type HeaderRow = {
|
|
||||||
key: string
|
|
||||||
value: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type FormState = {
|
|
||||||
providerID: string
|
|
||||||
name: string
|
|
||||||
baseURL: string
|
|
||||||
apiKey: string
|
|
||||||
models: ModelRow[]
|
|
||||||
headers: HeaderRow[]
|
|
||||||
saving: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type FormErrors = {
|
|
||||||
providerID: string | undefined
|
|
||||||
name: string | undefined
|
|
||||||
baseURL: string | undefined
|
|
||||||
models: Array<{ id?: string; name?: string }>
|
|
||||||
headers: Array<{ key?: string; value?: string }>
|
|
||||||
}
|
|
||||||
|
|
||||||
type ValidateArgs = {
|
|
||||||
form: FormState
|
|
||||||
t: Translator
|
|
||||||
disabledProviders: string[]
|
|
||||||
existingProviderIDs: Set<string>
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateCustomProvider(input: ValidateArgs) {
|
|
||||||
const providerID = input.form.providerID.trim()
|
|
||||||
const name = input.form.name.trim()
|
|
||||||
const baseURL = input.form.baseURL.trim()
|
|
||||||
const apiKey = input.form.apiKey.trim()
|
|
||||||
|
|
||||||
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
|
|
||||||
const key = apiKey && !env ? apiKey : undefined
|
|
||||||
|
|
||||||
const idError = !providerID
|
|
||||||
? input.t("provider.custom.error.providerID.required")
|
|
||||||
: !PROVIDER_ID.test(providerID)
|
|
||||||
? input.t("provider.custom.error.providerID.format")
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
|
|
||||||
const urlError = !baseURL
|
|
||||||
? input.t("provider.custom.error.baseURL.required")
|
|
||||||
: !/^https?:\/\//.test(baseURL)
|
|
||||||
? input.t("provider.custom.error.baseURL.format")
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const disabled = input.disabledProviders.includes(providerID)
|
|
||||||
const existsError = idError
|
|
||||||
? undefined
|
|
||||||
: input.existingProviderIDs.has(providerID) && !disabled
|
|
||||||
? input.t("provider.custom.error.providerID.exists")
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const seenModels = new Set<string>()
|
|
||||||
const modelErrors = input.form.models.map((m) => {
|
|
||||||
const id = m.id.trim()
|
|
||||||
const modelIdError = !id
|
|
||||||
? input.t("provider.custom.error.required")
|
|
||||||
: seenModels.has(id)
|
|
||||||
? input.t("provider.custom.error.duplicate")
|
|
||||||
: (() => {
|
|
||||||
seenModels.add(id)
|
|
||||||
return undefined
|
|
||||||
})()
|
|
||||||
const modelNameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
|
|
||||||
return { id: modelIdError, name: modelNameError }
|
|
||||||
})
|
|
||||||
const modelsValid = modelErrors.every((m) => !m.id && !m.name)
|
|
||||||
const models = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
|
|
||||||
|
|
||||||
const seenHeaders = new Set<string>()
|
|
||||||
const headerErrors = input.form.headers.map((h) => {
|
|
||||||
const key = h.key.trim()
|
|
||||||
const value = h.value.trim()
|
|
||||||
|
|
||||||
if (!key && !value) return {}
|
|
||||||
const keyError = !key
|
|
||||||
? input.t("provider.custom.error.required")
|
|
||||||
: seenHeaders.has(key.toLowerCase())
|
|
||||||
? input.t("provider.custom.error.duplicate")
|
|
||||||
: (() => {
|
|
||||||
seenHeaders.add(key.toLowerCase())
|
|
||||||
return undefined
|
|
||||||
})()
|
|
||||||
const valueError = !value ? input.t("provider.custom.error.required") : undefined
|
|
||||||
return { key: keyError, value: valueError }
|
|
||||||
})
|
|
||||||
const headersValid = headerErrors.every((h) => !h.key && !h.value)
|
|
||||||
const headers = Object.fromEntries(
|
|
||||||
input.form.headers
|
|
||||||
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
|
|
||||||
.filter((h) => !!h.key && !!h.value)
|
|
||||||
.map((h) => [h.key, h.value]),
|
|
||||||
)
|
|
||||||
|
|
||||||
const errors: FormErrors = {
|
|
||||||
providerID: idError ?? existsError,
|
|
||||||
name: nameError,
|
|
||||||
baseURL: urlError,
|
|
||||||
models: modelErrors,
|
|
||||||
headers: headerErrors,
|
|
||||||
}
|
|
||||||
|
|
||||||
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
|
|
||||||
if (!ok) return { errors }
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
baseURL,
|
|
||||||
...(Object.keys(headers).length ? { headers } : {}),
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
errors,
|
|
||||||
result: {
|
|
||||||
providerID,
|
|
||||||
name,
|
|
||||||
key,
|
|
||||||
config: {
|
|
||||||
npm: OPENAI_COMPATIBLE,
|
|
||||||
name,
|
|
||||||
...(env ? { env: [env] } : {}),
|
|
||||||
options,
|
|
||||||
models,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
back?: "providers" | "close"
|
back?: "providers" | "close"
|
||||||
}
|
}
|
||||||
@@ -172,17 +29,10 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
name: "",
|
name: "",
|
||||||
baseURL: "",
|
baseURL: "",
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
models: [{ id: "", name: "" }],
|
models: [modelRow()],
|
||||||
headers: [{ key: "", value: "" }],
|
headers: [headerRow()],
|
||||||
saving: false,
|
saving: false,
|
||||||
})
|
err: {},
|
||||||
|
|
||||||
const [errors, setErrors] = createStore<FormErrors>({
|
|
||||||
providerID: undefined,
|
|
||||||
name: undefined,
|
|
||||||
baseURL: undefined,
|
|
||||||
models: [{}],
|
|
||||||
headers: [{}],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
@@ -194,25 +44,61 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const addModel = () => {
|
const addModel = () => {
|
||||||
setForm("models", (v) => [...v, { id: "", name: "" }])
|
setForm(
|
||||||
setErrors("models", (v) => [...v, {}])
|
"models",
|
||||||
|
produce((rows) => {
|
||||||
|
rows.push(modelRow())
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeModel = (index: number) => {
|
const removeModel = (index: number) => {
|
||||||
if (form.models.length <= 1) return
|
if (form.models.length <= 1) return
|
||||||
setForm("models", (v) => v.filter((_, i) => i !== index))
|
setForm(
|
||||||
setErrors("models", (v) => v.filter((_, i) => i !== index))
|
"models",
|
||||||
|
produce((rows) => {
|
||||||
|
rows.splice(index, 1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const addHeader = () => {
|
const addHeader = () => {
|
||||||
setForm("headers", (v) => [...v, { key: "", value: "" }])
|
setForm(
|
||||||
setErrors("headers", (v) => [...v, {}])
|
"headers",
|
||||||
|
produce((rows) => {
|
||||||
|
rows.push(headerRow())
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeHeader = (index: number) => {
|
const removeHeader = (index: number) => {
|
||||||
if (form.headers.length <= 1) return
|
if (form.headers.length <= 1) return
|
||||||
setForm("headers", (v) => v.filter((_, i) => i !== index))
|
setForm(
|
||||||
setErrors("headers", (v) => v.filter((_, i) => i !== index))
|
"headers",
|
||||||
|
produce((rows) => {
|
||||||
|
rows.splice(index, 1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setField = (key: "providerID" | "name" | "baseURL" | "apiKey", value: string) => {
|
||||||
|
setForm(key, value)
|
||||||
|
if (key === "apiKey") return
|
||||||
|
setForm("err", key, undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setModel = (index: number, key: "id" | "name", value: string) => {
|
||||||
|
batch(() => {
|
||||||
|
setForm("models", index, key, value)
|
||||||
|
setForm("models", index, "err", key, undefined)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const setHeader = (index: number, key: "key" | "value", value: string) => {
|
||||||
|
batch(() => {
|
||||||
|
setForm("headers", index, key, value)
|
||||||
|
setForm("headers", index, "err", key, undefined)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
@@ -222,7 +108,11 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
disabledProviders: globalSync.data.config.disabled_providers ?? [],
|
disabledProviders: globalSync.data.config.disabled_providers ?? [],
|
||||||
existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
|
existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
|
||||||
})
|
})
|
||||||
setErrors(output.errors)
|
batch(() => {
|
||||||
|
setForm("err", output.err)
|
||||||
|
output.models.forEach((err, index) => setForm("models", index, "err", err))
|
||||||
|
output.headers.forEach((err, index) => setForm("headers", index, "err", err))
|
||||||
|
})
|
||||||
return output.result
|
return output.result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,32 +195,32 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
||||||
description={language.t("provider.custom.field.providerID.description")}
|
description={language.t("provider.custom.field.providerID.description")}
|
||||||
value={form.providerID}
|
value={form.providerID}
|
||||||
onChange={(v) => setForm("providerID", v)}
|
onChange={(v) => setField("providerID", v)}
|
||||||
validationState={errors.providerID ? "invalid" : undefined}
|
validationState={form.err.providerID ? "invalid" : undefined}
|
||||||
error={errors.providerID}
|
error={form.err.providerID}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label={language.t("provider.custom.field.name.label")}
|
label={language.t("provider.custom.field.name.label")}
|
||||||
placeholder={language.t("provider.custom.field.name.placeholder")}
|
placeholder={language.t("provider.custom.field.name.placeholder")}
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(v) => setForm("name", v)}
|
onChange={(v) => setField("name", v)}
|
||||||
validationState={errors.name ? "invalid" : undefined}
|
validationState={form.err.name ? "invalid" : undefined}
|
||||||
error={errors.name}
|
error={form.err.name}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label={language.t("provider.custom.field.baseURL.label")}
|
label={language.t("provider.custom.field.baseURL.label")}
|
||||||
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
|
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
|
||||||
value={form.baseURL}
|
value={form.baseURL}
|
||||||
onChange={(v) => setForm("baseURL", v)}
|
onChange={(v) => setField("baseURL", v)}
|
||||||
validationState={errors.baseURL ? "invalid" : undefined}
|
validationState={form.err.baseURL ? "invalid" : undefined}
|
||||||
error={errors.baseURL}
|
error={form.err.baseURL}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label={language.t("provider.custom.field.apiKey.label")}
|
label={language.t("provider.custom.field.apiKey.label")}
|
||||||
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
|
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
|
||||||
description={language.t("provider.custom.field.apiKey.description")}
|
description={language.t("provider.custom.field.apiKey.description")}
|
||||||
value={form.apiKey}
|
value={form.apiKey}
|
||||||
onChange={(v) => setForm("apiKey", v)}
|
onChange={(v) => setField("apiKey", v)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -338,16 +228,16 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
<label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
|
<label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
|
||||||
<For each={form.models}>
|
<For each={form.models}>
|
||||||
{(m, i) => (
|
{(m, i) => (
|
||||||
<div class="flex gap-2 items-start">
|
<div class="flex gap-2 items-start" data-row={m.row}>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<TextField
|
<TextField
|
||||||
label={language.t("provider.custom.models.id.label")}
|
label={language.t("provider.custom.models.id.label")}
|
||||||
hideLabel
|
hideLabel
|
||||||
placeholder={language.t("provider.custom.models.id.placeholder")}
|
placeholder={language.t("provider.custom.models.id.placeholder")}
|
||||||
value={m.id}
|
value={m.id}
|
||||||
onChange={(v) => setForm("models", i(), "id", v)}
|
onChange={(v) => setModel(i(), "id", v)}
|
||||||
validationState={errors.models[i()]?.id ? "invalid" : undefined}
|
validationState={m.err.id ? "invalid" : undefined}
|
||||||
error={errors.models[i()]?.id}
|
error={m.err.id}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@@ -356,9 +246,9 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
hideLabel
|
hideLabel
|
||||||
placeholder={language.t("provider.custom.models.name.placeholder")}
|
placeholder={language.t("provider.custom.models.name.placeholder")}
|
||||||
value={m.name}
|
value={m.name}
|
||||||
onChange={(v) => setForm("models", i(), "name", v)}
|
onChange={(v) => setModel(i(), "name", v)}
|
||||||
validationState={errors.models[i()]?.name ? "invalid" : undefined}
|
validationState={m.err.name ? "invalid" : undefined}
|
||||||
error={errors.models[i()]?.name}
|
error={m.err.name}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -382,16 +272,16 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
<label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
|
<label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
|
||||||
<For each={form.headers}>
|
<For each={form.headers}>
|
||||||
{(h, i) => (
|
{(h, i) => (
|
||||||
<div class="flex gap-2 items-start">
|
<div class="flex gap-2 items-start" data-row={h.row}>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<TextField
|
<TextField
|
||||||
label={language.t("provider.custom.headers.key.label")}
|
label={language.t("provider.custom.headers.key.label")}
|
||||||
hideLabel
|
hideLabel
|
||||||
placeholder={language.t("provider.custom.headers.key.placeholder")}
|
placeholder={language.t("provider.custom.headers.key.placeholder")}
|
||||||
value={h.key}
|
value={h.key}
|
||||||
onChange={(v) => setForm("headers", i(), "key", v)}
|
onChange={(v) => setHeader(i(), "key", v)}
|
||||||
validationState={errors.headers[i()]?.key ? "invalid" : undefined}
|
validationState={h.err.key ? "invalid" : undefined}
|
||||||
error={errors.headers[i()]?.key}
|
error={h.err.key}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@@ -400,9 +290,9 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
hideLabel
|
hideLabel
|
||||||
placeholder={language.t("provider.custom.headers.value.placeholder")}
|
placeholder={language.t("provider.custom.headers.value.placeholder")}
|
||||||
value={h.value}
|
value={h.value}
|
||||||
onChange={(v) => setForm("headers", i(), "value", v)}
|
onChange={(v) => setHeader(i(), "value", v)}
|
||||||
validationState={errors.headers[i()]?.value ? "invalid" : undefined}
|
validationState={h.err.value ? "invalid" : undefined}
|
||||||
error={errors.headers[i()]?.value}
|
error={h.err.value}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Keybind } from "@opencode-ai/ui/keybind"
|
|||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { base64Encode } from "@opencode-ai/util/encode"
|
import { base64Encode } from "@opencode-ai/util/encode"
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||||
import { useNavigate, useParams } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
|
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
|
||||||
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
|
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
@@ -14,6 +14,8 @@ import { useGlobalSync } from "@/context/global-sync"
|
|||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useFile } from "@/context/file"
|
import { useFile } from "@/context/file"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { getRelativeTime } from "@/utils/time"
|
import { getRelativeTime } from "@/utils/time"
|
||||||
|
|
||||||
@@ -132,9 +134,14 @@ function createFileEntries(props: {
|
|||||||
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
|
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
|
||||||
language: ReturnType<typeof useLanguage>
|
language: ReturnType<typeof useLanguage>
|
||||||
}) {
|
}) {
|
||||||
|
const tabState = createSessionTabs({
|
||||||
|
tabs: props.tabs,
|
||||||
|
pathFromTab: props.file.pathFromTab,
|
||||||
|
normalizeTab: (tab) => (tab.startsWith("file://") ? props.file.tab(tab) : tab),
|
||||||
|
})
|
||||||
const recent = createMemo(() => {
|
const recent = createMemo(() => {
|
||||||
const all = props.tabs().all()
|
const all = tabState.openedTabs()
|
||||||
const active = props.tabs().active()
|
const active = tabState.activeFileTab()
|
||||||
const order = active ? [active, ...all.filter((item) => item !== active)] : all
|
const order = active ? [active, ...all.filter((item) => item !== active)] : all
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const category = props.language.t("palette.group.files")
|
const category = props.language.t("palette.group.files")
|
||||||
@@ -259,14 +266,11 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const params = useParams()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
const globalSync = useGlobalSync()
|
const globalSync = useGlobalSync()
|
||||||
|
const { params, tabs, view } = useSessionLayout()
|
||||||
const filesOnly = () => props.mode === "files"
|
const filesOnly = () => props.mode === "files"
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
|
||||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
|
||||||
const view = createMemo(() => layout.view(sessionKey))
|
|
||||||
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
||||||
const [grouped, setGrouped] = createSignal(false)
|
const [grouped, setGrouped] = createSignal(false)
|
||||||
const commandEntries = createCommandEntries({ filesOnly, command, language })
|
const commandEntries = createCommandEntries({ filesOnly, command, language })
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||||
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
|
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
||||||
|
|
||||||
|
const DEFAULT_USERNAME = "opencode"
|
||||||
|
|
||||||
interface ServerFormProps {
|
interface ServerFormProps {
|
||||||
value: string
|
value: string
|
||||||
@@ -41,13 +43,15 @@ function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: ReturnType<typeof useLanguage>) {
|
function useDefaultServer() {
|
||||||
const [defaultUrl, defaultUrlActions] = createResource(
|
const language = useLanguage()
|
||||||
|
const platform = usePlatform()
|
||||||
|
const [defaultKey, defaultUrlActions] = createResource(
|
||||||
async () => {
|
async () => {
|
||||||
try {
|
try {
|
||||||
const url = await platform.getDefaultServerUrl?.()
|
const key = await platform.getDefaultServer?.()
|
||||||
if (!url) return null
|
if (!key) return null
|
||||||
return normalizeServerUrl(url) ?? null
|
return key
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showRequestError(language, err)
|
showRequestError(language, err)
|
||||||
return null
|
return null
|
||||||
@@ -56,20 +60,22 @@ function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: Re
|
|||||||
{ initialValue: null },
|
{ initialValue: null },
|
||||||
)
|
)
|
||||||
|
|
||||||
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
|
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
|
||||||
const setDefault = async (url: string | null) => {
|
const setDefault = async (key: ServerConnection.Key | null) => {
|
||||||
try {
|
try {
|
||||||
await platform.setDefaultServerUrl?.(url)
|
await platform.setDefaultServer?.(key)
|
||||||
defaultUrlActions.mutate(url)
|
defaultUrlActions.mutate(key)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showRequestError(language, err)
|
showRequestError(language, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { defaultUrl, canDefault, setDefault }
|
return { defaultKey, canDefault, setDefault }
|
||||||
}
|
}
|
||||||
|
|
||||||
function useServerPreview(fetcher: typeof fetch) {
|
function useServerPreview() {
|
||||||
|
const checkServerHealth = useCheckServerHealth()
|
||||||
|
|
||||||
const looksComplete = (value: string) => {
|
const looksComplete = (value: string) => {
|
||||||
const normalized = normalizeServerUrl(value)
|
const normalized = normalizeServerUrl(value)
|
||||||
if (!normalized) return false
|
if (!normalized) return false
|
||||||
@@ -92,7 +98,7 @@ function useServerPreview(fetcher: typeof fetch) {
|
|||||||
const http: ServerConnection.HttpBase = { url: normalized }
|
const http: ServerConnection.HttpBase = { url: normalized }
|
||||||
if (username) http.username = username
|
if (username) http.username = username
|
||||||
if (password) http.password = password
|
if (password) http.password = password
|
||||||
const result = await checkServerHealth(http, fetcher)
|
const result = await checkServerHealth(http)
|
||||||
setStatus(result.healthy)
|
setStatus(result.healthy)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,15 +176,15 @@ export function DialogSelectServer() {
|
|||||||
const server = useServer()
|
const server = useServer()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const fetcher = platform.fetch ?? globalThis.fetch
|
const { defaultKey, canDefault, setDefault } = useDefaultServer()
|
||||||
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
|
const { previewStatus } = useServerPreview()
|
||||||
const { previewStatus } = useServerPreview(fetcher)
|
const checkServerHealth = useCheckServerHealth()
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
|
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
|
||||||
addServer: {
|
addServer: {
|
||||||
url: "",
|
url: "",
|
||||||
name: "",
|
name: "",
|
||||||
username: "",
|
username: DEFAULT_USERNAME,
|
||||||
password: "",
|
password: "",
|
||||||
adding: false,
|
adding: false,
|
||||||
error: "",
|
error: "",
|
||||||
@@ -201,7 +207,7 @@ export function DialogSelectServer() {
|
|||||||
setStore("addServer", {
|
setStore("addServer", {
|
||||||
url: "",
|
url: "",
|
||||||
name: "",
|
name: "",
|
||||||
username: "",
|
username: DEFAULT_USERNAME,
|
||||||
password: "",
|
password: "",
|
||||||
adding: false,
|
adding: false,
|
||||||
error: "",
|
error: "",
|
||||||
@@ -264,7 +270,7 @@ export function DialogSelectServer() {
|
|||||||
const results: Record<ServerConnection.Key, ServerHealth> = {}
|
const results: Record<ServerConnection.Key, ServerHealth> = {}
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
items().map(async (conn) => {
|
items().map(async (conn) => {
|
||||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
|
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
setStore("status", reconcile(results))
|
setStore("status", reconcile(results))
|
||||||
@@ -362,9 +368,9 @@ export function DialogSelectServer() {
|
|||||||
http: { url: normalized },
|
http: { url: normalized },
|
||||||
}
|
}
|
||||||
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
|
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
|
||||||
if (store.addServer.username) conn.http.username = store.addServer.username
|
|
||||||
if (store.addServer.password) conn.http.password = store.addServer.password
|
if (store.addServer.password) conn.http.password = store.addServer.password
|
||||||
const result = await checkServerHealth(conn.http, fetcher)
|
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
|
||||||
|
const result = await checkServerHealth(conn.http)
|
||||||
setStore("addServer", { adding: false })
|
setStore("addServer", { adding: false })
|
||||||
if (!result.healthy) {
|
if (!result.healthy) {
|
||||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||||
@@ -404,7 +410,7 @@ export function DialogSelectServer() {
|
|||||||
displayName: name,
|
displayName: name,
|
||||||
http: { url: normalized, username, password },
|
http: { url: normalized, username, password },
|
||||||
}
|
}
|
||||||
const result = await checkServerHealth(conn.http, fetcher)
|
const result = await checkServerHealth(conn.http)
|
||||||
setStore("editServer", { busy: false })
|
setStore("editServer", { busy: false })
|
||||||
if (!result.healthy) {
|
if (!result.healthy) {
|
||||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||||
@@ -441,7 +447,7 @@ export function DialogSelectServer() {
|
|||||||
showForm: true,
|
showForm: true,
|
||||||
url: "",
|
url: "",
|
||||||
name: "",
|
name: "",
|
||||||
username: "",
|
username: DEFAULT_USERNAME,
|
||||||
password: "",
|
password: "",
|
||||||
error: "",
|
error: "",
|
||||||
status: undefined,
|
status: undefined,
|
||||||
@@ -494,8 +500,8 @@ export function DialogSelectServer() {
|
|||||||
|
|
||||||
async function handleRemove(url: ServerConnection.Key) {
|
async function handleRemove(url: ServerConnection.Key) {
|
||||||
server.remove(url)
|
server.remove(url)
|
||||||
if ((await platform.getDefaultServerUrl?.()) === url) {
|
if ((await platform.getDefaultServer?.()) === url) {
|
||||||
platform.setDefaultServerUrl?.(null)
|
platform.setDefaultServer?.(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,7 +557,7 @@ export function DialogSelectServer() {
|
|||||||
status={store.status[key]}
|
status={store.status[key]}
|
||||||
class="flex items-center gap-3 min-w-0 flex-1"
|
class="flex items-center gap-3 min-w-0 flex-1"
|
||||||
badge={
|
badge={
|
||||||
<Show when={defaultUrl() === i.http.url}>
|
<Show when={defaultKey() === ServerConnection.key(i)}>
|
||||||
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||||
{language.t("dialog.server.status.default")}
|
{language.t("dialog.server.status.default")}
|
||||||
</span>
|
</span>
|
||||||
@@ -584,14 +590,14 @@ export function DialogSelectServer() {
|
|||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<Show when={canDefault() && defaultUrl() !== i.http.url}>
|
<Show when={canDefault() && defaultKey() !== key}>
|
||||||
<DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
|
<DropdownMenu.Item onSelect={() => setDefault(key)}>
|
||||||
<DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>
|
||||||
{language.t("dialog.server.menu.default")}
|
{language.t("dialog.server.menu.default")}
|
||||||
</DropdownMenu.ItemLabel>
|
</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={canDefault() && defaultUrl() === i.http.url}>
|
<Show when={canDefault() && defaultKey() === key}>
|
||||||
<DropdownMenu.Item onSelect={() => setDefault(null)}>
|
<DropdownMenu.Item onSelect={() => setDefault(null)}>
|
||||||
<DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>
|
||||||
{language.t("dialog.server.menu.defaultRemove")}
|
{language.t("dialog.server.menu.defaultRemove")}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
} from "@/context/prompt"
|
} from "@/context/prompt"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useParams } from "@solidjs/router"
|
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useComments } from "@/context/comments"
|
import { useComments } from "@/context/comments"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
@@ -37,6 +36,8 @@ import { Persist, persisted } from "@/utils/persist"
|
|||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
||||||
import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
|
import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
|
||||||
import {
|
import {
|
||||||
@@ -102,13 +103,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const comments = useComments()
|
const comments = useComments()
|
||||||
const params = useParams()
|
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const providers = useProviders()
|
const providers = useProviders()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
const { params, tabs, view } = useSessionLayout()
|
||||||
let editorRef!: HTMLDivElement
|
let editorRef!: HTMLDivElement
|
||||||
let fileInputRef: HTMLInputElement | undefined
|
let fileInputRef: HTMLInputElement | undefined
|
||||||
let scrollRef!: HTMLDivElement
|
let scrollRef!: HTMLDivElement
|
||||||
@@ -154,9 +155,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
requestAnimationFrame(scrollCursorIntoView)
|
requestAnimationFrame(scrollCursorIntoView)
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
const activeFileTab = createSessionTabs({
|
||||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
tabs,
|
||||||
const view = createMemo(() => layout.view(sessionKey))
|
pathFromTab: files.pathFromTab,
|
||||||
|
normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab),
|
||||||
|
}).activeFileTab
|
||||||
|
|
||||||
const commentInReview = (path: string) => {
|
const commentInReview = (path: string) => {
|
||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
@@ -209,7 +212,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
const recent = createMemo(() => {
|
const recent = createMemo(() => {
|
||||||
const all = tabs().all()
|
const all = tabs().all()
|
||||||
const active = tabs().active()
|
const active = activeFileTab()
|
||||||
const order = active ? [active, ...all.filter((x) => x !== active)] : all
|
const order = active ? [active, ...all.filter((x) => x !== active)] : all
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const paths: string[] = []
|
const paths: string[] = []
|
||||||
@@ -255,6 +258,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
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 })
|
||||||
|
const motion = (value: number) => ({
|
||||||
|
opacity: value,
|
||||||
|
transform: `scale(${0.95 + value * 0.05})`,
|
||||||
|
filter: `blur(${(1 - value) * 2}px)`,
|
||||||
|
"pointer-events": value > 0.5 ? ("auto" as const) : ("none" as const),
|
||||||
|
})
|
||||||
|
const buttons = createMemo(() => motion(buttonsSpring()))
|
||||||
|
const shell = createMemo(() => motion(1 - buttonsSpring()))
|
||||||
|
const control = createMemo(() => ({ height: "28px", ...buttons() }))
|
||||||
|
|
||||||
const commentCount = createMemo(() => {
|
const commentCount = createMemo(() => {
|
||||||
if (store.mode === "shell") return 0
|
if (store.mode === "shell") return 0
|
||||||
@@ -490,6 +502,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
setComposing(false)
|
setComposing(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCompositionStart = () => {
|
||||||
|
setComposing(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCompositionEnd = () => {
|
||||||
|
setComposing(false)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (composing()) return
|
||||||
|
reconcile(prompt.current().filter((part) => part.type !== "image"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const agentList = createMemo(() =>
|
const agentList = createMemo(() =>
|
||||||
sync.data.agent
|
sync.data.agent
|
||||||
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
||||||
@@ -680,24 +704,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reconcile = (input: Prompt) => {
|
||||||
|
if (mirror.input) {
|
||||||
|
mirror.input = false
|
||||||
|
if (isNormalizedEditor()) return
|
||||||
|
|
||||||
|
renderEditorWithCursor(input)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const dom = parseFromDOM()
|
||||||
|
if (isNormalizedEditor() && isPromptEqual(input, dom)) return
|
||||||
|
|
||||||
|
renderEditorWithCursor(input)
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => prompt.current(),
|
() => prompt.current(),
|
||||||
(currentParts) => {
|
(parts) => {
|
||||||
const inputParts = currentParts.filter((part) => part.type !== "image")
|
if (composing()) return
|
||||||
|
reconcile(parts.filter((part) => part.type !== "image"))
|
||||||
if (mirror.input) {
|
|
||||||
mirror.input = false
|
|
||||||
if (isNormalizedEditor()) return
|
|
||||||
|
|
||||||
renderEditorWithCursor(inputParts)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const domParts = parseFromDOM()
|
|
||||||
if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
|
|
||||||
|
|
||||||
renderEditorWithCursor(inputParts)
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -1208,8 +1235,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
spellcheck={store.mode === "normal"}
|
spellcheck={store.mode === "normal"}
|
||||||
onInput={handleInput}
|
onInput={handleInput}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
onCompositionStart={() => setComposing(true)}
|
onCompositionStart={handleCompositionStart}
|
||||||
onCompositionEnd={() => setComposing(false)}
|
onCompositionEnd={handleCompositionEnd}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
classList={{
|
classList={{
|
||||||
@@ -1260,11 +1287,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="size-8 p-0"
|
class="size-8 p-0"
|
||||||
style={{
|
style={buttons()}
|
||||||
opacity: buttonsSpring(),
|
|
||||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
|
||||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
|
||||||
}}
|
|
||||||
onClick={pick}
|
onClick={pick}
|
||||||
disabled={store.mode !== "normal"}
|
disabled={store.mode !== "normal"}
|
||||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||||
@@ -1302,11 +1325,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
icon={working() ? "stop" : "arrow-up"}
|
icon={working() ? "stop" : "arrow-up"}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
class="size-8"
|
class="size-8"
|
||||||
style={{
|
style={buttons()}
|
||||||
opacity: buttonsSpring(),
|
|
||||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
|
||||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
|
||||||
}}
|
|
||||||
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -1364,10 +1383,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0"
|
class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0"
|
||||||
style={{
|
style={{
|
||||||
padding: "0 4px 0 8px",
|
padding: "0 4px 0 8px",
|
||||||
opacity: 1 - buttonsSpring(),
|
...shell(),
|
||||||
transform: `scale(${0.95 + (1 - buttonsSpring()) * 0.05})`,
|
|
||||||
filter: `blur(${buttonsSpring() * 2}px)`,
|
|
||||||
"pointer-events": buttonsSpring() < 0.5 ? "auto" : "none",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
|
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
|
||||||
@@ -1387,13 +1403,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
onSelect={local.agent.set}
|
onSelect={local.agent.set}
|
||||||
class="capitalize max-w-[160px]"
|
class="capitalize max-w-[160px]"
|
||||||
valueClass="truncate text-13-regular"
|
valueClass="truncate text-13-regular"
|
||||||
triggerStyle={{
|
triggerStyle={control()}
|
||||||
height: "28px",
|
|
||||||
opacity: buttonsSpring(),
|
|
||||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
|
||||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
|
||||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
|
||||||
}}
|
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
/>
|
/>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
@@ -1411,13 +1421,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="normal"
|
size="normal"
|
||||||
class="min-w-0 max-w-[320px] text-13-regular group"
|
class="min-w-0 max-w-[320px] text-13-regular group"
|
||||||
style={{
|
style={control()}
|
||||||
height: "28px",
|
|
||||||
opacity: buttonsSpring(),
|
|
||||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
|
||||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
|
||||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
|
||||||
}}
|
|
||||||
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
|
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
|
||||||
>
|
>
|
||||||
<Show when={local.model.current()?.provider?.id}>
|
<Show when={local.model.current()?.provider?.id}>
|
||||||
@@ -1446,13 +1450,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
triggerProps={{
|
triggerProps={{
|
||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
size: "normal",
|
size: "normal",
|
||||||
style: {
|
style: control(),
|
||||||
height: "28px",
|
|
||||||
opacity: buttonsSpring(),
|
|
||||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
|
||||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
|
||||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
|
||||||
},
|
|
||||||
class: "min-w-0 max-w-[320px] text-13-regular group",
|
class: "min-w-0 max-w-[320px] text-13-regular group",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1484,13 +1482,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
|
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
|
||||||
class="capitalize max-w-[160px]"
|
class="capitalize max-w-[160px]"
|
||||||
valueClass="truncate text-13-regular"
|
valueClass="truncate text-13-regular"
|
||||||
triggerStyle={{
|
triggerStyle={control()}
|
||||||
height: "28px",
|
|
||||||
opacity: buttonsSpring(),
|
|
||||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
|
||||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
|
||||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
|
||||||
}}
|
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
/>
|
/>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -316,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 = () =>
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { Match, Show, Switch, createMemo } from "solid-js"
|
|||||||
import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
|
import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
|
||||||
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { useParams } from "@solidjs/router"
|
|
||||||
|
|
||||||
|
import { useFile } from "@/context/file"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
|
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
|
||||||
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
|
|
||||||
interface SessionContextUsageProps {
|
interface SessionContextUsageProps {
|
||||||
variant?: "button" | "indicator"
|
variant?: "button" | "indicator"
|
||||||
@@ -27,14 +29,17 @@ function openSessionContext(args: {
|
|||||||
|
|
||||||
export function SessionContextUsage(props: SessionContextUsageProps) {
|
export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const params = useParams()
|
const file = useFile()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const { params, tabs, view } = useSessionLayout()
|
||||||
|
|
||||||
const variant = createMemo(() => props.variant ?? "button")
|
const variant = createMemo(() => props.variant ?? "button")
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
const tabState = createSessionTabs({
|
||||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
tabs,
|
||||||
const view = createMemo(() => layout.view(sessionKey))
|
pathFromTab: file.pathFromTab,
|
||||||
|
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||||
|
})
|
||||||
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||||
|
|
||||||
const usd = createMemo(
|
const usd = createMemo(
|
||||||
@@ -54,7 +59,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
const openContext = () => {
|
const openContext = () => {
|
||||||
if (!params.id) return
|
if (!params.id) return
|
||||||
|
|
||||||
if (tabs().active() === "context") {
|
if (tabState.activeTab() === "context") {
|
||||||
tabs().close("context")
|
tabs().close("context")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
|
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
|
||||||
import type { JSX } from "solid-js"
|
import type { JSX } from "solid-js"
|
||||||
import { useParams } from "@solidjs/router"
|
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useLayout } from "@/context/layout"
|
|
||||||
import { checksum } from "@opencode-ai/util/encode"
|
import { checksum } from "@opencode-ai/util/encode"
|
||||||
import { findLast } from "@opencode-ai/util/array"
|
import { findLast } from "@opencode-ai/util/array"
|
||||||
import { same } from "@/utils/same"
|
import { same } from "@/utils/same"
|
||||||
@@ -14,6 +12,7 @@ import { Markdown } from "@opencode-ai/ui/markdown"
|
|||||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||||
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { getSessionContextMetrics } from "./session-context-metrics"
|
import { getSessionContextMetrics } from "./session-context-metrics"
|
||||||
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
||||||
import { createSessionContextFormatter } from "./session-context-format"
|
import { createSessionContextFormatter } from "./session-context-format"
|
||||||
@@ -91,13 +90,10 @@ const emptyMessages: Message[] = []
|
|||||||
const emptyUserMessages: UserMessage[] = []
|
const emptyUserMessages: UserMessage[] = []
|
||||||
|
|
||||||
export function SessionContextTab() {
|
export function SessionContextTab() {
|
||||||
const params = useParams()
|
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const layout = useLayout()
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const { params, view } = useSessionLayout()
|
||||||
|
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
|
||||||
const view = createMemo(() => layout.view(sessionKey))
|
|
||||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||||
|
|
||||||
const messages = createMemo(
|
const messages = createMemo(
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { TextField } from "@opencode-ai/ui/text-field"
|
|||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
import { getFilename } from "@opencode-ai/util/path"
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
import { useParams } from "@solidjs/router"
|
|
||||||
import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
|
import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { Portal } from "solid-js/web"
|
import { Portal } from "solid-js/web"
|
||||||
@@ -21,6 +20,9 @@ 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 { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
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"
|
||||||
@@ -223,12 +225,13 @@ function useSessionShare(args: {
|
|||||||
export function SessionHeader() {
|
export function SessionHeader() {
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const params = useParams()
|
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const terminal = useTerminal()
|
||||||
|
const { params, view } = useSessionLayout()
|
||||||
|
|
||||||
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||||
const project = createMemo(() => {
|
const project = createMemo(() => {
|
||||||
@@ -246,8 +249,6 @@ export function SessionHeader() {
|
|||||||
const currentSession = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
const currentSession = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||||
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
||||||
const showShare = createMemo(() => shareEnabled() && !!params.id)
|
const showShare = createMemo(() => shareEnabled() && !!params.id)
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
|
||||||
const view = createMemo(() => layout.view(sessionKey))
|
|
||||||
const os = createMemo(() => detectOS(platform))
|
const os = createMemo(() => detectOS(platform))
|
||||||
|
|
||||||
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
||||||
@@ -279,10 +280,7 @@ export function SessionHeader() {
|
|||||||
Promise.resolve(platform.checkAppExists?.(app.openWith))
|
Promise.resolve(platform.checkAppExists?.(app.openWith))
|
||||||
.then((value) => Boolean(value))
|
.then((value) => Boolean(value))
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
.then((ok) => {
|
.then((ok) => [app.id, ok] as const),
|
||||||
console.debug(`[session-header] App "${app.label}" (${app.openWith}): ${ok ? "exists" : "does not exist"}`)
|
|
||||||
return [app.id, ok] as const
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
).then((entries) => {
|
).then((entries) => {
|
||||||
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
|
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
|
||||||
@@ -296,6 +294,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 +311,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 +625,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,16 +4,15 @@ 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
|
||||||
onWorktreeChange: (value: string) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NewSessionView(props: NewSessionViewProps) {
|
export function NewSessionView(props: NewSessionViewProps) {
|
||||||
@@ -50,33 +49,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-start gap-3 min-h-5">
|
<div class="flex-1 px-6 pb-30 flex items-center justify-center text-center">
|
||||||
<Icon name="folder" size="small" class="mt-0.5 shrink-0" />
|
<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 leading-5">
|
<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-start gap-3 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">{label(current())}</div>
|
|
||||||
</div>
|
|
||||||
<Show when={sync.project}>
|
|
||||||
{(project) => (
|
|
||||||
<div class="flex justify-center items-start gap-3 min-h-5">
|
|
||||||
<Icon name="pencil-line" size="small" class="mt-0.5 shrink-0" />
|
|
||||||
<div class="text-12-medium text-text-weak leading-5">
|
|
||||||
{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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
|||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { focusTerminalById } from "@/pages/session/helpers"
|
||||||
|
|
||||||
export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => void }): JSX.Element {
|
export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => void }): JSX.Element {
|
||||||
const terminal = useTerminal()
|
const terminal = useTerminal()
|
||||||
@@ -53,21 +54,8 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
|||||||
|
|
||||||
const focus = () => {
|
const focus = () => {
|
||||||
if (store.editing) return
|
if (store.editing) return
|
||||||
|
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
|
||||||
if (document.activeElement instanceof HTMLElement) {
|
focusTerminalById(props.terminal.id)
|
||||||
document.activeElement.blur()
|
|
||||||
}
|
|
||||||
const wrapper = document.getElementById(`terminal-wrapper-${props.terminal.id}`)
|
|
||||||
const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement
|
|
||||||
if (!element) return
|
|
||||||
|
|
||||||
const textarea = element.querySelector("textarea") as HTMLTextAreaElement
|
|
||||||
if (textarea) {
|
|
||||||
textarea.focus()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
element.focus()
|
|
||||||
element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true }))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const edit = (e?: Event) => {
|
const edit = (e?: Event) => {
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import { Component } from "solid-js"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
|
|
||||||
export const SettingsAgents: Component = () => {
|
|
||||||
// TODO: Replace this placeholder with full agents settings controls.
|
|
||||||
const language = useLanguage()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col h-full overflow-y-auto">
|
|
||||||
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
|
|
||||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.agents.title")}</h2>
|
|
||||||
<p class="text-14-regular text-text-weak">{language.t("settings.agents.description")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { Component } from "solid-js"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
|
|
||||||
export const SettingsCommands: Component = () => {
|
|
||||||
// TODO: Replace this placeholder with full commands settings controls.
|
|
||||||
const language = useLanguage()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col h-full overflow-y-auto">
|
|
||||||
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
|
|
||||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.commands.title")}</h2>
|
|
||||||
<p class="text-14-regular text-text-weak">{language.t("settings.commands.description")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { Component } from "solid-js"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
|
|
||||||
export const SettingsMcp: Component = () => {
|
|
||||||
// TODO: Replace this placeholder with full MCP settings controls.
|
|
||||||
const language = useLanguage()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col h-full overflow-y-auto">
|
|
||||||
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
|
|
||||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.mcp.title")}</h2>
|
|
||||||
<p class="text-14-regular text-text-weak">{language.t("settings.mcp.description")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
import { Select } from "@opencode-ai/ui/select"
|
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
|
||||||
import { Component, For, createMemo, type JSX } from "solid-js"
|
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
|
|
||||||
type PermissionAction = "allow" | "ask" | "deny"
|
|
||||||
|
|
||||||
type PermissionObject = Record<string, PermissionAction>
|
|
||||||
type PermissionValue = PermissionAction | PermissionObject | string[] | undefined
|
|
||||||
type PermissionMap = Record<string, PermissionValue>
|
|
||||||
|
|
||||||
type PermissionItem = {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const ACTIONS = [
|
|
||||||
{ value: "allow", label: "settings.permissions.action.allow" },
|
|
||||||
{ value: "ask", label: "settings.permissions.action.ask" },
|
|
||||||
{ value: "deny", label: "settings.permissions.action.deny" },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const ITEMS = [
|
|
||||||
{
|
|
||||||
id: "read",
|
|
||||||
title: "settings.permissions.tool.read.title",
|
|
||||||
description: "settings.permissions.tool.read.description",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "edit",
|
|
||||||
title: "settings.permissions.tool.edit.title",
|
|
||||||
description: "settings.permissions.tool.edit.description",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "glob",
|
|
||||||
title: "settings.permissions.tool.glob.title",
|
|
||||||
description: "settings.permissions.tool.glob.description",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "grep",
|
|
||||||
title: "settings.permissions.tool.grep.title",
|
|
||||||
description: "settings.permissions.tool.grep.description",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "list",
|
|
||||||
title: "settings.permissions.tool.list.title",
|
|
||||||
description: "settings.permissions.tool.list.description",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "bash",
|
|
||||||
title: "settings.permissions.tool.bash.title",
|
|
||||||
description: "settings.permissions.tool.bash.description",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "task",
|
|
||||||
title: "settings.permissions.tool.task.title",
|
|
||||||
description: "settings.permissions.tool.task.description",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "skill",
|
|
||||||
title: "settings.permissions.tool.skill.title",
|
|
||||||
description: "settings.permissions.tool.skill.description",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "lsp",
|
|
||||||
title: "settings.permissions.tool.lsp.title",
|
|
||||||
description: "settings.permissions.tool.lsp.description",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "todoread",
|
|
||||||
title: "settings.permissions.tool.todoread.title",
|
|
||||||
description: "settings.permissions.tool.todoread.description",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "todowrite",
|
|
||||||
title: "settings.permissions.tool.todowrite.title",
|
|
||||||
description: "settings.permissions.tool.todowrite.description",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "webfetch",
|
|
||||||
title: "settings.permissions.tool.webfetch.title",
|
|
||||||
description: "settings.permissions.tool.webfetch.description",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "websearch",
|
|
||||||
title: "settings.permissions.tool.websearch.title",
|
|
||||||
description: "settings.permissions.tool.websearch.description",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "codesearch",
|
|
||||||
title: "settings.permissions.tool.codesearch.title",
|
|
||||||
description: "settings.permissions.tool.codesearch.description",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "external_directory",
|
|
||||||
title: "settings.permissions.tool.external_directory.title",
|
|
||||||
description: "settings.permissions.tool.external_directory.description",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "doom_loop",
|
|
||||||
title: "settings.permissions.tool.doom_loop.title",
|
|
||||||
description: "settings.permissions.tool.doom_loop.description",
|
|
||||||
},
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const VALID_ACTIONS = new Set<PermissionAction>(["allow", "ask", "deny"])
|
|
||||||
|
|
||||||
function toMap(value: unknown): PermissionMap {
|
|
||||||
if (value && typeof value === "object" && !Array.isArray(value)) return value as PermissionMap
|
|
||||||
|
|
||||||
const action = getAction(value)
|
|
||||||
if (action) return { "*": action }
|
|
||||||
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAction(value: unknown): PermissionAction | undefined {
|
|
||||||
if (typeof value === "string" && VALID_ACTIONS.has(value as PermissionAction)) return value as PermissionAction
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRuleDefault(value: unknown): PermissionAction | undefined {
|
|
||||||
const action = getAction(value)
|
|
||||||
if (action) return action
|
|
||||||
|
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) return
|
|
||||||
|
|
||||||
return getAction((value as Record<string, unknown>)["*"])
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SettingsPermissions: Component = () => {
|
|
||||||
const globalSync = useGlobalSync()
|
|
||||||
const language = useLanguage()
|
|
||||||
|
|
||||||
const actions = createMemo(
|
|
||||||
(): Array<{ value: PermissionAction; label: string }> =>
|
|
||||||
ACTIONS.map((action) => ({
|
|
||||||
value: action.value,
|
|
||||||
label: language.t(action.label),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
|
|
||||||
const permission = createMemo(() => {
|
|
||||||
return toMap(globalSync.data.config.permission)
|
|
||||||
})
|
|
||||||
|
|
||||||
const actionFor = (id: string): PermissionAction => {
|
|
||||||
const value = permission()[id]
|
|
||||||
const direct = getRuleDefault(value)
|
|
||||||
if (direct) return direct
|
|
||||||
|
|
||||||
const wildcard = getRuleDefault(permission()["*"])
|
|
||||||
if (wildcard) return wildcard
|
|
||||||
|
|
||||||
return "allow"
|
|
||||||
}
|
|
||||||
|
|
||||||
const setPermission = async (id: string, action: PermissionAction) => {
|
|
||||||
const before = globalSync.data.config.permission
|
|
||||||
const map = toMap(before)
|
|
||||||
const existing = map[id]
|
|
||||||
|
|
||||||
const nextValue =
|
|
||||||
existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing, "*": action } : action
|
|
||||||
|
|
||||||
const rollback = (err: unknown) => {
|
|
||||||
globalSync.set("config", "permission", before)
|
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
|
||||||
showToast({ title: language.t("settings.permissions.toast.updateFailed.title"), description: message })
|
|
||||||
}
|
|
||||||
|
|
||||||
globalSync.set("config", "permission", { ...map, [id]: nextValue })
|
|
||||||
globalSync.updateConfig({ permission: { [id]: nextValue } }).catch(rollback)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar">
|
|
||||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
|
||||||
<div class="flex flex-col gap-1 px-4 py-8 sm:p-8 max-w-[720px]">
|
|
||||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.permissions.title")}</h2>
|
|
||||||
<p class="text-14-regular text-text-weak">{language.t("settings.permissions.description")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-6 px-4 py-6 sm:p-8 sm:pt-6 max-w-[720px]">
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<h3 class="text-14-medium text-text-strong">{language.t("settings.permissions.section.tools")}</h3>
|
|
||||||
<div class="border border-border-weak-base rounded-lg overflow-hidden">
|
|
||||||
<For each={ITEMS}>
|
|
||||||
{(item) => (
|
|
||||||
<SettingsRow title={language.t(item.title)} description={language.t(item.description)}>
|
|
||||||
<Select
|
|
||||||
options={actions()}
|
|
||||||
current={actions().find((o) => o.value === actionFor(item.id))}
|
|
||||||
value={(o) => o.value}
|
|
||||||
label={(o) => o.label}
|
|
||||||
onSelect={(option) => option && setPermission(item.id, option.value)}
|
|
||||||
variant="secondary"
|
|
||||||
size="small"
|
|
||||||
triggerVariant="settings"
|
|
||||||
/>
|
|
||||||
</SettingsRow>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SettingsRowProps {
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
children: JSX.Element
|
|
||||||
}
|
|
||||||
|
|
||||||
const SettingsRow: Component<SettingsRowProps> = (props) => {
|
|
||||||
return (
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4 px-4 py-3 border-b border-border-weak-base last:border-none">
|
|
||||||
<div class="flex flex-col gap-0.5 min-w-0">
|
|
||||||
<span class="text-14-medium text-text-strong">{props.title}</span>
|
|
||||||
<span class="text-12-regular text-text-weak">{props.description}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex-shrink-0">{props.children}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,7 @@ import { usePlatform } from "@/context/platform"
|
|||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
|
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||||
import { DialogSelectServer } from "./dialog-select-server"
|
import { DialogSelectServer } from "./dialog-select-server"
|
||||||
|
|
||||||
const pollMs = 10_000
|
const pollMs = 10_000
|
||||||
@@ -53,7 +53,8 @@ const listServersByHealth = (
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typeof fetch) => {
|
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => {
|
||||||
|
const checkServerHealth = useCheckServerHealth()
|
||||||
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
|
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -64,7 +65,7 @@ const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typ
|
|||||||
const results: Record<string, ServerHealth> = {}
|
const results: Record<string, ServerHealth> = {}
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
list.map(async (conn) => {
|
list.map(async (conn) => {
|
||||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
|
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (dead) return
|
if (dead) return
|
||||||
@@ -168,7 +169,6 @@ export function StatusPopover() {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const fetcher = platform.fetch ?? globalThis.fetch
|
|
||||||
const servers = createMemo(() => {
|
const servers = createMemo(() => {
|
||||||
const current = server.current
|
const current = server.current
|
||||||
const list = server.list
|
const list = server.list
|
||||||
@@ -176,10 +176,10 @@ export function StatusPopover() {
|
|||||||
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
|
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
|
||||||
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
|
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
|
||||||
})
|
})
|
||||||
const health = useServerHealth(servers, fetcher)
|
const health = useServerHealth(servers)
|
||||||
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
|
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
|
||||||
const mcp = useMcpToggle({ sync, sdk, language })
|
const mcp = useMcpToggle({ sync, sdk, language })
|
||||||
const defaultServer = useDefaultServerKey(platform.getDefaultServerUrl)
|
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||||
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||||
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
||||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useSDK } from "@/context/sdk"
|
|||||||
import { useServer } from "@/context/server"
|
import { useServer } from "@/context/server"
|
||||||
import { monoFontFamily, useSettings } from "@/context/settings"
|
import { monoFontFamily, useSettings } from "@/context/settings"
|
||||||
import type { LocalPTY } from "@/context/terminal"
|
import type { LocalPTY } from "@/context/terminal"
|
||||||
|
import { terminalAttr, terminalProbe } from "@/testing/terminal"
|
||||||
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
||||||
import { terminalWriter } from "@/utils/terminal-writer"
|
import { terminalWriter } from "@/utils/terminal-writer"
|
||||||
|
|
||||||
@@ -17,6 +18,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,8 +159,9 @@ 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 probe = terminalProbe(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 =
|
||||||
restore &&
|
restore &&
|
||||||
@@ -217,7 +220,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
|
||||||
@@ -325,6 +328,9 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
probe.init()
|
||||||
|
cleanups.push(() => probe.drop())
|
||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
const loaded = await loadGhostty()
|
const loaded = await loadGhostty()
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
@@ -352,7 +358,13 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
}
|
}
|
||||||
ghostty = g
|
ghostty = g
|
||||||
term = t
|
term = t
|
||||||
output = terminalWriter((data, done) => t.write(data, done))
|
output = terminalWriter((data, done) =>
|
||||||
|
t.write(data, () => {
|
||||||
|
probe.render(data)
|
||||||
|
probe.settle()
|
||||||
|
done?.()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
t.attachCustomKeyEventHandler((event) => {
|
t.attachCustomKeyEventHandler((event) => {
|
||||||
const key = event.key.toLowerCase()
|
const key = event.key.toLowerCase()
|
||||||
@@ -386,7 +398,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)
|
||||||
@@ -440,10 +452,6 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
startResize()
|
startResize()
|
||||||
}
|
}
|
||||||
|
|
||||||
// t.onScroll((ydisp) => {
|
|
||||||
// console.log("Scroll position:", ydisp)
|
|
||||||
// })
|
|
||||||
|
|
||||||
const once = { value: false }
|
const once = { value: false }
|
||||||
let closing = false
|
let closing = false
|
||||||
|
|
||||||
@@ -451,7 +459,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
url.searchParams.set("directory", sdk.directory)
|
url.searchParams.set("directory", sdk.directory)
|
||||||
url.searchParams.set("cursor", String(start !== undefined ? start : restore ? -1 : 0))
|
url.searchParams.set("cursor", String(start !== undefined ? start : restore ? -1 : 0))
|
||||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||||
url.username = server.current?.http.username ?? ""
|
url.username = server.current?.http.username ?? "opencode"
|
||||||
url.password = server.current?.http.password ?? ""
|
url.password = server.current?.http.password ?? ""
|
||||||
|
|
||||||
const socket = new WebSocket(url)
|
const socket = new WebSocket(url)
|
||||||
@@ -459,6 +467,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
ws = socket
|
ws = socket
|
||||||
|
|
||||||
const handleOpen = () => {
|
const handleOpen = () => {
|
||||||
|
probe.connect()
|
||||||
local.onConnect?.()
|
local.onConnect?.()
|
||||||
scheduleSize(t.cols, t.rows)
|
scheduleSize(t.cols, t.rows)
|
||||||
}
|
}
|
||||||
@@ -559,6 +568,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
<div
|
<div
|
||||||
ref={container}
|
ref={container}
|
||||||
data-component="terminal"
|
data-component="terminal"
|
||||||
|
{...{ [terminalAttr]: id }}
|
||||||
data-prevent-autofocus
|
data-prevent-autofocus
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
style={{ "background-color": terminalColors().background }}
|
style={{ "background-color": terminalColors().background }}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createEffect, createMemo, Show, untrack } from "solid-js"
|
import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
@@ -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
|
||||||
@@ -282,7 +282,7 @@ export function Titlebar() {
|
|||||||
>
|
>
|
||||||
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
|
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
|
||||||
<Show when={windows()}>
|
<Show when={windows()}>
|
||||||
<div class="w-6 shrink-0" />
|
{!tauriApi() && <div class="w-36 shrink-0" />}
|
||||||
<div data-tauri-decorum-tb class="flex flex-row" />
|
<div data-tauri-decorum-tb class="flex flex-row" />
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import {
|
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
||||||
canDisposeDirectory,
|
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||||
estimateRootSessionTotal,
|
|
||||||
loadRootSessionsWithFallback,
|
|
||||||
pickDirectoriesToEvict,
|
|
||||||
} from "./global-sync"
|
|
||||||
|
|
||||||
describe("pickDirectoriesToEvict", () => {
|
describe("pickDirectoriesToEvict", () => {
|
||||||
test("keeps pinned stores and evicts idle stores", () => {
|
test("keeps pinned stores and evicts idle stores", () => {
|
||||||
|
|||||||
@@ -27,8 +27,9 @@ 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 { clearSessionPrefetchDirectory } from "./global-sync/session-prefetch"
|
||||||
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"
|
||||||
import type { ProjectMeta } from "./global-sync/types"
|
import type { ProjectMeta } from "./global-sync/types"
|
||||||
@@ -161,6 +162,7 @@ function createGlobalSync() {
|
|||||||
queue.clear(directory)
|
queue.clear(directory)
|
||||||
sessionMeta.delete(directory)
|
sessionMeta.delete(directory)
|
||||||
sdkCache.delete(directory)
|
sdkCache.delete(directory)
|
||||||
|
clearSessionPrefetchDirectory(directory)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -189,6 +191,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 +223,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) => {
|
||||||
@@ -400,6 +404,3 @@ export function useGlobalSync() {
|
|||||||
if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
|
if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
|
||||||
return context
|
return context
|
||||||
}
|
}
|
||||||
|
|
||||||
export { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
|
||||||
export { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import {
|
||||||
|
clearSessionPrefetch,
|
||||||
|
clearSessionPrefetchDirectory,
|
||||||
|
getSessionPrefetch,
|
||||||
|
runSessionPrefetch,
|
||||||
|
setSessionPrefetch,
|
||||||
|
} from "./session-prefetch"
|
||||||
|
|
||||||
|
describe("session prefetch", () => {
|
||||||
|
test("stores and clears message metadata by directory", () => {
|
||||||
|
clearSessionPrefetch("/tmp/a", ["ses_1"])
|
||||||
|
clearSessionPrefetch("/tmp/b", ["ses_1"])
|
||||||
|
|
||||||
|
setSessionPrefetch({
|
||||||
|
directory: "/tmp/a",
|
||||||
|
sessionID: "ses_1",
|
||||||
|
limit: 200,
|
||||||
|
complete: false,
|
||||||
|
at: 123,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(getSessionPrefetch("/tmp/a", "ses_1")).toEqual({ limit: 200, complete: false, at: 123 })
|
||||||
|
expect(getSessionPrefetch("/tmp/b", "ses_1")).toBeUndefined()
|
||||||
|
|
||||||
|
clearSessionPrefetch("/tmp/a", ["ses_1"])
|
||||||
|
|
||||||
|
expect(getSessionPrefetch("/tmp/a", "ses_1")).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("dedupes inflight work", async () => {
|
||||||
|
clearSessionPrefetch("/tmp/c", ["ses_2"])
|
||||||
|
|
||||||
|
let calls = 0
|
||||||
|
const run = () =>
|
||||||
|
runSessionPrefetch({
|
||||||
|
directory: "/tmp/c",
|
||||||
|
sessionID: "ses_2",
|
||||||
|
task: async () => {
|
||||||
|
calls += 1
|
||||||
|
return { limit: 100, complete: true, at: 456 }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const [a, b] = await Promise.all([run(), run()])
|
||||||
|
|
||||||
|
expect(calls).toBe(1)
|
||||||
|
expect(a).toEqual({ limit: 100, complete: true, at: 456 })
|
||||||
|
expect(b).toEqual({ limit: 100, complete: true, at: 456 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("clears a whole directory", () => {
|
||||||
|
setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_1", limit: 10, complete: true, at: 1 })
|
||||||
|
setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_2", limit: 20, complete: false, at: 2 })
|
||||||
|
setSessionPrefetch({ directory: "/tmp/e", sessionID: "ses_1", limit: 30, complete: true, at: 3 })
|
||||||
|
|
||||||
|
clearSessionPrefetchDirectory("/tmp/d")
|
||||||
|
|
||||||
|
expect(getSessionPrefetch("/tmp/d", "ses_1")).toBeUndefined()
|
||||||
|
expect(getSessionPrefetch("/tmp/d", "ses_2")).toBeUndefined()
|
||||||
|
expect(getSessionPrefetch("/tmp/e", "ses_1")).toEqual({ limit: 30, complete: true, at: 3 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
const key = (directory: string, sessionID: string) => `${directory}\n${sessionID}`
|
||||||
|
|
||||||
|
export const SESSION_PREFETCH_TTL = 15_000
|
||||||
|
|
||||||
|
type Meta = {
|
||||||
|
limit: number
|
||||||
|
complete: boolean
|
||||||
|
at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = new Map<string, Meta>()
|
||||||
|
const inflight = new Map<string, Promise<Meta | undefined>>()
|
||||||
|
const rev = new Map<string, number>()
|
||||||
|
|
||||||
|
const version = (id: string) => rev.get(id) ?? 0
|
||||||
|
|
||||||
|
export function getSessionPrefetch(directory: string, sessionID: string) {
|
||||||
|
return cache.get(key(directory, sessionID))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionPrefetchPromise(directory: string, sessionID: string) {
|
||||||
|
return inflight.get(key(directory, sessionID))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSessionPrefetchInflight() {
|
||||||
|
inflight.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSessionPrefetchCurrent(directory: string, sessionID: string, value: number) {
|
||||||
|
return version(key(directory, sessionID)) === value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runSessionPrefetch(input: {
|
||||||
|
directory: string
|
||||||
|
sessionID: string
|
||||||
|
task: (value: number) => Promise<Meta | undefined>
|
||||||
|
}) {
|
||||||
|
const id = key(input.directory, input.sessionID)
|
||||||
|
const pending = inflight.get(id)
|
||||||
|
if (pending) return pending
|
||||||
|
|
||||||
|
const value = version(id)
|
||||||
|
|
||||||
|
const promise = input.task(value).finally(() => {
|
||||||
|
if (inflight.get(id) === promise) inflight.delete(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
inflight.set(id, promise)
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSessionPrefetch(input: {
|
||||||
|
directory: string
|
||||||
|
sessionID: string
|
||||||
|
limit: number
|
||||||
|
complete: boolean
|
||||||
|
at?: number
|
||||||
|
}) {
|
||||||
|
cache.set(key(input.directory, input.sessionID), {
|
||||||
|
limit: input.limit,
|
||||||
|
complete: input.complete,
|
||||||
|
at: input.at ?? Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSessionPrefetch(directory: string, sessionIDs: Iterable<string>) {
|
||||||
|
for (const sessionID of sessionIDs) {
|
||||||
|
if (!sessionID) continue
|
||||||
|
const id = key(directory, sessionID)
|
||||||
|
rev.set(id, version(id) + 1)
|
||||||
|
cache.delete(id)
|
||||||
|
inflight.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSessionPrefetchDirectory(directory: string) {
|
||||||
|
const prefix = `${directory}\n`
|
||||||
|
const keys = new Set([...cache.keys(), ...inflight.keys()])
|
||||||
|
for (const id of keys) {
|
||||||
|
if (!id.startsWith(prefix)) continue
|
||||||
|
rev.set(id, version(id) + 1)
|
||||||
|
cache.delete(id)
|
||||||
|
inflight.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createEffect, createSignal, onCleanup } from "solid-js"
|
import { createEffect, onCleanup } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
@@ -146,8 +146,10 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
|
|||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const [store, setStore, _, ready] = persisted("highlights.v1", createStore<Store>({ version: undefined }))
|
const [store, setStore, _, ready] = persisted("highlights.v1", createStore<Store>({ version: undefined }))
|
||||||
|
|
||||||
const [from, setFrom] = createSignal<string | undefined>(undefined)
|
const [range, setRange] = createStore({
|
||||||
const [to, setTo] = createSignal<string | undefined>(undefined)
|
from: undefined as string | undefined,
|
||||||
|
to: undefined as string | undefined,
|
||||||
|
})
|
||||||
const state = { started: false }
|
const state = { started: false }
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
@@ -214,15 +216,14 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
|
|||||||
|
|
||||||
if (previous === platform.version) return
|
if (previous === platform.version) return
|
||||||
|
|
||||||
setFrom(previous)
|
setRange({ from: previous, to: platform.version })
|
||||||
setTo(platform.version)
|
|
||||||
start(previous)
|
start(previous)
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready,
|
||||||
from,
|
from: () => range.from,
|
||||||
to,
|
to: () => range.to,
|
||||||
get last() {
|
get last() {
|
||||||
return store.version
|
return store.version
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -793,20 +793,67 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
open: createMemo(() => s().reviewOpen),
|
open: createMemo(() => s().reviewOpen ?? []),
|
||||||
setOpen(open: string[]) {
|
setOpen(open: string[]) {
|
||||||
|
const session = key()
|
||||||
|
const next = Array.from(new Set(open))
|
||||||
|
const current = store.sessionView[session]
|
||||||
|
if (!current) {
|
||||||
|
setStore("sessionView", session, {
|
||||||
|
scroll: {},
|
||||||
|
reviewOpen: next,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (same(current.reviewOpen, next)) return
|
||||||
|
setStore("sessionView", session, "reviewOpen", next)
|
||||||
|
},
|
||||||
|
openPath(path: string) {
|
||||||
const session = key()
|
const session = key()
|
||||||
const current = store.sessionView[session]
|
const current = store.sessionView[session]
|
||||||
if (!current) {
|
if (!current) {
|
||||||
setStore("sessionView", session, {
|
setStore("sessionView", session, {
|
||||||
scroll: {},
|
scroll: {},
|
||||||
reviewOpen: open,
|
reviewOpen: [path],
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (same(current.reviewOpen, open)) return
|
if (!current.reviewOpen) {
|
||||||
setStore("sessionView", session, "reviewOpen", open)
|
setStore("sessionView", session, "reviewOpen", [path])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.reviewOpen.includes(path)) return
|
||||||
|
setStore("sessionView", session, "reviewOpen", current.reviewOpen.length, path)
|
||||||
|
},
|
||||||
|
closePath(path: string) {
|
||||||
|
const session = key()
|
||||||
|
const current = store.sessionView[session]?.reviewOpen
|
||||||
|
if (!current) return
|
||||||
|
|
||||||
|
const index = current.indexOf(path)
|
||||||
|
if (index === -1) return
|
||||||
|
setStore(
|
||||||
|
"sessionView",
|
||||||
|
session,
|
||||||
|
"reviewOpen",
|
||||||
|
produce((draft) => {
|
||||||
|
if (!draft) return
|
||||||
|
draft.splice(index, 1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
togglePath(path: string) {
|
||||||
|
const session = key()
|
||||||
|
const current = store.sessionView[session]?.reviewOpen
|
||||||
|
if (!current || !current.includes(path)) {
|
||||||
|
this.openPath(path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.closePath(path)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
type NotificationIndexItem = {
|
|
||||||
directory?: string
|
|
||||||
session?: string
|
|
||||||
viewed: boolean
|
|
||||||
type: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildNotificationIndex<T extends NotificationIndexItem>(list: T[]) {
|
|
||||||
const sessionAll = new Map<string, T[]>()
|
|
||||||
const sessionUnseen = new Map<string, T[]>()
|
|
||||||
const sessionUnseenCount = new Map<string, number>()
|
|
||||||
const sessionUnseenHasError = new Map<string, boolean>()
|
|
||||||
const projectAll = new Map<string, T[]>()
|
|
||||||
const projectUnseen = new Map<string, T[]>()
|
|
||||||
const projectUnseenCount = new Map<string, number>()
|
|
||||||
const projectUnseenHasError = new Map<string, boolean>()
|
|
||||||
|
|
||||||
for (const notification of list) {
|
|
||||||
const session = notification.session
|
|
||||||
if (session) {
|
|
||||||
const all = sessionAll.get(session)
|
|
||||||
if (all) all.push(notification)
|
|
||||||
else sessionAll.set(session, [notification])
|
|
||||||
|
|
||||||
if (!notification.viewed) {
|
|
||||||
const unseen = sessionUnseen.get(session)
|
|
||||||
if (unseen) unseen.push(notification)
|
|
||||||
else sessionUnseen.set(session, [notification])
|
|
||||||
|
|
||||||
sessionUnseenCount.set(session, (sessionUnseenCount.get(session) ?? 0) + 1)
|
|
||||||
if (notification.type === "error") sessionUnseenHasError.set(session, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const directory = notification.directory
|
|
||||||
if (directory) {
|
|
||||||
const all = projectAll.get(directory)
|
|
||||||
if (all) all.push(notification)
|
|
||||||
else projectAll.set(directory, [notification])
|
|
||||||
|
|
||||||
if (!notification.viewed) {
|
|
||||||
const unseen = projectUnseen.get(directory)
|
|
||||||
if (unseen) unseen.push(notification)
|
|
||||||
else projectUnseen.set(directory, [notification])
|
|
||||||
|
|
||||||
projectUnseenCount.set(directory, (projectUnseenCount.get(directory) ?? 0) + 1)
|
|
||||||
if (notification.type === "error") projectUnseenHasError.set(directory, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
session: {
|
|
||||||
all: sessionAll,
|
|
||||||
unseen: sessionUnseen,
|
|
||||||
unseenCount: sessionUnseenCount,
|
|
||||||
unseenHasError: sessionUnseenHasError,
|
|
||||||
},
|
|
||||||
project: {
|
|
||||||
all: projectAll,
|
|
||||||
unseen: projectUnseen,
|
|
||||||
unseenCount: projectUnseenCount,
|
|
||||||
unseenHasError: projectUnseenHasError,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import { buildNotificationIndex } from "./notification-index"
|
|
||||||
|
|
||||||
type Notification = {
|
|
||||||
type: "turn-complete" | "error"
|
|
||||||
session: string
|
|
||||||
directory: string
|
|
||||||
viewed: boolean
|
|
||||||
time: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const turn = (session: string, directory: string, viewed = false): Notification => ({
|
|
||||||
type: "turn-complete",
|
|
||||||
session,
|
|
||||||
directory,
|
|
||||||
viewed,
|
|
||||||
time: 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
const error = (session: string, directory: string, viewed = false): Notification => ({
|
|
||||||
type: "error",
|
|
||||||
session,
|
|
||||||
directory,
|
|
||||||
viewed,
|
|
||||||
time: 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("buildNotificationIndex", () => {
|
|
||||||
test("builds unseen counts and unseen error flags", () => {
|
|
||||||
const list = [
|
|
||||||
turn("s1", "d1", false),
|
|
||||||
error("s1", "d1", false),
|
|
||||||
turn("s1", "d1", true),
|
|
||||||
turn("s2", "d1", false),
|
|
||||||
error("s3", "d2", true),
|
|
||||||
]
|
|
||||||
|
|
||||||
const index = buildNotificationIndex(list)
|
|
||||||
|
|
||||||
expect(index.session.all.get("s1")?.length).toBe(3)
|
|
||||||
expect(index.session.unseen.get("s1")?.length).toBe(2)
|
|
||||||
expect(index.session.unseenCount.get("s1")).toBe(2)
|
|
||||||
expect(index.session.unseenHasError.get("s1")).toBe(true)
|
|
||||||
|
|
||||||
expect(index.session.unseenCount.get("s2")).toBe(1)
|
|
||||||
expect(index.session.unseenHasError.get("s2") ?? false).toBe(false)
|
|
||||||
expect(index.session.unseenCount.get("s3") ?? 0).toBe(0)
|
|
||||||
expect(index.session.unseenHasError.get("s3") ?? false).toBe(false)
|
|
||||||
|
|
||||||
expect(index.project.unseenCount.get("d1")).toBe(3)
|
|
||||||
expect(index.project.unseenHasError.get("d1")).toBe(true)
|
|
||||||
expect(index.project.unseenCount.get("d2") ?? 0).toBe(0)
|
|
||||||
expect(index.project.unseenHasError.get("d2") ?? false).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("updates selectors after viewed transitions", () => {
|
|
||||||
const list = [turn("s1", "d1", false), error("s1", "d1", false), turn("s2", "d1", false)]
|
|
||||||
const next = list.map((item) => (item.session === "s1" ? { ...item, viewed: true } : item))
|
|
||||||
|
|
||||||
const before = buildNotificationIndex(list)
|
|
||||||
const after = buildNotificationIndex(next)
|
|
||||||
|
|
||||||
expect(before.session.unseenCount.get("s1")).toBe(2)
|
|
||||||
expect(before.session.unseenHasError.get("s1")).toBe(true)
|
|
||||||
expect(before.project.unseenCount.get("d1")).toBe(3)
|
|
||||||
expect(before.project.unseenHasError.get("d1")).toBe(true)
|
|
||||||
|
|
||||||
expect(after.session.unseenCount.get("s1") ?? 0).toBe(0)
|
|
||||||
expect(after.session.unseenHasError.get("s1") ?? false).toBe(false)
|
|
||||||
expect(after.project.unseenCount.get("d1")).toBe(1)
|
|
||||||
expect(after.project.unseenHasError.get("d1") ?? false).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
|
import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
|
||||||
import type { Accessor } from "solid-js"
|
import type { Accessor } from "solid-js"
|
||||||
|
import { ServerConnection } from "./server"
|
||||||
|
|
||||||
type PickerPaths = string | string[] | null
|
type PickerPaths = string | string[] | null
|
||||||
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
||||||
@@ -58,10 +59,10 @@ export type Platform = {
|
|||||||
fetch?: typeof fetch
|
fetch?: typeof fetch
|
||||||
|
|
||||||
/** Get the configured default server URL (platform-specific) */
|
/** Get the configured default server URL (platform-specific) */
|
||||||
getDefaultServerUrl?(): Promise<string | null>
|
getDefaultServer?(): Promise<ServerConnection.Key | null>
|
||||||
|
|
||||||
/** Set the default server URL to use on app startup (platform-specific) */
|
/** Set the default server URL to use on app startup (platform-specific) */
|
||||||
setDefaultServerUrl?(url: string | null): Promise<void> | void
|
setDefaultServer?(url: ServerConnection.Key | null): Promise<void> | void
|
||||||
|
|
||||||
/** Get the configured WSL integration (desktop only) */
|
/** Get the configured WSL integration (desktop only) */
|
||||||
getWslEnabled?(): Promise<boolean>
|
getWslEnabled?(): Promise<boolean>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
|
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { usePlatform } from "@/context/platform"
|
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { checkServerHealth } from "@/utils/server-health"
|
import { useCheckServerHealth } from "@/utils/server-health"
|
||||||
|
|
||||||
type StoredProject = { worktree: string; expanded: boolean }
|
type StoredProject = { worktree: string; expanded: boolean }
|
||||||
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
|
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
|
||||||
@@ -96,7 +95,7 @@ export namespace ServerConnection {
|
|||||||
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
|
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
|
||||||
name: "Server",
|
name: "Server",
|
||||||
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
|
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
|
||||||
const platform = usePlatform()
|
const checkServerHealth = useCheckServerHealth()
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
Persist.global("server", ["server.v3"]),
|
Persist.global("server", ["server.v3"]),
|
||||||
@@ -197,8 +196,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
|
|
||||||
const isReady = createMemo(() => ready() && !!state.active)
|
const isReady = createMemo(() => ready() && !!state.active)
|
||||||
|
|
||||||
const fetcher = platform.fetch ?? globalThis.fetch
|
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http).then((x) => x.healthy)
|
||||||
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http, fetcher).then((x) => x.healthy)
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const current_ = current()
|
const current_ = current()
|
||||||
|
|||||||
@@ -3,9 +3,16 @@ import { createStore, produce, reconcile } from "solid-js/store"
|
|||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
import { retry } from "@opencode-ai/util/retry"
|
import { retry } from "@opencode-ai/util/retry"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
|
import {
|
||||||
|
clearSessionPrefetch,
|
||||||
|
getSessionPrefetch,
|
||||||
|
getSessionPrefetchPromise,
|
||||||
|
setSessionPrefetch,
|
||||||
|
} from "./global-sync/session-prefetch"
|
||||||
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 +115,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 +130,63 @@ 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
|
||||||
|
clearSessionPrefetch(directory, sessionIDs)
|
||||||
|
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 +201,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 +216,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) {
|
||||||
@@ -155,9 +224,16 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
setMeta("limit", key, input.limit)
|
setMeta("limit", key, input.limit)
|
||||||
setMeta("complete", key, next.complete)
|
setMeta("complete", key, next.complete)
|
||||||
|
setSessionPrefetch({
|
||||||
|
directory: input.directory,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
limit: input.limit,
|
||||||
|
complete: next.complete,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
if (!tracked(input.directory, input.sessionID)) return
|
||||||
setMeta("loading", key, false)
|
setMeta("loading", key, false)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -199,6 +275,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 +284,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, {
|
||||||
@@ -215,67 +293,103 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
parts: input.parts,
|
parts: input.parts,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
async sync(sessionID: string) {
|
async sync(sessionID: string, opts?: { force?: boolean }) {
|
||||||
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)
|
||||||
const key = keyFor(directory, sessionID)
|
const key = keyFor(directory, sessionID)
|
||||||
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
|
|
||||||
|
|
||||||
const limit = meta.limit[key] ?? messagePageSize
|
touch(directory, setStore, sessionID)
|
||||||
|
|
||||||
const sessionReq = hasSession
|
const seeded = getSessionPrefetch(directory, sessionID)
|
||||||
? Promise.resolve()
|
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
|
||||||
: retry(() => client.session.get({ sessionID })).then((session) => {
|
batch(() => {
|
||||||
const data = session.data
|
setMeta("limit", key, seeded.limit)
|
||||||
if (!data) return
|
setMeta("complete", key, seeded.complete)
|
||||||
setStore(
|
setMeta("loading", key, false)
|
||||||
"session",
|
})
|
||||||
produce((draft) => {
|
}
|
||||||
const match = Binary.search(draft, sessionID, (s) => s.id)
|
|
||||||
if (match.found) {
|
|
||||||
draft[match.index] = data
|
|
||||||
return
|
|
||||||
}
|
|
||||||
draft.splice(match.index, 0, data)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const messagesReq = loadMessages({
|
return runInflight(inflight, key, async () => {
|
||||||
directory,
|
const pending = getSessionPrefetchPromise(directory, sessionID)
|
||||||
client,
|
if (pending) {
|
||||||
setStore,
|
await pending
|
||||||
sessionID,
|
const seeded = getSessionPrefetch(directory, sessionID)
|
||||||
limit,
|
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
|
||||||
|
batch(() => {
|
||||||
|
setMeta("limit", key, seeded.limit)
|
||||||
|
setMeta("complete", key, seeded.complete)
|
||||||
|
setMeta("loading", key, false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
|
||||||
|
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
|
||||||
|
if (cached && hasSession && !opts?.force) return
|
||||||
|
|
||||||
|
const limit = meta.limit[key] ?? messagePageSize
|
||||||
|
const sessionReq =
|
||||||
|
hasSession && !opts?.force
|
||||||
|
? Promise.resolve()
|
||||||
|
: retry(() => client.session.get({ sessionID })).then((session) => {
|
||||||
|
if (!tracked(directory, sessionID)) return
|
||||||
|
const data = session.data
|
||||||
|
if (!data) return
|
||||||
|
setStore(
|
||||||
|
"session",
|
||||||
|
produce((draft) => {
|
||||||
|
const match = Binary.search(draft, sessionID, (s) => s.id)
|
||||||
|
if (match.found) {
|
||||||
|
draft[match.index] = data
|
||||||
|
return
|
||||||
|
}
|
||||||
|
draft.splice(match.index, 0, data)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const messagesReq =
|
||||||
|
cached && !opts?.force
|
||||||
|
? Promise.resolve()
|
||||||
|
: loadMessages({
|
||||||
|
directory,
|
||||||
|
client,
|
||||||
|
setStore,
|
||||||
|
sessionID,
|
||||||
|
limit,
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.all([sessionReq, messagesReq])
|
||||||
})
|
})
|
||||||
|
|
||||||
return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {}))
|
|
||||||
},
|
},
|
||||||
async diff(sessionID: string) {
|
async diff(sessionID: string, opts?: { force?: boolean }) {
|
||||||
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)
|
||||||
if (store.session_diff[sessionID] !== undefined) return
|
touch(directory, setStore, sessionID)
|
||||||
|
if (store.session_diff[sessionID] !== undefined && !opts?.force) 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" }))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
async todo(sessionID: string) {
|
async todo(sessionID: string, opts?: { force?: boolean }) {
|
||||||
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) {
|
||||||
if (cached === undefined) {
|
if (cached === undefined) {
|
||||||
globalSync.todo.set(sessionID, existing)
|
globalSync.todo.set(sessionID, existing)
|
||||||
}
|
}
|
||||||
return
|
if (!opts?.force) return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cached !== undefined) {
|
if (cached !== undefined) {
|
||||||
@@ -285,6 +399,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 +423,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 +439,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),
|
||||||
|
|||||||
+20
-13
@@ -98,6 +98,19 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) {
|
|||||||
throw new Error(getRootNotFoundError())
|
throw new Error(getRootNotFoundError())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getCurrentUrl = () => {
|
||||||
|
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
||||||
|
if (import.meta.env.DEV)
|
||||||
|
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||||
|
return location.origin
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDefaultUrl = () => {
|
||||||
|
const lsDefault = readDefaultServerUrl()
|
||||||
|
if (lsDefault) return lsDefault
|
||||||
|
return getCurrentUrl()
|
||||||
|
}
|
||||||
|
|
||||||
const platform: Platform = {
|
const platform: Platform = {
|
||||||
platform: "web",
|
platform: "web",
|
||||||
version: pkg.version,
|
version: pkg.version,
|
||||||
@@ -106,26 +119,20 @@ const platform: Platform = {
|
|||||||
forward,
|
forward,
|
||||||
restart,
|
restart,
|
||||||
notify,
|
notify,
|
||||||
getDefaultServerUrl: async () => readDefaultServerUrl(),
|
getDefaultServer: async () => {
|
||||||
setDefaultServerUrl: writeDefaultServerUrl,
|
const stored = readDefaultServerUrl()
|
||||||
|
return stored ? ServerConnection.Key.make(stored) : null
|
||||||
|
},
|
||||||
|
setDefaultServer: writeDefaultServerUrl,
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultUrl = iife(() => {
|
|
||||||
const lsDefault = readDefaultServerUrl()
|
|
||||||
if (lsDefault) return lsDefault
|
|
||||||
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
|
||||||
if (import.meta.env.DEV)
|
|
||||||
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
|
||||||
return location.origin
|
|
||||||
})
|
|
||||||
|
|
||||||
if (root instanceof HTMLElement) {
|
if (root instanceof HTMLElement) {
|
||||||
const server: ServerConnection.Http = { type: "http", http: { url: defaultUrl } }
|
const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } }
|
||||||
render(
|
render(
|
||||||
() => (
|
() => (
|
||||||
<PlatformProvider value={platform}>
|
<PlatformProvider value={platform}>
|
||||||
<AppBaseProviders>
|
<AppBaseProviders>
|
||||||
<AppInterface defaultServer={ServerConnection.key(server)} servers={[server]} />
|
<AppInterface defaultServer={ServerConnection.Key.make(getDefaultUrl())} servers={[server]} />
|
||||||
</AppBaseProviders>
|
</AppBaseProviders>
|
||||||
</PlatformProvider>
|
</PlatformProvider>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -18,25 +18,27 @@ const popularProviderSet = new Set(popularProviders)
|
|||||||
export function useProviders() {
|
export function useProviders() {
|
||||||
const globalSync = useGlobalSync()
|
const globalSync = useGlobalSync()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const currentDirectory = createMemo(() => decode64(params.dir) ?? "")
|
const dir = createMemo(() => decode64(params.dir) ?? "")
|
||||||
const providers = createMemo(() => {
|
const providers = () => {
|
||||||
if (currentDirectory()) {
|
if (dir()) {
|
||||||
const [projectStore] = globalSync.child(currentDirectory())
|
const [projectStore] = globalSync.child(dir())
|
||||||
return projectStore.provider
|
return projectStore.provider
|
||||||
}
|
}
|
||||||
return globalSync.data.provider
|
return globalSync.data.provider
|
||||||
})
|
}
|
||||||
const connectedIDs = createMemo(() => new Set(providers().connected))
|
|
||||||
const connected = createMemo(() => providers().all.filter((p) => connectedIDs().has(p.id)))
|
|
||||||
const paid = createMemo(() =>
|
|
||||||
connected().filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input)),
|
|
||||||
)
|
|
||||||
const popular = createMemo(() => providers().all.filter((p) => popularProviderSet.has(p.id)))
|
|
||||||
return {
|
return {
|
||||||
all: createMemo(() => providers().all),
|
all: () => providers().all,
|
||||||
default: createMemo(() => providers().default),
|
default: () => providers().default,
|
||||||
popular,
|
popular: () => providers().all.filter((p) => popularProviderSet.has(p.id)),
|
||||||
connected,
|
connected: () => {
|
||||||
paid,
|
const connected = new Set(providers().connected)
|
||||||
|
return providers().all.filter((p) => connected.has(p.id))
|
||||||
|
},
|
||||||
|
paid: () => {
|
||||||
|
const connected = new Set(providers().connected)
|
||||||
|
return providers().all.filter(
|
||||||
|
(p) => connected.has(p.id) && (p.id !== "opencode" || Object.values(p.models).some((m) => m.cost?.input)),
|
||||||
|
)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -530,7 +530,13 @@ export const dict = {
|
|||||||
"session.todo.title": "Todos",
|
"session.todo.title": "Todos",
|
||||||
"session.todo.collapse": "Collapse",
|
"session.todo.collapse": "Collapse",
|
||||||
"session.todo.expand": "Expand",
|
"session.todo.expand": "Expand",
|
||||||
|
"session.revertDock.summary.one": "{{count}} rolled back message",
|
||||||
|
"session.revertDock.summary.other": "{{count}} rolled back messages",
|
||||||
|
"session.revertDock.collapse": "Collapse rolled back messages",
|
||||||
|
"session.revertDock.expand": "Expand rolled back messages",
|
||||||
|
"session.revertDock.restore": "Restore message",
|
||||||
|
|
||||||
|
"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",
|
||||||
|
|||||||
@@ -463,6 +463,7 @@ export const dict = {
|
|||||||
"session.todo.title": "Tâches",
|
"session.todo.title": "Tâches",
|
||||||
"session.todo.collapse": "Réduire",
|
"session.todo.collapse": "Réduire",
|
||||||
"session.todo.expand": "Développer",
|
"session.todo.expand": "Développer",
|
||||||
|
"session.new.title": "Créez ce que vous voulez",
|
||||||
"session.new.worktree.main": "Branche principale",
|
"session.new.worktree.main": "Branche principale",
|
||||||
"session.new.worktree.mainWithBranch": "Branche principale ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Branche principale ({{branch}})",
|
||||||
"session.new.worktree.create": "Créer un nouvel arbre de travail",
|
"session.new.worktree.create": "Créer un nouvel arbre de travail",
|
||||||
|
|||||||
@@ -457,6 +457,7 @@ export const dict = {
|
|||||||
"session.todo.title": "ToDo",
|
"session.todo.title": "ToDo",
|
||||||
"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": "할 일",
|
"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": "새 작업 트리 생성",
|
||||||
|
|||||||
@@ -516,6 +516,7 @@ export const dict = {
|
|||||||
"session.todo.collapse": "Skjul",
|
"session.todo.collapse": "Skjul",
|
||||||
"session.todo.expand": "Utvid",
|
"session.todo.expand": "Utvid",
|
||||||
|
|
||||||
|
"session.new.title": "Bygg hva 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": "Opprett nytt worktree",
|
"session.new.worktree.create": "Opprett nytt worktree",
|
||||||
|
|||||||
@@ -458,6 +458,7 @@ export const dict = {
|
|||||||
"session.todo.title": "Zadania",
|
"session.todo.title": "Zadania",
|
||||||
"session.todo.collapse": "Zwiń",
|
"session.todo.collapse": "Zwiń",
|
||||||
"session.todo.expand": "Rozwiń",
|
"session.todo.expand": "Rozwiń",
|
||||||
|
"session.new.title": "Zbuduj cokolwiek",
|
||||||
"session.new.worktree.main": "Główna gałąź",
|
"session.new.worktree.main": "Główna gałąź",
|
||||||
"session.new.worktree.mainWithBranch": "Główna gałąź ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Główna gałąź ({{branch}})",
|
||||||
"session.new.worktree.create": "Utwórz nowe drzewo robocze",
|
"session.new.worktree.create": "Utwórz nowe drzewo robocze",
|
||||||
|
|||||||
@@ -514,6 +514,7 @@ export const dict = {
|
|||||||
"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": "Создать новый worktree",
|
"session.new.worktree.create": "Создать новый worktree",
|
||||||
|
|||||||
@@ -511,6 +511,7 @@ export const dict = {
|
|||||||
"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": "สร้าง worktree ใหม่",
|
"session.new.worktree.create": "สร้าง worktree ใหม่",
|
||||||
|
|||||||
@@ -523,6 +523,7 @@ export const dict = {
|
|||||||
"session.todo.collapse": "Daralt",
|
"session.todo.collapse": "Daralt",
|
||||||
"session.todo.expand": "Genişlet",
|
"session.todo.expand": "Genişlet",
|
||||||
|
|
||||||
|
"session.new.title": "İstediğini yap",
|
||||||
"session.new.worktree.main": "Ana dal",
|
"session.new.worktree.main": "Ana dal",
|
||||||
"session.new.worktree.mainWithBranch": "Ana dal ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Ana dal ({{branch}})",
|
||||||
"session.new.worktree.create": "Yeni çalışma ağacı oluştur",
|
"session.new.worktree.create": "Yeni çalışma ağacı oluştur",
|
||||||
|
|||||||
@@ -510,6 +510,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": "创建新的 worktree",
|
"session.new.worktree.create": "创建新的 worktree",
|
||||||
|
|||||||
@@ -507,6 +507,7 @@ export const dict = {
|
|||||||
"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": "建立新的 worktree",
|
"session.new.worktree.create": "建立新的 worktree",
|
||||||
|
|||||||
+318
-263
@@ -34,6 +34,16 @@ import { useProviders } from "@/hooks/use-providers"
|
|||||||
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
|
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { clearWorkspaceTerminals } from "@/context/terminal"
|
import { clearWorkspaceTerminals } from "@/context/terminal"
|
||||||
|
import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
|
||||||
|
import {
|
||||||
|
clearSessionPrefetchInflight,
|
||||||
|
clearSessionPrefetch,
|
||||||
|
getSessionPrefetch,
|
||||||
|
isSessionPrefetchCurrent,
|
||||||
|
runSessionPrefetch,
|
||||||
|
SESSION_PREFETCH_TTL,
|
||||||
|
setSessionPrefetch,
|
||||||
|
} from "@/context/global-sync/session-prefetch"
|
||||||
import { useNotification } from "@/context/notification"
|
import { useNotification } from "@/context/notification"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
@@ -50,9 +60,10 @@ import { DialogSelectProvider } from "@/components/dialog-select-provider"
|
|||||||
import { DialogSelectServer } from "@/components/dialog-select-server"
|
import { DialogSelectServer } from "@/components/dialog-select-server"
|
||||||
import { DialogSettings } from "@/components/dialog-settings"
|
import { DialogSettings } from "@/components/dialog-settings"
|
||||||
import { useCommand, type CommandOption } from "@/context/command"
|
import { useCommand, type CommandOption } from "@/context/command"
|
||||||
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||||
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
||||||
import { DialogEditProject } from "@/components/dialog-edit-project"
|
import { DialogEditProject } from "@/components/dialog-edit-project"
|
||||||
|
import { DebugBar } from "@/components/debug-bar"
|
||||||
import { Titlebar } from "@/components/titlebar"
|
import { Titlebar } from "@/components/titlebar"
|
||||||
import { useServer } from "@/context/server"
|
import { useServer } from "@/context/server"
|
||||||
import { useLanguage, type Locale } from "@/context/language"
|
import { useLanguage, type Locale } from "@/context/language"
|
||||||
@@ -60,7 +71,6 @@ import {
|
|||||||
displayName,
|
displayName,
|
||||||
effectiveWorkspaceOrder,
|
effectiveWorkspaceOrder,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
getDraggableId,
|
|
||||||
latestRootSession,
|
latestRootSession,
|
||||||
sortedRootSessions,
|
sortedRootSessions,
|
||||||
workspaceKey,
|
workspaceKey,
|
||||||
@@ -78,7 +88,6 @@ import {
|
|||||||
WorkspaceDragOverlay,
|
WorkspaceDragOverlay,
|
||||||
type WorkspaceSidebarContext,
|
type WorkspaceSidebarContext,
|
||||||
} from "./layout/sidebar-workspace"
|
} from "./layout/sidebar-workspace"
|
||||||
import { workspaceOpenState } from "./layout/sidebar-workspace-helpers"
|
|
||||||
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
|
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
|
||||||
import { SidebarContent } from "./layout/sidebar-shell"
|
import { SidebarContent } from "./layout/sidebar-shell"
|
||||||
|
|
||||||
@@ -423,6 +432,17 @@ export default function Layout(props: ParentProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
e.details?.type === "question.replied" ||
|
||||||
|
e.details?.type === "question.rejected" ||
|
||||||
|
e.details?.type === "permission.replied"
|
||||||
|
) {
|
||||||
|
const props = e.details.properties as { sessionID: string }
|
||||||
|
const sessionKey = `${e.name}:${props.sessionID}`
|
||||||
|
dismissSessionAlert(sessionKey)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return
|
if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return
|
||||||
const title =
|
const title =
|
||||||
e.details.type === "permission.asked"
|
e.details.type === "permission.asked"
|
||||||
@@ -651,41 +671,57 @@ export default function Layout(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const prefetchChunk = 200
|
const prefetchChunk = 200
|
||||||
const prefetchConcurrency = 1
|
const prefetchConcurrency = 2
|
||||||
const prefetchPendingLimit = 6
|
const prefetchPendingLimit = 10
|
||||||
|
const span = 4
|
||||||
const prefetchToken = { value: 0 }
|
const prefetchToken = { value: 0 }
|
||||||
const prefetchQueues = new Map<string, PrefetchQueue>()
|
const prefetchQueues = new Map<string, PrefetchQueue>()
|
||||||
|
|
||||||
const PREFETCH_MAX_SESSIONS_PER_DIR = 10
|
const PREFETCH_MAX_SESSIONS_PER_DIR = 10
|
||||||
const prefetchedByDir = new Map<string, Map<string, true>>()
|
const prefetchedByDir = new Map<string, Set<string>>()
|
||||||
|
|
||||||
const lruFor = (directory: string) => {
|
const lruFor = (directory: string) => {
|
||||||
const existing = prefetchedByDir.get(directory)
|
const existing = prefetchedByDir.get(directory)
|
||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
const created = new Map<string, true>()
|
const created = new Set<string>()
|
||||||
prefetchedByDir.set(directory, created)
|
prefetchedByDir.set(directory, created)
|
||||||
return created
|
return created
|
||||||
}
|
}
|
||||||
|
|
||||||
const markPrefetched = (directory: string, sessionID: string) => {
|
const markPrefetched = (directory: string, sessionID: string) => {
|
||||||
const lru = lruFor(directory)
|
const lru = lruFor(directory)
|
||||||
if (lru.has(sessionID)) lru.delete(sessionID)
|
return pickSessionCacheEvictions({
|
||||||
lru.set(sessionID, true)
|
seen: lru,
|
||||||
while (lru.size > PREFETCH_MAX_SESSIONS_PER_DIR) {
|
keep: sessionID,
|
||||||
const oldest = lru.keys().next().value as string | undefined
|
limit: PREFETCH_MAX_SESSIONS_PER_DIR,
|
||||||
if (!oldest) return
|
preserve: directory === params.dir && params.id ? [params.id] : undefined,
|
||||||
lru.delete(oldest)
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const active = new Set(visibleSessionDirs())
|
||||||
|
for (const directory of [...prefetchedByDir.keys()]) {
|
||||||
|
if (active.has(directory)) continue
|
||||||
|
prefetchedByDir.delete(directory)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
params.dir
|
params.dir
|
||||||
globalSDK.url
|
globalSDK.url
|
||||||
|
|
||||||
prefetchToken.value += 1
|
prefetchToken.value += 1
|
||||||
for (const q of prefetchQueues.values()) {
|
clearSessionPrefetchInflight()
|
||||||
|
prefetchQueues.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const visible = new Set(visibleSessionDirs())
|
||||||
|
for (const [directory, q] of prefetchQueues) {
|
||||||
|
if (visible.has(directory)) continue
|
||||||
q.pending.length = 0
|
q.pending.length = 0
|
||||||
q.pendingSet.clear()
|
q.pendingSet.clear()
|
||||||
|
if (q.running === 0) prefetchQueues.delete(directory)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -721,35 +757,67 @@ export default function Layout(props: ParentProps) {
|
|||||||
async function prefetchMessages(directory: string, sessionID: string, token: number) {
|
async function prefetchMessages(directory: string, sessionID: string, token: number) {
|
||||||
const [store, setStore] = globalSync.child(directory, { bootstrap: false })
|
const [store, setStore] = globalSync.child(directory, { bootstrap: false })
|
||||||
|
|
||||||
return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
|
return runSessionPrefetch({
|
||||||
.then((messages) => {
|
directory,
|
||||||
if (prefetchToken.value !== token) return
|
sessionID,
|
||||||
|
task: (rev) =>
|
||||||
|
retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
|
||||||
|
.then((messages) => {
|
||||||
|
if (prefetchToken.value !== token) return
|
||||||
|
if (!isSessionPrefetchCurrent(directory, sessionID, rev)) return
|
||||||
|
|
||||||
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
||||||
const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
|
const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
|
||||||
const sorted = mergeByID([], next)
|
const sorted = mergeByID([], next)
|
||||||
|
const stale = markPrefetched(directory, sessionID)
|
||||||
|
const meta = {
|
||||||
|
limit: prefetchChunk,
|
||||||
|
complete: sorted.length < prefetchChunk,
|
||||||
|
at: Date.now(),
|
||||||
|
}
|
||||||
|
|
||||||
const current = store.message[sessionID] ?? []
|
if (stale.length > 0) {
|
||||||
const merged = mergeByID(
|
clearSessionPrefetch(directory, stale)
|
||||||
current.filter((item): item is Message => !!item?.id),
|
for (const id of stale) {
|
||||||
sorted,
|
globalSync.todo.set(id, undefined)
|
||||||
)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
batch(() => {
|
const current = store.message[sessionID] ?? []
|
||||||
setStore("message", sessionID, reconcile(merged, { key: "id" }))
|
const merged = mergeByID(
|
||||||
|
current.filter((item): item is Message => !!item?.id),
|
||||||
for (const message of items) {
|
sorted,
|
||||||
const currentParts = store.part[message.info.id] ?? []
|
|
||||||
const mergedParts = mergeByID(
|
|
||||||
currentParts.filter((item): item is (typeof currentParts)[number] & { id: string } => !!item?.id),
|
|
||||||
message.parts.filter((item): item is (typeof message.parts)[number] & { id: string } => !!item?.id),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
setStore("part", message.info.id, reconcile(mergedParts, { key: "id" }))
|
if (!isSessionPrefetchCurrent(directory, sessionID, rev)) return
|
||||||
}
|
|
||||||
})
|
batch(() => {
|
||||||
})
|
if (stale.length > 0) {
|
||||||
.catch(() => undefined)
|
setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
dropSessionCaches(draft, stale)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
setStore("message", sessionID, reconcile(merged, { key: "id" }))
|
||||||
|
setSessionPrefetch({ directory, sessionID, ...meta })
|
||||||
|
|
||||||
|
for (const message of items) {
|
||||||
|
const currentParts = store.part[message.info.id] ?? []
|
||||||
|
const mergedParts = mergeByID(
|
||||||
|
currentParts.filter((item): item is (typeof currentParts)[number] & { id: string } => !!item?.id),
|
||||||
|
message.parts.filter((item): item is (typeof message.parts)[number] & { id: string } => !!item?.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
setStore("part", message.info.id, reconcile(mergedParts, { key: "id" }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return meta
|
||||||
|
})
|
||||||
|
.catch(() => undefined),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const pumpPrefetch = (directory: string) => {
|
const pumpPrefetch = (directory: string) => {
|
||||||
@@ -777,17 +845,29 @@ export default function Layout(props: ParentProps) {
|
|||||||
if (!directory) return
|
if (!directory) return
|
||||||
|
|
||||||
const [store] = globalSync.child(directory, { bootstrap: false })
|
const [store] = globalSync.child(directory, { bootstrap: false })
|
||||||
const cached = untrack(() => store.message[session.id] !== undefined)
|
const cached = untrack(() => {
|
||||||
|
if (store.message[session.id] === undefined) return false
|
||||||
|
const info = getSessionPrefetch(directory, session.id)
|
||||||
|
if (!info) return false
|
||||||
|
return Date.now() - info.at < SESSION_PREFETCH_TTL
|
||||||
|
})
|
||||||
if (cached) return
|
if (cached) return
|
||||||
|
|
||||||
const q = queueFor(directory)
|
const q = queueFor(directory)
|
||||||
if (q.inflight.has(session.id)) return
|
if (q.inflight.has(session.id)) return
|
||||||
if (q.pendingSet.has(session.id)) return
|
if (q.pendingSet.has(session.id)) {
|
||||||
|
if (priority !== "high") return
|
||||||
|
const index = q.pending.indexOf(session.id)
|
||||||
|
if (index > 0) {
|
||||||
|
q.pending.splice(index, 1)
|
||||||
|
q.pending.unshift(session.id)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const lru = lruFor(directory)
|
const lru = lruFor(directory)
|
||||||
const known = lru.has(session.id)
|
const known = lru.has(session.id)
|
||||||
if (!known && lru.size >= PREFETCH_MAX_SESSIONS_PER_DIR && priority !== "high") return
|
if (!known && lru.size >= PREFETCH_MAX_SESSIONS_PER_DIR && priority !== "high") return
|
||||||
markPrefetched(directory, session.id)
|
|
||||||
|
|
||||||
if (priority === "high") q.pending.unshift(session.id)
|
if (priority === "high") q.pending.unshift(session.id)
|
||||||
if (priority !== "high") q.pending.push(session.id)
|
if (priority !== "high") q.pending.push(session.id)
|
||||||
@@ -802,27 +882,29 @@ export default function Layout(props: ParentProps) {
|
|||||||
pumpPrefetch(directory)
|
pumpPrefetch(directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const warm = (sessions: Session[], index: number) => {
|
||||||
|
for (let offset = 1; offset <= span; offset++) {
|
||||||
|
const next = sessions[index + offset]
|
||||||
|
if (next) prefetchSession(next, offset === 1 ? "high" : "low")
|
||||||
|
|
||||||
|
const prev = sessions[index - offset]
|
||||||
|
if (prev) prefetchSession(prev, offset === 1 ? "high" : "low")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const sessions = currentSessions()
|
const sessions = currentSessions()
|
||||||
const id = params.id
|
if (sessions.length === 0) return
|
||||||
|
|
||||||
if (!id) {
|
const index = params.id ? sessions.findIndex((s) => s.id === params.id) : 0
|
||||||
const first = sessions[0]
|
|
||||||
if (first) prefetchSession(first)
|
|
||||||
|
|
||||||
const second = sessions[1]
|
|
||||||
if (second) prefetchSession(second)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const index = sessions.findIndex((s) => s.id === id)
|
|
||||||
if (index === -1) return
|
if (index === -1) return
|
||||||
|
|
||||||
const next = sessions[index + 1]
|
if (!params.id) {
|
||||||
if (next) prefetchSession(next)
|
const first = sessions[index]
|
||||||
|
if (first) prefetchSession(first, "high")
|
||||||
|
}
|
||||||
|
|
||||||
const prev = sessions[index - 1]
|
warm(sessions, index)
|
||||||
if (prev) prefetchSession(prev)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function navigateSessionByOffset(offset: number) {
|
function navigateSessionByOffset(offset: number) {
|
||||||
@@ -841,18 +923,8 @@ export default function Layout(props: ParentProps) {
|
|||||||
const session = sessions[targetIndex]
|
const session = sessions[targetIndex]
|
||||||
if (!session) return
|
if (!session) return
|
||||||
|
|
||||||
const next = sessions[(targetIndex + 1) % sessions.length]
|
prefetchSession(session, "high")
|
||||||
const prev = sessions[(targetIndex - 1 + sessions.length) % sessions.length]
|
warm(sessions, targetIndex)
|
||||||
|
|
||||||
if (offset > 0) {
|
|
||||||
if (next) prefetchSession(next, "high")
|
|
||||||
if (prev) prefetchSession(prev)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (offset < 0) {
|
|
||||||
if (prev) prefetchSession(prev, "high")
|
|
||||||
if (next) prefetchSession(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
navigateToSession(session)
|
navigateToSession(session)
|
||||||
}
|
}
|
||||||
@@ -874,19 +946,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
if (notification.session.unseenCount(session.id) === 0) continue
|
if (notification.session.unseenCount(session.id) === 0) continue
|
||||||
|
|
||||||
prefetchSession(session, "high")
|
prefetchSession(session, "high")
|
||||||
|
warm(sessions, index)
|
||||||
const next = sessions[(index + 1) % sessions.length]
|
|
||||||
const prev = sessions[(index - 1 + sessions.length) % sessions.length]
|
|
||||||
|
|
||||||
if (offset > 0) {
|
|
||||||
if (next) prefetchSession(next, "high")
|
|
||||||
if (prev) prefetchSession(prev)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (offset < 0) {
|
|
||||||
if (prev) prefetchSession(prev, "high")
|
|
||||||
if (next) prefetchSession(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
navigateToSession(session)
|
navigateToSession(session)
|
||||||
return
|
return
|
||||||
@@ -1820,6 +1880,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
|
|
||||||
const workspaceSidebarCtx: WorkspaceSidebarContext = {
|
const workspaceSidebarCtx: WorkspaceSidebarContext = {
|
||||||
currentDir,
|
currentDir,
|
||||||
|
navList: currentSessions,
|
||||||
sidebarExpanded,
|
sidebarExpanded,
|
||||||
sidebarHovering,
|
sidebarHovering,
|
||||||
nav: () => state.nav,
|
nav: () => state.nav,
|
||||||
@@ -1836,7 +1897,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
setEditor,
|
setEditor,
|
||||||
InlineEditor,
|
InlineEditor,
|
||||||
isBusy,
|
isBusy,
|
||||||
workspaceExpanded: (directory, local) => workspaceOpenState(store.workspaceExpanded, directory, local),
|
workspaceExpanded: (directory, local) => store.workspaceExpanded[directory] ?? local,
|
||||||
setWorkspaceExpanded: (directory, value) => setStore("workspaceExpanded", directory, value),
|
setWorkspaceExpanded: (directory, value) => setStore("workspaceExpanded", directory, value),
|
||||||
showResetWorkspaceDialog: (root, directory) =>
|
showResetWorkspaceDialog: (root, directory) =>
|
||||||
dialog.show(() => <DialogResetWorkspace root={root} directory={directory} />),
|
dialog.show(() => <DialogResetWorkspace root={root} directory={directory} />),
|
||||||
@@ -1865,6 +1926,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
workspaceIds,
|
workspaceIds,
|
||||||
workspaceLabel,
|
workspaceLabel,
|
||||||
sessionProps: {
|
sessionProps: {
|
||||||
|
navList: currentSessions,
|
||||||
sidebarExpanded,
|
sidebarExpanded,
|
||||||
sidebarHovering,
|
sidebarHovering,
|
||||||
nav: () => state.nav,
|
nav: () => state.nav,
|
||||||
@@ -1879,6 +1941,8 @@ export default function Layout(props: ParentProps) {
|
|||||||
|
|
||||||
const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean; merged?: boolean }) => {
|
const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean; merged?: boolean }) => {
|
||||||
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
|
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
|
||||||
|
const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened())
|
||||||
|
const popover = createMemo(() => !!panelProps.mobile || panelProps.merged === false || layout.sidebar.opened())
|
||||||
const projectName = createMemo(() => {
|
const projectName = createMemo(() => {
|
||||||
const project = panelProps.project
|
const project = panelProps.project
|
||||||
if (!project) return ""
|
if (!project) return ""
|
||||||
@@ -1904,11 +1968,11 @@ export default function Layout(props: ParentProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"flex flex-col min-h-0 min-w-0 rounded-tl-[12px] px-2": true,
|
"flex flex-col min-h-0 min-w-0 box-border rounded-tl-[12px] px-2": true,
|
||||||
"border border-b-0 border-border-weak-base": !merged(),
|
"border border-b-0 border-border-weak-base": !merged(),
|
||||||
"border-l border-t border-border-weaker-base": merged(),
|
"border-l border-t border-border-weaker-base": merged(),
|
||||||
"bg-background-base": merged(),
|
"bg-background-base": merged() || hover(),
|
||||||
"bg-background-stronger": !merged(),
|
"bg-background-stronger": !merged() && !hover(),
|
||||||
"flex-1 min-w-0": panelProps.mobile,
|
"flex-1 min-w-0": panelProps.mobile,
|
||||||
"max-w-full overflow-hidden": panelProps.mobile,
|
"max-w-full overflow-hidden": panelProps.mobile,
|
||||||
}}
|
}}
|
||||||
@@ -2022,6 +2086,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
project={p()}
|
project={p()}
|
||||||
sortNow={sortNow}
|
sortNow={sortNow}
|
||||||
mobile={panelProps.mobile}
|
mobile={panelProps.mobile}
|
||||||
|
popover={popover()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -2057,6 +2122,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
project={p()}
|
project={p()}
|
||||||
sortNow={sortNow}
|
sortNow={sortNow}
|
||||||
mobile={panelProps.mobile}
|
mobile={panelProps.mobile}
|
||||||
|
popover={popover()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
@@ -2110,194 +2176,183 @@ export default function Layout(props: ParentProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const projects = () => layout.projects.list()
|
||||||
|
const projectOverlay = () => <ProjectDragOverlay projects={projects} activeProject={() => store.activeProject} />
|
||||||
|
const sidebarContent = (mobile?: boolean) => (
|
||||||
|
<SidebarContent
|
||||||
|
mobile={mobile}
|
||||||
|
opened={() => layout.sidebar.opened()}
|
||||||
|
aimMove={aim.move}
|
||||||
|
projects={projects}
|
||||||
|
renderProject={(project) => (
|
||||||
|
<SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} mobile={mobile} />
|
||||||
|
)}
|
||||||
|
handleDragStart={handleDragStart}
|
||||||
|
handleDragEnd={handleDragEnd}
|
||||||
|
handleDragOver={handleDragOver}
|
||||||
|
openProjectLabel={language.t("command.project.open")}
|
||||||
|
openProjectKeybind={() => command.keybind("project.open")}
|
||||||
|
onOpenProject={chooseProject}
|
||||||
|
renderProjectOverlay={projectOverlay}
|
||||||
|
settingsLabel={() => language.t("sidebar.settings")}
|
||||||
|
settingsKeybind={() => command.keybind("settings.open")}
|
||||||
|
onOpenSettings={openSettings}
|
||||||
|
helpLabel={() => language.t("sidebar.help")}
|
||||||
|
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||||
|
renderPanel={() =>
|
||||||
|
mobile ? (
|
||||||
|
<SidebarPanel project={currentProject()} mobile />
|
||||||
|
) : (
|
||||||
|
<Show when={currentProject()} keyed>
|
||||||
|
{(project) => <SidebarPanel project={project} merged />}
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative bg-background-base flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||||
<Titlebar />
|
<Titlebar />
|
||||||
<div class="flex-1 min-h-0 relative overflow-x-hidden">
|
<div class="flex-1 min-h-0 min-w-0 flex">
|
||||||
<nav
|
<div class="flex-1 min-h-0 relative">
|
||||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
<div class="size-full relative overflow-x-hidden">
|
||||||
data-component="sidebar-nav-desktop"
|
<nav
|
||||||
classList={{
|
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||||
"hidden xl:block": true,
|
data-component="sidebar-nav-desktop"
|
||||||
"absolute inset-y-0 left-0": true,
|
classList={{
|
||||||
"z-10": true,
|
"hidden xl:block": true,
|
||||||
}}
|
"absolute inset-y-0 left-0": true,
|
||||||
style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }}
|
"z-10": true,
|
||||||
ref={(el) => {
|
}}
|
||||||
setState("nav", el)
|
style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }}
|
||||||
}}
|
ref={(el) => {
|
||||||
onMouseEnter={() => {
|
setState("nav", el)
|
||||||
disarm()
|
}}
|
||||||
}}
|
onMouseEnter={() => {
|
||||||
onMouseLeave={() => {
|
disarm()
|
||||||
aim.reset()
|
}}
|
||||||
if (!sidebarHovering()) return
|
onMouseLeave={() => {
|
||||||
|
aim.reset()
|
||||||
|
if (!sidebarHovering()) return
|
||||||
|
|
||||||
arm()
|
arm()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="@container w-full h-full contain-strict">
|
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
|
||||||
<SidebarContent
|
<Show when={layout.sidebar.opened()}>
|
||||||
opened={() => layout.sidebar.opened()}
|
<div onPointerDown={() => setSizing(true)}>
|
||||||
aimMove={aim.move}
|
<ResizeHandle
|
||||||
projects={() => layout.projects.list()}
|
direction="horizontal"
|
||||||
renderProject={(project) => (
|
size={layout.sidebar.width()}
|
||||||
<SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} />
|
min={244}
|
||||||
)}
|
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||||
handleDragStart={handleDragStart}
|
collapseThreshold={244}
|
||||||
handleDragEnd={handleDragEnd}
|
onResize={(w) => {
|
||||||
handleDragOver={handleDragOver}
|
setSizing(true)
|
||||||
openProjectLabel={language.t("command.project.open")}
|
if (sizet !== undefined) clearTimeout(sizet)
|
||||||
openProjectKeybind={() => command.keybind("project.open")}
|
sizet = window.setTimeout(() => setSizing(false), 120)
|
||||||
onOpenProject={chooseProject}
|
layout.sidebar.resize(w)
|
||||||
renderProjectOverlay={() => (
|
}}
|
||||||
<ProjectDragOverlay projects={() => layout.projects.list()} activeProject={() => store.activeProject} />
|
onCollapse={layout.sidebar.close}
|
||||||
)}
|
/>
|
||||||
settingsLabel={() => language.t("sidebar.settings")}
|
</div>
|
||||||
settingsKeybind={() => command.keybind("settings.open")}
|
</Show>
|
||||||
onOpenSettings={openSettings}
|
</nav>
|
||||||
helpLabel={() => language.t("sidebar.help")}
|
|
||||||
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
<div
|
||||||
renderPanel={() => (
|
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
|
||||||
<Show when={currentProject()} keyed>
|
style={{ left: "calc(4rem + 12px)" }}
|
||||||
{(project) => <SidebarPanel project={project} merged />}
|
|
||||||
</Show>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<Show when={layout.sidebar.opened()}>
|
<div class="xl:hidden">
|
||||||
<div onPointerDown={() => setSizing(true)}>
|
<div
|
||||||
<ResizeHandle
|
classList={{
|
||||||
direction="horizontal"
|
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
|
||||||
size={layout.sidebar.width()}
|
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
|
||||||
min={244}
|
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
|
||||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
}}
|
||||||
collapseThreshold={244}
|
onClick={(e) => {
|
||||||
onResize={(w) => {
|
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
|
||||||
setSizing(true)
|
|
||||||
if (sizet !== undefined) clearTimeout(sizet)
|
|
||||||
sizet = window.setTimeout(() => setSizing(false), 120)
|
|
||||||
layout.sidebar.resize(w)
|
|
||||||
}}
|
}}
|
||||||
onCollapse={layout.sidebar.close}
|
|
||||||
/>
|
/>
|
||||||
|
<nav
|
||||||
|
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||||
|
data-component="sidebar-nav-mobile"
|
||||||
|
classList={{
|
||||||
|
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
|
||||||
|
"translate-x-0": layout.mobileSidebar.opened(),
|
||||||
|
"-translate-x-full": !layout.mobileSidebar.opened(),
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{sidebarContent(true)}
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
|
classList={{
|
||||||
style={{ left: "calc(4rem + 12px)" }}
|
"absolute inset-0": true,
|
||||||
/>
|
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
|
||||||
|
"z-20": true,
|
||||||
|
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
|
||||||
|
!sizing(),
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
"--main-left": layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "4rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<main
|
||||||
|
classList={{
|
||||||
|
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show when={!autoselecting()} fallback={<div class="size-full" />}>
|
||||||
|
{props.children}
|
||||||
|
</Show>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="xl:hidden">
|
<div
|
||||||
<div
|
classList={{
|
||||||
classList={{
|
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
|
||||||
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
|
"opacity-100 translate-x-0 pointer-events-auto": peeked() && !layout.sidebar.opened(),
|
||||||
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
|
"opacity-0 -translate-x-2 pointer-events-none": !peeked() || layout.sidebar.opened(),
|
||||||
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
|
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||||
}}
|
"duration-180 ease-out": peeked() && !layout.sidebar.opened(),
|
||||||
onClick={(e) => {
|
"duration-120 ease-in": !peeked() || layout.sidebar.opened(),
|
||||||
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
|
}}
|
||||||
}}
|
onMouseMove={disarm}
|
||||||
/>
|
onMouseEnter={() => {
|
||||||
<nav
|
disarm()
|
||||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
aim.reset()
|
||||||
data-component="sidebar-nav-mobile"
|
}}
|
||||||
classList={{
|
onPointerDown={disarm}
|
||||||
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
|
onMouseLeave={() => {
|
||||||
"translate-x-0": layout.mobileSidebar.opened(),
|
arm()
|
||||||
"-translate-x-full": !layout.mobileSidebar.opened(),
|
}}
|
||||||
}}
|
>
|
||||||
onClick={(e) => e.stopPropagation()}
|
<Show when={peek()} keyed>
|
||||||
>
|
{(project) => <SidebarPanel project={project} merged={false} />}
|
||||||
<SidebarContent
|
</Show>
|
||||||
mobile
|
</div>
|
||||||
opened={() => layout.sidebar.opened()}
|
|
||||||
aimMove={aim.move}
|
|
||||||
projects={() => layout.projects.list()}
|
|
||||||
renderProject={(project) => (
|
|
||||||
<SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} mobile />
|
|
||||||
)}
|
|
||||||
handleDragStart={handleDragStart}
|
|
||||||
handleDragEnd={handleDragEnd}
|
|
||||||
handleDragOver={handleDragOver}
|
|
||||||
openProjectLabel={language.t("command.project.open")}
|
|
||||||
openProjectKeybind={() => command.keybind("project.open")}
|
|
||||||
onOpenProject={chooseProject}
|
|
||||||
renderProjectOverlay={() => (
|
|
||||||
<ProjectDragOverlay projects={() => layout.projects.list()} activeProject={() => store.activeProject} />
|
|
||||||
)}
|
|
||||||
settingsLabel={() => language.t("sidebar.settings")}
|
|
||||||
settingsKeybind={() => command.keybind("settings.open")}
|
|
||||||
onOpenSettings={openSettings}
|
|
||||||
helpLabel={() => language.t("sidebar.help")}
|
|
||||||
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
|
||||||
renderPanel={() => <SidebarPanel project={currentProject()} mobile />}
|
|
||||||
/>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"absolute inset-0": true,
|
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
|
||||||
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
|
"opacity-100 translate-x-0": peeked() && !layout.sidebar.opened(),
|
||||||
"z-20": true,
|
"opacity-0 -translate-x-2": !peeked() || layout.sidebar.opened(),
|
||||||
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
|
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||||
!sizing(),
|
"duration-180 ease-out": peeked() && !layout.sidebar.opened(),
|
||||||
}}
|
"duration-120 ease-in": !peeked() || layout.sidebar.opened(),
|
||||||
style={{
|
}}
|
||||||
"--main-left": layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "4rem",
|
style={{ left: `calc(4rem + ${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px)` }}
|
||||||
}}
|
>
|
||||||
>
|
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
|
||||||
<main
|
</div>
|
||||||
classList={{
|
</div>
|
||||||
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base xl:border-l xl:rounded-tl-[12px]": true,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Show when={!autoselecting()} fallback={<div class="size-full" />}>
|
|
||||||
{props.children}
|
|
||||||
</Show>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
|
|
||||||
"opacity-100 translate-x-0 pointer-events-auto": peeked() && !layout.sidebar.opened(),
|
|
||||||
"opacity-0 -translate-x-2 pointer-events-none": !peeked() || layout.sidebar.opened(),
|
|
||||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
|
||||||
"duration-180 ease-out": peeked() && !layout.sidebar.opened(),
|
|
||||||
"duration-120 ease-in": !peeked() || layout.sidebar.opened(),
|
|
||||||
}}
|
|
||||||
onMouseMove={disarm}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
disarm()
|
|
||||||
aim.reset()
|
|
||||||
}}
|
|
||||||
onPointerDown={disarm}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
arm()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Show when={peek()} keyed>
|
|
||||||
{(project) => <SidebarPanel project={project} merged={false} />}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
|
|
||||||
"opacity-100 translate-x-0": peeked() && !layout.sidebar.opened(),
|
|
||||||
"opacity-0 -translate-x-2": !peeked() || layout.sidebar.opened(),
|
|
||||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
|
||||||
"duration-180 ease-out": peeked() && !layout.sidebar.opened(),
|
|
||||||
"duration-120 ease-in": !peeked() || layout.sidebar.opened(),
|
|
||||||
}}
|
|
||||||
style={{ left: `calc(4rem + ${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px)` }}
|
|
||||||
>
|
|
||||||
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
|
|
||||||
</div>
|
</div>
|
||||||
|
{import.meta.env.DEV && <DebugBar />}
|
||||||
</div>
|
</div>
|
||||||
<Toast.Region />
|
<Toast.Region />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,9 +6,15 @@ import {
|
|||||||
parseDeepLink,
|
parseDeepLink,
|
||||||
parseNewSessionDeepLink,
|
parseNewSessionDeepLink,
|
||||||
} from "./deep-links"
|
} from "./deep-links"
|
||||||
import { displayName, errorMessage, getDraggableId, syncWorkspaceOrder, workspaceKey } from "./helpers"
|
|
||||||
import { type Session } from "@opencode-ai/sdk/v2/client"
|
import { type Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import { hasProjectPermissions, latestRootSession } from "./helpers"
|
import {
|
||||||
|
displayName,
|
||||||
|
effectiveWorkspaceOrder,
|
||||||
|
errorMessage,
|
||||||
|
hasProjectPermissions,
|
||||||
|
latestRootSession,
|
||||||
|
workspaceKey,
|
||||||
|
} from "./helpers"
|
||||||
|
|
||||||
const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
|
const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
|
||||||
({
|
({
|
||||||
@@ -110,7 +116,7 @@ describe("layout workspace helpers", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("keeps local first while preserving known order", () => {
|
test("keeps local first while preserving known order", () => {
|
||||||
const result = syncWorkspaceOrder("/root", ["/root", "/b", "/c"], ["/root", "/c", "/a", "/b"])
|
const result = effectiveWorkspaceOrder("/root", ["/root", "/b", "/c"], ["/root", "/c", "/a", "/b"])
|
||||||
expect(result).toEqual(["/root", "/c", "/b"])
|
expect(result).toEqual(["/root", "/c", "/b"])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -192,12 +198,6 @@ describe("layout workspace helpers", () => {
|
|||||||
expect(result?.id).toBe("root")
|
expect(result?.id).toBe("root")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("extracts draggable id safely", () => {
|
|
||||||
expect(getDraggableId({ draggable: { id: "x" } })).toBe("x")
|
|
||||||
expect(getDraggableId({ draggable: { id: 42 } })).toBeUndefined()
|
|
||||||
expect(getDraggableId(null)).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("formats fallback project display name", () => {
|
test("formats fallback project display name", () => {
|
||||||
expect(displayName({ worktree: "/tmp/app" })).toBe("app")
|
expect(displayName({ worktree: "/tmp/app" })).toBe("app")
|
||||||
expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App")
|
expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App")
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const workspaceKey = (directory: string) => {
|
|||||||
return directory.replace(/[\\/]+$/, "")
|
return directory.replace(/[\\/]+$/, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sortSessions(now: number) {
|
function sortSessions(now: number) {
|
||||||
const oneMinuteAgo = now - 60 * 1000
|
const oneMinuteAgo = now - 60 * 1000
|
||||||
return (a: Session, b: Session) => {
|
return (a: Session, b: Session) => {
|
||||||
const aUpdated = a.time.updated ?? a.time.created
|
const aUpdated = a.time.updated ?? a.time.created
|
||||||
@@ -22,7 +22,7 @@ export function sortSessions(now: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isRootVisibleSession = (session: Session, directory: string) =>
|
const isRootVisibleSession = (session: Session, directory: string) =>
|
||||||
workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived
|
workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived
|
||||||
|
|
||||||
export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) =>
|
export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) =>
|
||||||
@@ -54,14 +54,6 @@ export const childMapByParent = (sessions: Session[]) => {
|
|||||||
return map
|
return map
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDraggableId(event: unknown): string | undefined {
|
|
||||||
if (typeof event !== "object" || event === null) return undefined
|
|
||||||
if (!("draggable" in event)) return undefined
|
|
||||||
const draggable = (event as { draggable?: { id?: unknown } }).draggable
|
|
||||||
if (!draggable) return undefined
|
|
||||||
return typeof draggable.id === "string" ? draggable.id : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export const displayName = (project: { name?: string; worktree: string }) =>
|
export const displayName = (project: { name?: string; worktree: string }) =>
|
||||||
project.name || getFilename(project.worktree)
|
project.name || getFilename(project.worktree)
|
||||||
|
|
||||||
@@ -98,5 +90,3 @@ export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted
|
|||||||
|
|
||||||
return [...result, ...live.values()]
|
return [...result, ...live.values()]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const syncWorkspaceOrder = effectiveWorkspaceOrder
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { base64Encode } from "@opencode-ai/util/encode"
|
|||||||
import { getFilename } from "@opencode-ai/util/path"
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
import { A, useNavigate, useParams } from "@solidjs/router"
|
import { A, useNavigate, useParams } from "@solidjs/router"
|
||||||
import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js"
|
import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js"
|
||||||
|
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
|
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
|
||||||
@@ -67,6 +68,8 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
|
|||||||
|
|
||||||
export type SessionItemProps = {
|
export type SessionItemProps = {
|
||||||
session: Session
|
session: Session
|
||||||
|
list: Session[]
|
||||||
|
navList?: Accessor<Session[]>
|
||||||
slug: string
|
slug: string
|
||||||
mobile?: boolean
|
mobile?: boolean
|
||||||
dense?: boolean
|
dense?: boolean
|
||||||
@@ -95,18 +98,18 @@ const SessionRow = (props: {
|
|||||||
setHoverSession: (id: string | undefined) => void
|
setHoverSession: (id: string | undefined) => void
|
||||||
clearHoverProjectSoon: () => void
|
clearHoverProjectSoon: () => void
|
||||||
sidebarOpened: Accessor<boolean>
|
sidebarOpened: Accessor<boolean>
|
||||||
prefetchSession: (session: Session, priority?: "high" | "low") => void
|
warmHover: () => void
|
||||||
scheduleHoverPrefetch: () => void
|
warmPress: () => void
|
||||||
|
warmFocus: () => void
|
||||||
cancelHoverPrefetch: () => void
|
cancelHoverPrefetch: () => void
|
||||||
}): JSX.Element => (
|
}): JSX.Element => (
|
||||||
<A
|
<A
|
||||||
href={`/${props.slug}/session/${props.session.id}`}
|
href={`/${props.slug}/session/${props.session.id}`}
|
||||||
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
|
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
|
||||||
onPointerEnter={props.scheduleHoverPrefetch}
|
onPointerDown={props.warmPress}
|
||||||
|
onPointerEnter={props.warmHover}
|
||||||
onPointerLeave={props.cancelHoverPrefetch}
|
onPointerLeave={props.cancelHoverPrefetch}
|
||||||
onMouseEnter={props.scheduleHoverPrefetch}
|
onFocus={props.warmFocus}
|
||||||
onMouseLeave={props.cancelHoverPrefetch}
|
|
||||||
onFocus={() => props.prefetchSession(props.session, "high")}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
props.setHoverSession(undefined)
|
props.setHoverSession(undefined)
|
||||||
if (props.sidebarOpened()) return
|
if (props.sidebarOpened()) return
|
||||||
@@ -225,11 +228,37 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
|||||||
const hoverMessages = createMemo(() =>
|
const hoverMessages = createMemo(() =>
|
||||||
sessionStore.message[props.session.id]?.filter((message): message is UserMessage => message.role === "user"),
|
sessionStore.message[props.session.id]?.filter((message): message is UserMessage => message.role === "user"),
|
||||||
)
|
)
|
||||||
const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined)
|
const hoverReady = createMemo(() => {
|
||||||
|
if (sessionStore.message[props.session.id] === undefined) return false
|
||||||
|
if (props.session.id === params.id) return true
|
||||||
|
const info = getSessionPrefetch(props.session.directory, props.session.id)
|
||||||
|
if (!info) return false
|
||||||
|
return Date.now() - info.at < SESSION_PREFETCH_TTL
|
||||||
|
})
|
||||||
const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded())
|
const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded())
|
||||||
const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
|
const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
|
||||||
const isActive = createMemo(() => props.session.id === params.id)
|
const isActive = createMemo(() => props.session.id === params.id)
|
||||||
|
|
||||||
|
const warm = (span: number, priority: "high" | "low") => {
|
||||||
|
const nav = props.navList?.()
|
||||||
|
const list = nav?.some((item) => item.id === props.session.id && item.directory === props.session.directory)
|
||||||
|
? nav
|
||||||
|
: props.list
|
||||||
|
|
||||||
|
props.prefetchSession(props.session, priority)
|
||||||
|
|
||||||
|
const idx = list.findIndex((item) => item.id === props.session.id && item.directory === props.session.directory)
|
||||||
|
if (idx === -1) return
|
||||||
|
|
||||||
|
for (let step = 1; step <= span; step++) {
|
||||||
|
const next = list[idx + step]
|
||||||
|
if (next) props.prefetchSession(next, step === 1 ? "high" : priority)
|
||||||
|
|
||||||
|
const prev = list[idx - step]
|
||||||
|
if (prev) props.prefetchSession(prev, step === 1 ? "high" : priority)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const hoverPrefetch = {
|
const hoverPrefetch = {
|
||||||
current: undefined as ReturnType<typeof setTimeout> | undefined,
|
current: undefined as ReturnType<typeof setTimeout> | undefined,
|
||||||
}
|
}
|
||||||
@@ -239,11 +268,12 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
|||||||
hoverPrefetch.current = undefined
|
hoverPrefetch.current = undefined
|
||||||
}
|
}
|
||||||
const scheduleHoverPrefetch = () => {
|
const scheduleHoverPrefetch = () => {
|
||||||
|
warm(1, "high")
|
||||||
if (hoverPrefetch.current !== undefined) return
|
if (hoverPrefetch.current !== undefined) return
|
||||||
hoverPrefetch.current = setTimeout(() => {
|
hoverPrefetch.current = setTimeout(() => {
|
||||||
hoverPrefetch.current = undefined
|
hoverPrefetch.current = undefined
|
||||||
props.prefetchSession(props.session)
|
warm(2, "low")
|
||||||
}, 200)
|
}, 80)
|
||||||
}
|
}
|
||||||
|
|
||||||
onCleanup(cancelHoverPrefetch)
|
onCleanup(cancelHoverPrefetch)
|
||||||
@@ -267,8 +297,9 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
|||||||
setHoverSession={props.setHoverSession}
|
setHoverSession={props.setHoverSession}
|
||||||
clearHoverProjectSoon={props.clearHoverProjectSoon}
|
clearHoverProjectSoon={props.clearHoverProjectSoon}
|
||||||
sidebarOpened={layout.sidebar.opened}
|
sidebarOpened={layout.sidebar.opened}
|
||||||
prefetchSession={props.prefetchSession}
|
warmHover={scheduleHoverPrefetch}
|
||||||
scheduleHoverPrefetch={scheduleHoverPrefetch}
|
warmPress={() => warm(2, "high")}
|
||||||
|
warmFocus={() => warm(2, "high")}
|
||||||
cancelHoverPrefetch={cancelHoverPrefetch}
|
cancelHoverPrefetch={cancelHoverPrefetch}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user