Compare commits
168
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a53f8e084 | ||
|
|
6f5b2f786e | ||
|
|
7910ce5d36 | ||
|
|
6ad171dba9 | ||
|
|
556703f8ab | ||
|
|
cb5674edc7 | ||
|
|
b99de4118e | ||
|
|
040700dbc4 | ||
|
|
4d5da9697e | ||
|
|
a28648f530 | ||
|
|
4d81e2d4d9 | ||
|
|
21e72cbf42 | ||
|
|
5f277d1e62 | ||
|
|
d67e877e28 | ||
|
|
d4e51e04b3 | ||
|
|
070c1679e4 | ||
|
|
406d216cd2 | ||
|
|
5dc8b4ef29 | ||
|
|
2f41d89163 | ||
|
|
b2eae867a1 | ||
|
|
3c2fda4d91 | ||
|
|
2678ceb45e | ||
|
|
58a4cd00b6 | ||
|
|
0faa191b6d | ||
|
|
58cf092105 | ||
|
|
0ff8bfe1d9 | ||
|
|
ceb79c786a | ||
|
|
b1a15d559b | ||
|
|
124a8abf9b | ||
|
|
85c2bb342b | ||
|
|
4c57e39466 | ||
|
|
0cdd4e4e16 | ||
|
|
a9b01be0c2 | ||
|
|
528daf5490 | ||
|
|
0e176d3ac3 | ||
|
|
27f359852e | ||
|
|
173128d431 | ||
|
|
e8ee1e239f | ||
|
|
656fa191c1 | ||
|
|
6b9f8fb9b3 | ||
|
|
f77e5cf8fb | ||
|
|
e6cdc21f2d | ||
|
|
1fe8d4d7ad | ||
|
|
e44320980d | ||
|
|
f5d7fe3072 | ||
|
|
835a27cf51 | ||
|
|
85afaaa13d | ||
|
|
490615169e | ||
|
|
bb232247d0 | ||
|
|
94c128f73b | ||
|
|
613562f504 | ||
|
|
9c4325bcf8 | ||
|
|
ad08fd57df | ||
|
|
54ba59d3e1 | ||
|
|
a4330a225d | ||
|
|
69ddc91c35 | ||
|
|
4c4aed5a87 | ||
|
|
5a40158abf | ||
|
|
4dce485854 | ||
|
|
5ec5d1dace | ||
|
|
d2c765e2b3 | ||
|
|
d036c57d59 | ||
|
|
e7493e2204 | ||
|
|
3500bf64b8 | ||
|
|
4f982ddb94 | ||
|
|
ff3bb7424d | ||
|
|
89d6f60d25 | ||
|
|
ee18c9976e | ||
|
|
794532928f | ||
|
|
7b773c65ec | ||
|
|
e53aa79dc6 | ||
|
|
d9a97249c0 | ||
|
|
86cef16940 | ||
|
|
ce38997c76 | ||
|
|
7e10c728d4 | ||
|
|
3627c67cf2 | ||
|
|
2518fd81f6 | ||
|
|
39ef7fc90e | ||
|
|
37ae0a4051 | ||
|
|
b312928e9f | ||
|
|
2f2856e20a | ||
|
|
831eb6881b | ||
|
|
f20ee2fad2 | ||
|
|
8b9710e56c | ||
|
|
c6262f9d40 | ||
|
|
b749fa90f2 | ||
|
|
8a51cbd253 | ||
|
|
399b8f0701 | ||
|
|
3742e42fdf | ||
|
|
0388ec6862 | ||
|
|
366b8a8034 | ||
|
|
ef9bc4ec9e | ||
|
|
5838b58913 | ||
|
|
2712244ad3 | ||
|
|
6388cbaf92 | ||
|
|
5cc61e1b53 | ||
|
|
0243be86a7 | ||
|
|
9154cd64e7 | ||
|
|
c71d1bde5e | ||
|
|
f27ef595f6 | ||
|
|
34328828ae | ||
|
|
18fb19da3b | ||
|
|
849e1ac543 | ||
|
|
656a8d8f55 | ||
|
|
b976f339e8 | ||
|
|
7d7837e5b6 | ||
|
|
1db292f4df | ||
|
|
49a3a9fe36 | ||
|
|
e51ed460a6 | ||
|
|
d15c2ce349 | ||
|
|
5cc4bb4089 | ||
|
|
6e9e027886 | ||
|
|
f9a3d129a4 | ||
|
|
c53d1d3ad8 | ||
|
|
f386137fba | ||
|
|
c797b60069 | ||
|
|
a139e9297d | ||
|
|
050f99ec54 | ||
|
|
23ed652901 | ||
|
|
13a68f3de3 | ||
|
|
fdad35aaa7 | ||
|
|
a2ce4eb650 | ||
|
|
8fa04986cf | ||
|
|
a5710ed3e1 | ||
|
|
2efdc9df93 | ||
|
|
0c245886fe | ||
|
|
f03288b411 | ||
|
|
09388c98f3 | ||
|
|
ae25c1e7b7 | ||
|
|
0813c14cc6 | ||
|
|
b5151c421f | ||
|
|
e66fd079db | ||
|
|
207ebf4b8c | ||
|
|
12d862dbd3 | ||
|
|
981353793d | ||
|
|
426dcfa3b0 | ||
|
|
69cb49f7cc | ||
|
|
e30678a088 | ||
|
|
771b29a857 | ||
|
|
e6d1aae33a | ||
|
|
9dc8ac4734 | ||
|
|
fdd037ba20 | ||
|
|
523f792b48 | ||
|
|
2230c3c401 | ||
|
|
1b494e5087 | ||
|
|
9c43893a0f | ||
|
|
6dfe19b445 | ||
|
|
a965a06259 | ||
|
|
0654f28c72 | ||
|
|
a32b76dee0 | ||
|
|
a52d640c8c | ||
|
|
218869cf45 | ||
|
|
e99d7a4292 | ||
|
|
f0beb38f91 | ||
|
|
66fcab7b08 | ||
|
|
641e1781a2 | ||
|
|
490b95efe7 | ||
|
|
ba1edea0ab | ||
|
|
73c9b685a7 | ||
|
|
99d8aab0ac | ||
|
|
7dd6369952 | ||
|
|
06f60af1e9 | ||
|
|
66d0beba6f | ||
|
|
6b99dd50b6 | ||
|
|
c53c9d4e4e | ||
|
|
bbd0f3a252 | ||
|
|
b7e208b4f1 | ||
|
|
be9b4d1bcd |
@@ -31,6 +31,10 @@ runs:
|
|||||||
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
|
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
|
||||||
bun-download-url: ${{ steps.bun-url.outputs.url }}
|
bun-download-url: ${{ steps.bun-url.outputs.url }}
|
||||||
|
|
||||||
|
- name: Install setuptools for distutils compatibility
|
||||||
|
run: python3 -m pip install setuptools || pip install setuptools || true
|
||||||
|
shell: bash
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install
|
run: bun install
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
name: storybook
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev]
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/storybook.yml"
|
||||||
|
- "package.json"
|
||||||
|
- "bun.lock"
|
||||||
|
- "packages/storybook/**"
|
||||||
|
- "packages/ui/**"
|
||||||
|
pull_request:
|
||||||
|
branches: [dev]
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/storybook.yml"
|
||||||
|
- "package.json"
|
||||||
|
- "bun.lock"
|
||||||
|
- "packages/storybook/**"
|
||||||
|
- "packages/ui/**"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: storybook build
|
||||||
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
|
- name: Build Storybook
|
||||||
|
run: bun --cwd packages/storybook build
|
||||||
@@ -6,6 +6,14 @@ on:
|
|||||||
- dev
|
- dev
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
unit:
|
unit:
|
||||||
name: unit (${{ matrix.settings.name }})
|
name: unit (${{ matrix.settings.name }})
|
||||||
@@ -86,18 +94,3 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
packages/app/e2e/test-results
|
packages/app/e2e/test-results
|
||||||
packages/app/e2e/playwright-report
|
packages/app/e2e/playwright-report
|
||||||
|
|
||||||
required:
|
|
||||||
name: test (linux)
|
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
|
||||||
needs:
|
|
||||||
- unit
|
|
||||||
- e2e
|
|
||||||
if: always()
|
|
||||||
steps:
|
|
||||||
- name: Verify upstream test jobs passed
|
|
||||||
run: |
|
|
||||||
echo "unit=${{ needs.unit.result }}"
|
|
||||||
echo "e2e=${{ needs.e2e.result }}"
|
|
||||||
test "${{ needs.unit.result }}" = "success"
|
|
||||||
test "${{ needs.e2e.result }}" = "success"
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
plans/
|
node_modules
|
||||||
bun.lock
|
plans
|
||||||
package.json
|
package.json
|
||||||
|
bun.lock
|
||||||
|
.gitignore
|
||||||
|
package-lock.json
|
||||||
@@ -5,6 +5,11 @@
|
|||||||
"options": {},
|
"options": {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"permission": {
|
||||||
|
"edit": {
|
||||||
|
"packages/opencode/migration/*": "deny",
|
||||||
|
},
|
||||||
|
},
|
||||||
"mcp": {},
|
"mcp": {},
|
||||||
"tools": {
|
"tools": {
|
||||||
"github-triage": false,
|
"github-triage": false,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
/// <reference path="../env.d.ts" />
|
/// <reference path="../env.d.ts" />
|
||||||
import { tool } from "@opencode-ai/plugin"
|
import { tool } from "@opencode-ai/plugin"
|
||||||
import DESCRIPTION from "./github-pr-search.txt"
|
|
||||||
|
|
||||||
async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
||||||
const response = await fetch(`https://api.github.com${endpoint}`, {
|
const response = await fetch(`https://api.github.com${endpoint}`, {
|
||||||
@@ -24,7 +23,16 @@ interface PR {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default tool({
|
export default tool({
|
||||||
description: DESCRIPTION,
|
description: `Use this tool to search GitHub pull requests by title and description.
|
||||||
|
|
||||||
|
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
|
||||||
|
- PR number and title
|
||||||
|
- Author
|
||||||
|
- State (open/closed/merged)
|
||||||
|
- Labels
|
||||||
|
- Description snippet
|
||||||
|
|
||||||
|
Use the query parameter to search for keywords that might appear in PR titles or descriptions.`,
|
||||||
args: {
|
args: {
|
||||||
query: tool.schema.string().describe("Search query for PR titles and descriptions"),
|
query: tool.schema.string().describe("Search query for PR titles and descriptions"),
|
||||||
limit: tool.schema.number().describe("Maximum number of results to return").default(10),
|
limit: tool.schema.number().describe("Maximum number of results to return").default(10),
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
Use this tool to search GitHub pull requests by title and description.
|
|
||||||
|
|
||||||
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
|
|
||||||
- PR number and title
|
|
||||||
- Author
|
|
||||||
- State (open/closed/merged)
|
|
||||||
- Labels
|
|
||||||
- Description snippet
|
|
||||||
|
|
||||||
Use the query parameter to search for keywords that might appear in PR titles or descriptions.
|
|
||||||
@@ -1,20 +1,11 @@
|
|||||||
/// <reference path="../env.d.ts" />
|
/// <reference path="../env.d.ts" />
|
||||||
import { tool } from "@opencode-ai/plugin"
|
import { tool } from "@opencode-ai/plugin"
|
||||||
import DESCRIPTION from "./github-triage.txt"
|
|
||||||
|
|
||||||
const TEAM = {
|
const TEAM = {
|
||||||
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
|
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
|
||||||
zen: ["fwang", "MrMushrooooom"],
|
zen: ["fwang", "MrMushrooooom"],
|
||||||
tui: [
|
tui: ["thdxr", "kommander", "rekram1-node"],
|
||||||
"thdxr",
|
core: ["thdxr", "rekram1-node", "jlongster"],
|
||||||
"kommander",
|
|
||||||
// "rekram1-node" (on vacation)
|
|
||||||
],
|
|
||||||
core: [
|
|
||||||
"thdxr",
|
|
||||||
// "rekram1-node", (on vacation)
|
|
||||||
"jlongster",
|
|
||||||
],
|
|
||||||
docs: ["R44VC0RP"],
|
docs: ["R44VC0RP"],
|
||||||
windows: ["Hona"],
|
windows: ["Hona"],
|
||||||
} as const
|
} as const
|
||||||
@@ -48,9 +39,17 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default tool({
|
export default tool({
|
||||||
description: DESCRIPTION,
|
description: `Use this tool to assign and/or label a GitHub issue.
|
||||||
|
|
||||||
|
Choose labels and assignee using the current triage policy and ownership rules.
|
||||||
|
Pick the most fitting labels for the issue and assign one owner.
|
||||||
|
|
||||||
|
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`,
|
||||||
args: {
|
args: {
|
||||||
assignee: tool.schema.enum(ASSIGNEES as [string, ...string[]]).describe("The username of the assignee"),
|
assignee: tool.schema
|
||||||
|
.enum(ASSIGNEES as [string, ...string[]])
|
||||||
|
.describe("The username of the assignee")
|
||||||
|
.default("rekram1-node"),
|
||||||
labels: tool.schema
|
labels: tool.schema
|
||||||
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
|
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
|
||||||
.describe("The labels(s) to add to the issue")
|
.describe("The labels(s) to add to the issue")
|
||||||
@@ -73,8 +72,7 @@ export default tool({
|
|||||||
results.push("Dropped label: nix (issue does not mention nix)")
|
results.push("Dropped label: nix (issue does not mention nix)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
|
const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
|
||||||
const assignee = web ? pick(TEAM.desktop) : args.assignee
|
|
||||||
|
|
||||||
if (labels.includes("zen") && !zen) {
|
if (labels.includes("zen") && !zen) {
|
||||||
throw new Error("Only add the zen label when issue title/body contains 'zen'")
|
throw new Error("Only add the zen label when issue title/body contains 'zen'")
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
Use this tool to assign and/or label a GitHub issue.
|
|
||||||
|
|
||||||
Choose labels and assignee using the current triage policy and ownership rules.
|
|
||||||
Pick the most fitting labels for the issue and assign one owner.
|
|
||||||
|
|
||||||
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.
|
|
||||||
|
|
||||||
(Note: rekram1-node is on vacation, do not assign issues to him.)
|
|
||||||
@@ -122,3 +122,7 @@ const table = sqliteTable("session", {
|
|||||||
- Avoid mocks as much as possible
|
- Avoid mocks as much as possible
|
||||||
- Test actual implementation, do not duplicate logic into tests
|
- Test actual implementation, do not duplicate logic into tests
|
||||||
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
|
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
|
||||||
|
|
||||||
|
## Type Checking
|
||||||
|
|
||||||
|
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ If you are working on a project that's related to OpenCode and is using "opencod
|
|||||||
|
|
||||||
#### How is this different from Claude Code?
|
#### How is this different from Claude Code?
|
||||||
|
|
||||||
It's very similar to Claude Code in terms of capability. Here are the key differences:
|
It's very similar to Claude Code in terms of capability. Here are the key differences::
|
||||||
|
|
||||||
- 100% open source
|
- 100% open source
|
||||||
- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important.
|
- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important.
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-4kjoJ06VNvHltPHfzQRBG0bC6R39jao10ffGzrNZ230=",
|
"x86_64-linux": "sha256-dhL4YeSi4Lm9yDp919Fx7N2hyLUbZQa2qWoCf/50ce8=",
|
||||||
"aarch64-linux": "sha256-6Uio+S2rcyBWbBEeOZb9N1CCKgkbKi68lOIKi3Ws/pQ=",
|
"aarch64-linux": "sha256-//YxCsrvYlxuvd0MtFFO+pLxjmuemyrvGzSIPxzO+rA=",
|
||||||
"aarch64-darwin": "sha256-8ngN5KVN4vhdsk0QJ11BGgSVBrcaEbwSj23c77HBpgs=",
|
"aarch64-darwin": "sha256-c65kSWteQNaBcQUsjbXNqT61vt98JPNYo9yMNvUygCw=",
|
||||||
"x86_64-darwin": "sha256-v/ueYGb9a0Nymzy+mkO4uQr78DAuJnES1qOT0onFgnQ="
|
"x86_64-darwin": "sha256-hlTzEFv3nZHwlDXU65LfMC+NaqYjjyZqagdJ366CNxY="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-2
@@ -11,6 +11,7 @@
|
|||||||
"dev:web": "bun --cwd packages/app dev",
|
"dev:web": "bun --cwd packages/app dev",
|
||||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||||
"typecheck": "bun turbo typecheck",
|
"typecheck": "bun turbo typecheck",
|
||||||
|
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"random": "echo 'Random script'",
|
"random": "echo 'Random script'",
|
||||||
"hello": "echo 'Hello World!'",
|
"hello": "echo 'Hello World!'",
|
||||||
@@ -41,8 +42,9 @@
|
|||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
"diff": "8.0.2",
|
"diff": "8.0.2",
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
"effect": "4.0.0-beta.29",
|
||||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
"drizzle-kit": "1.0.0-beta.16-c2458b2",
|
||||||
|
"drizzle-orm": "1.0.0-beta.16-c2458b2",
|
||||||
"ai": "5.0.124",
|
"ai": "5.0.124",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
@@ -97,6 +99,7 @@
|
|||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"esbuild",
|
"esbuild",
|
||||||
|
"node-pty",
|
||||||
"protobufjs",
|
"protobufjs",
|
||||||
"tree-sitter",
|
"tree-sitter",
|
||||||
"tree-sitter-bash",
|
"tree-sitter-bash",
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ test("test description", async ({ page, sdk, gotoSession }) => {
|
|||||||
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
|
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
|
||||||
- `withSession(sdk, title, callback)` - Create temp session
|
- `withSession(sdk, title, callback)` - Create temp session
|
||||||
- `withProject(...)` - Create temp project/workspace
|
- `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
|
||||||
@@ -169,9 +172,10 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings
|
|||||||
1. Choose appropriate folder or create new one
|
1. Choose appropriate folder or create new one
|
||||||
2. Import from `../fixtures`
|
2. Import from `../fixtures`
|
||||||
3. Use helper functions from `../actions` and `../selectors`
|
3. Use helper functions from `../actions` and `../selectors`
|
||||||
4. Clean up any created resources
|
4. When validating routing, use shared helpers from `../actions`. Workspace URL slugs can be canonicalized on Windows, so assert against canonical or resolved workspace slugs.
|
||||||
5. Use specific selectors (avoid CSS classes)
|
5. Clean up any created resources
|
||||||
6. Test one feature per test file
|
6. Use specific selectors (avoid CSS classes)
|
||||||
|
7. Test one feature per test file
|
||||||
|
|
||||||
## Local Development
|
## Local Development
|
||||||
|
|
||||||
|
|||||||
@@ -199,6 +199,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,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 })
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
|
|||||||
export const projectSwitchSelector = (slug: string) =>
|
export const projectSwitchSelector = (slug: string) =>
|
||||||
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
|
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
|
||||||
|
|
||||||
export const projectCloseHoverSelector = (slug: string) => `[data-action="project-close-hover"][data-project="${slug}"]`
|
|
||||||
|
|
||||||
export const projectMenuTriggerSelector = (slug: string) =>
|
export const projectMenuTriggerSelector = (slug: string) =>
|
||||||
`${sidebarNavSelector} [data-action="project-menu"][data-project="${slug}"]`
|
`${sidebarNavSelector} [data-action="project-menu"][data-project="${slug}"]`
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -44,12 +44,14 @@ 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)
|
||||||
@@ -61,22 +63,39 @@ test("terminal tab buffers persist across tab switches", async ({ page, withProj
|
|||||||
|
|
||||||
await run(page, `echo ${two}`)
|
await run(page, `echo ${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(
|
||||||
async () => {
|
async () => {
|
||||||
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: 30_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: 30_000 },
|
||||||
|
)
|
||||||
|
.toEqual({ first: true, second: false })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
;(function () {
|
;(function () {
|
||||||
var themeId = localStorage.getItem("opencode-theme-id")
|
var key = "opencode-theme-id"
|
||||||
if (!themeId) return
|
var themeId = localStorage.getItem(key) || "oc-2"
|
||||||
|
|
||||||
|
if (themeId === "oc-1") {
|
||||||
|
themeId = "oc-2"
|
||||||
|
localStorage.setItem(key, themeId)
|
||||||
|
localStorage.removeItem("opencode-theme-css-light")
|
||||||
|
localStorage.removeItem("opencode-theme-css-dark")
|
||||||
|
}
|
||||||
|
|
||||||
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
|
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
|
||||||
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
|
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
|
||||||
@@ -9,9 +16,9 @@
|
|||||||
document.documentElement.dataset.theme = themeId
|
document.documentElement.dataset.theme = themeId
|
||||||
document.documentElement.dataset.colorScheme = mode
|
document.documentElement.dataset.colorScheme = mode
|
||||||
|
|
||||||
if (themeId === "oc-1") return
|
if (themeId === "oc-2") return
|
||||||
|
|
||||||
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
|
var css = localStorage.getItem("opencode-theme-css-" + mode)
|
||||||
if (css) {
|
if (css) {
|
||||||
var style = document.createElement("style")
|
var style = document.createElement("style")
|
||||||
style.id = "oc-theme-preload"
|
style.id = "oc-theme-preload"
|
||||||
|
|||||||
@@ -0,0 +1,441 @@
|
|||||||
|
import { useIsRouting, useLocation } from "@solidjs/router"
|
||||||
|
import { batch, createEffect, onCleanup, onMount } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
|
|
||||||
|
type Mem = Performance & {
|
||||||
|
memory?: {
|
||||||
|
usedJSHeapSize: number
|
||||||
|
jsHeapSizeLimit: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Evt = PerformanceEntry & {
|
||||||
|
interactionId?: number
|
||||||
|
processingStart?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Shift = PerformanceEntry & {
|
||||||
|
hadRecentInput: boolean
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Obs = PerformanceObserverInit & {
|
||||||
|
durationThreshold?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const span = 5000
|
||||||
|
|
||||||
|
const ms = (n?: number, d = 0) => {
|
||||||
|
if (n === undefined || Number.isNaN(n)) return "n/a"
|
||||||
|
return `${n.toFixed(d)}ms`
|
||||||
|
}
|
||||||
|
|
||||||
|
const time = (n?: number) => {
|
||||||
|
if (n === undefined || Number.isNaN(n)) return "n/a"
|
||||||
|
return `${Math.round(n)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const mb = (n?: number) => {
|
||||||
|
if (n === undefined || Number.isNaN(n)) return "n/a"
|
||||||
|
const v = n / 1024 / 1024
|
||||||
|
return `${v >= 1024 ? v.toFixed(0) : v.toFixed(1)}MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
const bad = (n: number | undefined, limit: number, low = false) => {
|
||||||
|
if (n === undefined || Number.isNaN(n)) return false
|
||||||
|
return low ? n < limit : n > limit
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = (path: string) => path.includes("/session")
|
||||||
|
|
||||||
|
function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string; value: string; wide?: boolean }) {
|
||||||
|
return (
|
||||||
|
<Tooltip value={props.tip} placement="top">
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] bg-white/5 px-0.5 py-1 text-center": true,
|
||||||
|
"col-span-2": !!props.wide,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70">{props.label}</div>
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"text-[13px] leading-none font-bold tabular-nums sm:text-[14px]": true,
|
||||||
|
"text-text-on-critical-base": !!props.bad,
|
||||||
|
"opacity-70": !!props.dim,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DebugBar() {
|
||||||
|
const location = useLocation()
|
||||||
|
const routing = useIsRouting()
|
||||||
|
const [state, setState] = createStore({
|
||||||
|
cls: undefined as number | undefined,
|
||||||
|
delay: undefined as number | undefined,
|
||||||
|
fps: undefined as number | undefined,
|
||||||
|
gap: undefined as number | undefined,
|
||||||
|
heap: {
|
||||||
|
limit: undefined as number | undefined,
|
||||||
|
used: undefined as number | undefined,
|
||||||
|
},
|
||||||
|
inp: undefined as number | undefined,
|
||||||
|
jank: undefined as number | undefined,
|
||||||
|
long: {
|
||||||
|
block: undefined as number | undefined,
|
||||||
|
count: undefined as number | undefined,
|
||||||
|
max: undefined as number | undefined,
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
dur: undefined as number | undefined,
|
||||||
|
pending: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined)
|
||||||
|
const heapv = () => {
|
||||||
|
const value = heap()
|
||||||
|
if (value === undefined) return "n/a"
|
||||||
|
return `${Math.round(value * 100)}%`
|
||||||
|
}
|
||||||
|
const longv = () => (state.long.count === undefined ? "n/a" : `${time(state.long.block)}/${state.long.count}`)
|
||||||
|
const navv = () => (state.nav.pending ? "..." : time(state.nav.dur))
|
||||||
|
|
||||||
|
let prev = ""
|
||||||
|
let start = 0
|
||||||
|
let init = false
|
||||||
|
let one = 0
|
||||||
|
let two = 0
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const busy = routing()
|
||||||
|
const next = `${location.pathname}${location.search}`
|
||||||
|
|
||||||
|
if (!init) {
|
||||||
|
init = true
|
||||||
|
prev = next
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (busy) {
|
||||||
|
if (one !== 0) cancelAnimationFrame(one)
|
||||||
|
if (two !== 0) cancelAnimationFrame(two)
|
||||||
|
one = 0
|
||||||
|
two = 0
|
||||||
|
if (start !== 0) return
|
||||||
|
start = performance.now()
|
||||||
|
if (session(prev)) setState("nav", { dur: undefined, pending: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start === 0) {
|
||||||
|
prev = next
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const at = start
|
||||||
|
const from = prev
|
||||||
|
start = 0
|
||||||
|
prev = next
|
||||||
|
|
||||||
|
if (!(session(from) || session(next))) return
|
||||||
|
|
||||||
|
if (one !== 0) cancelAnimationFrame(one)
|
||||||
|
if (two !== 0) cancelAnimationFrame(two)
|
||||||
|
one = requestAnimationFrame(() => {
|
||||||
|
one = 0
|
||||||
|
two = requestAnimationFrame(() => {
|
||||||
|
two = 0
|
||||||
|
setState("nav", { dur: performance.now() - at, pending: false })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const obs: PerformanceObserver[] = []
|
||||||
|
const fps: Array<{ at: number; dur: number }> = []
|
||||||
|
const long: Array<{ at: number; dur: number }> = []
|
||||||
|
const seen = new Map<number | string, { at: number; delay: number; dur: number }>()
|
||||||
|
let hasLong = false
|
||||||
|
let poll: number | undefined
|
||||||
|
let raf = 0
|
||||||
|
let last = 0
|
||||||
|
let snap = 0
|
||||||
|
|
||||||
|
const trim = (list: Array<{ at: number; dur: number }>, span: number, at: number) => {
|
||||||
|
while (list[0] && at - list[0].at > span) list.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncFrame = (at: number) => {
|
||||||
|
trim(fps, span, at)
|
||||||
|
const total = fps.reduce((sum, entry) => sum + entry.dur, 0)
|
||||||
|
const gap = fps.reduce((max, entry) => Math.max(max, entry.dur), 0)
|
||||||
|
const jank = fps.filter((entry) => entry.dur > 32).length
|
||||||
|
batch(() => {
|
||||||
|
setState("fps", total > 0 ? (fps.length * 1000) / total : undefined)
|
||||||
|
setState("gap", gap > 0 ? gap : undefined)
|
||||||
|
setState("jank", jank)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncLong = (at = performance.now()) => {
|
||||||
|
if (!hasLong) return
|
||||||
|
trim(long, span, at)
|
||||||
|
const block = long.reduce((sum, entry) => sum + Math.max(0, entry.dur - 50), 0)
|
||||||
|
const max = long.reduce((hi, entry) => Math.max(hi, entry.dur), 0)
|
||||||
|
setState("long", { block, count: long.length, max })
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncInp = (at = performance.now()) => {
|
||||||
|
for (const [key, entry] of seen) {
|
||||||
|
if (at - entry.at > span) seen.delete(key)
|
||||||
|
}
|
||||||
|
let delay = 0
|
||||||
|
let inp = 0
|
||||||
|
for (const entry of seen.values()) {
|
||||||
|
delay = Math.max(delay, entry.delay)
|
||||||
|
inp = Math.max(inp, entry.dur)
|
||||||
|
}
|
||||||
|
batch(() => {
|
||||||
|
setState("delay", delay > 0 ? delay : undefined)
|
||||||
|
setState("inp", inp > 0 ? inp : undefined)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncHeap = () => {
|
||||||
|
const mem = (performance as Mem).memory
|
||||||
|
if (!mem) return
|
||||||
|
setState("heap", { limit: mem.jsHeapSizeLimit, used: mem.usedJSHeapSize })
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
fps.length = 0
|
||||||
|
long.length = 0
|
||||||
|
seen.clear()
|
||||||
|
last = 0
|
||||||
|
snap = 0
|
||||||
|
batch(() => {
|
||||||
|
setState("fps", undefined)
|
||||||
|
setState("gap", undefined)
|
||||||
|
setState("jank", undefined)
|
||||||
|
setState("delay", undefined)
|
||||||
|
setState("inp", undefined)
|
||||||
|
if (hasLong) setState("long", { block: 0, count: 0, max: 0 })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const watch = (type: string, init: Obs, fn: (entries: PerformanceEntry[]) => void) => {
|
||||||
|
if (typeof PerformanceObserver === "undefined") return false
|
||||||
|
if (!(PerformanceObserver.supportedEntryTypes ?? []).includes(type)) return false
|
||||||
|
const ob = new PerformanceObserver((list) => fn(list.getEntries()))
|
||||||
|
try {
|
||||||
|
ob.observe(init)
|
||||||
|
obs.push(ob)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
ob.disconnect()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
watch("layout-shift", { buffered: true, type: "layout-shift" }, (entries) => {
|
||||||
|
const add = entries.reduce((sum, entry) => {
|
||||||
|
const item = entry as Shift
|
||||||
|
if (item.hadRecentInput) return sum
|
||||||
|
return sum + item.value
|
||||||
|
}, 0)
|
||||||
|
if (add === 0) return
|
||||||
|
setState("cls", (value) => (value ?? 0) + add)
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
setState("cls", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
watch("longtask", { buffered: true, type: "longtask" }, (entries) => {
|
||||||
|
const at = performance.now()
|
||||||
|
long.push(...entries.map((entry) => ({ at: entry.startTime, dur: entry.duration })))
|
||||||
|
syncLong(at)
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
hasLong = true
|
||||||
|
setState("long", { block: 0, count: 0, max: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
watch("event", { buffered: true, durationThreshold: 16, type: "event" }, (entries) => {
|
||||||
|
for (const raw of entries) {
|
||||||
|
const entry = raw as Evt
|
||||||
|
if (entry.duration < 16) continue
|
||||||
|
const key =
|
||||||
|
entry.interactionId && entry.interactionId > 0
|
||||||
|
? entry.interactionId
|
||||||
|
: `${entry.name}:${Math.round(entry.startTime)}`
|
||||||
|
const prev = seen.get(key)
|
||||||
|
const delay = Math.max(0, (entry.processingStart ?? entry.startTime) - entry.startTime)
|
||||||
|
seen.set(key, {
|
||||||
|
at: entry.startTime,
|
||||||
|
delay: Math.max(prev?.delay ?? 0, delay),
|
||||||
|
dur: Math.max(prev?.dur ?? 0, entry.duration),
|
||||||
|
})
|
||||||
|
if (seen.size <= 200) continue
|
||||||
|
const first = seen.keys().next().value
|
||||||
|
if (first !== undefined) seen.delete(first)
|
||||||
|
}
|
||||||
|
syncInp()
|
||||||
|
})
|
||||||
|
|
||||||
|
const loop = (at: number) => {
|
||||||
|
if (document.visibilityState !== "visible") {
|
||||||
|
raf = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (last === 0) {
|
||||||
|
last = at
|
||||||
|
raf = requestAnimationFrame(loop)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fps.push({ at, dur: at - last })
|
||||||
|
last = at
|
||||||
|
|
||||||
|
if (at - snap >= 250) {
|
||||||
|
snap = at
|
||||||
|
syncFrame(at)
|
||||||
|
}
|
||||||
|
|
||||||
|
raf = requestAnimationFrame(loop)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (raf !== 0) cancelAnimationFrame(raf)
|
||||||
|
raf = 0
|
||||||
|
if (poll === undefined) return
|
||||||
|
clearInterval(poll)
|
||||||
|
poll = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
if (document.visibilityState !== "visible") return
|
||||||
|
if (poll === undefined) {
|
||||||
|
poll = window.setInterval(() => {
|
||||||
|
syncLong()
|
||||||
|
syncInp()
|
||||||
|
syncHeap()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
if (raf !== 0) return
|
||||||
|
raf = requestAnimationFrame(loop)
|
||||||
|
}
|
||||||
|
|
||||||
|
const vis = () => {
|
||||||
|
if (document.visibilityState !== "visible") {
|
||||||
|
stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reset()
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
|
||||||
|
syncHeap()
|
||||||
|
start()
|
||||||
|
document.addEventListener("visibilitychange", vis)
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (one !== 0) cancelAnimationFrame(one)
|
||||||
|
if (two !== 0) cancelAnimationFrame(two)
|
||||||
|
stop()
|
||||||
|
document.removeEventListener("visibilitychange", vis)
|
||||||
|
for (const ob of obs) ob.disconnect()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
aria-label="Development performance diagnostics"
|
||||||
|
class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border p-0.5 text-text-on-interactive-base shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
|
||||||
|
style={{
|
||||||
|
"background-color": "color-mix(in srgb, var(--icon-interactive-base) 42%, black)",
|
||||||
|
"border-color": "color-mix(in srgb, white 14%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-5 gap-px font-mono">
|
||||||
|
<Cell
|
||||||
|
label="NAV"
|
||||||
|
tip="Last completed route transition touching a session page, measured from router start until the first paint after it settles."
|
||||||
|
value={navv()}
|
||||||
|
bad={bad(state.nav.dur, 400)}
|
||||||
|
dim={state.nav.dur === undefined && !state.nav.pending}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label="FPS"
|
||||||
|
tip="Rolling frames per second over the last 5 seconds."
|
||||||
|
value={state.fps === undefined ? "n/a" : `${Math.round(state.fps)}`}
|
||||||
|
bad={bad(state.fps, 50, true)}
|
||||||
|
dim={state.fps === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label="FRM"
|
||||||
|
tip="Worst frame time over the last 5 seconds."
|
||||||
|
value={time(state.gap)}
|
||||||
|
bad={bad(state.gap, 50)}
|
||||||
|
dim={state.gap === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label="JNK"
|
||||||
|
tip="Frames over 32ms in the last 5 seconds."
|
||||||
|
value={state.jank === undefined ? "n/a" : `${state.jank}`}
|
||||||
|
bad={bad(state.jank, 8)}
|
||||||
|
dim={state.jank === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label="LNG"
|
||||||
|
tip={`Blocked time and long-task count in the last 5 seconds. Max task: ${ms(state.long.max)}.`}
|
||||||
|
value={longv()}
|
||||||
|
bad={bad(state.long.block, 200)}
|
||||||
|
dim={state.long.count === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label="DLY"
|
||||||
|
tip="Worst observed input delay in the last 5 seconds."
|
||||||
|
value={time(state.delay)}
|
||||||
|
bad={bad(state.delay, 100)}
|
||||||
|
dim={state.delay === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label="INP"
|
||||||
|
tip="Approximate interaction duration over the last 5 seconds. This is INP-like, not the official Web Vitals INP."
|
||||||
|
value={time(state.inp)}
|
||||||
|
bad={bad(state.inp, 200)}
|
||||||
|
dim={state.inp === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label="CLS"
|
||||||
|
tip="Cumulative layout shift for the current app lifetime."
|
||||||
|
value={state.cls === undefined ? "n/a" : state.cls.toFixed(2)}
|
||||||
|
bad={bad(state.cls, 0.1)}
|
||||||
|
dim={state.cls === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label="MEM"
|
||||||
|
tip={
|
||||||
|
state.heap.used === undefined
|
||||||
|
? "Used JS heap vs heap limit. Chromium only."
|
||||||
|
: `Used JS heap vs heap limit. ${mb(state.heap.used)} of ${mb(state.heap.limit)}.`
|
||||||
|
}
|
||||||
|
value={heapv()}
|
||||||
|
bad={bad(heap(), 0.8)}
|
||||||
|
dim={state.heap.used === undefined}
|
||||||
|
wide
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 = () =>
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import { useLayout } from "@/context/layout"
|
|||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useServer } from "@/context/server"
|
import { useServer } from "@/context/server"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
|
import { useTerminal } from "@/context/terminal"
|
||||||
|
import { focusTerminalById } from "@/pages/session/helpers"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { StatusPopover } from "../status-popover"
|
import { StatusPopover } from "../status-popover"
|
||||||
@@ -229,6 +231,7 @@ export function SessionHeader() {
|
|||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const terminal = useTerminal()
|
||||||
|
|
||||||
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||||
const project = createMemo(() => {
|
const project = createMemo(() => {
|
||||||
@@ -296,6 +299,16 @@ export function SessionHeader() {
|
|||||||
] as const
|
] as const
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const toggleTerminal = () => {
|
||||||
|
const next = !view().terminal.opened()
|
||||||
|
view().terminal.toggle()
|
||||||
|
if (!next) return
|
||||||
|
|
||||||
|
const id = terminal.active()
|
||||||
|
if (!id) return
|
||||||
|
focusTerminalById(id)
|
||||||
|
}
|
||||||
|
|
||||||
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
|
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
|
||||||
const [menu, setMenu] = createStore({ open: false })
|
const [menu, setMenu] = createStore({ open: false })
|
||||||
const [openRequest, setOpenRequest] = createStore({
|
const [openRequest, setOpenRequest] = createStore({
|
||||||
@@ -303,7 +316,12 @@ export function SessionHeader() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
|
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
|
||||||
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
|
const current = createMemo(
|
||||||
|
() =>
|
||||||
|
options().find((o) => o.id === prefs.app) ??
|
||||||
|
options()[0] ??
|
||||||
|
({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
|
||||||
|
)
|
||||||
const opening = createMemo(() => openRequest.app !== undefined)
|
const opening = createMemo(() => openRequest.app !== undefined)
|
||||||
|
|
||||||
const selectApp = (app: OpenApp) => {
|
const selectApp = (app: OpenApp) => {
|
||||||
@@ -612,39 +630,39 @@ export function SessionHeader() {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<div class="hidden md:flex items-center gap-1 shrink-0">
|
<TooltipKeybind
|
||||||
<TooltipKeybind
|
title={language.t("command.terminal.toggle")}
|
||||||
title={language.t("command.terminal.toggle")}
|
keybind={command.keybind("terminal.toggle")}
|
||||||
keybind={command.keybind("terminal.toggle")}
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
|
||||||
|
onClick={toggleTerminal}
|
||||||
|
aria-label={language.t("command.terminal.toggle")}
|
||||||
|
aria-expanded={view().terminal.opened()}
|
||||||
|
aria-controls="terminal-panel"
|
||||||
>
|
>
|
||||||
<Button
|
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||||
variant="ghost"
|
<Icon
|
||||||
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border"
|
size="small"
|
||||||
onClick={() => view().terminal.toggle()}
|
name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"}
|
||||||
aria-label={language.t("command.terminal.toggle")}
|
class="group-hover/terminal-toggle:hidden"
|
||||||
aria-expanded={view().terminal.opened()}
|
/>
|
||||||
aria-controls="terminal-panel"
|
<Icon
|
||||||
>
|
size="small"
|
||||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
name="layout-bottom-partial"
|
||||||
<Icon
|
class="hidden group-hover/terminal-toggle:inline-block"
|
||||||
size="small"
|
/>
|
||||||
name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"}
|
<Icon
|
||||||
class="group-hover/terminal-toggle:hidden"
|
size="small"
|
||||||
/>
|
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-partial"}
|
||||||
<Icon
|
class="hidden group-active/terminal-toggle:inline-block"
|
||||||
size="small"
|
/>
|
||||||
name="layout-bottom-partial"
|
</div>
|
||||||
class="hidden group-hover/terminal-toggle:inline-block"
|
</Button>
|
||||||
/>
|
</TooltipKeybind>
|
||||||
<Icon
|
|
||||||
size="small"
|
|
||||||
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-partial"}
|
|
||||||
class="hidden group-active/terminal-toggle:inline-block"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</TooltipKeybind>
|
|
||||||
|
|
||||||
|
<div class="hidden md:flex items-center gap-1 shrink-0">
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
title={language.t("command.review.toggle")}
|
title={language.t("command.review.toggle")}
|
||||||
keybind={command.keybind("review.toggle")}
|
keybind={command.keybind("review.toggle")}
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { useSync } from "@/context/sync"
|
|||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { Mark } from "@opencode-ai/ui/logo"
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||||
|
|
||||||
const MAIN_WORKTREE = "main"
|
const MAIN_WORKTREE = "main"
|
||||||
const CREATE_WORKTREE = "create"
|
const CREATE_WORKTREE = "create"
|
||||||
const ROOT_CLASS =
|
const ROOT_CLASS = "size-full flex flex-col"
|
||||||
"size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-16"
|
|
||||||
|
|
||||||
interface NewSessionViewProps {
|
interface NewSessionViewProps {
|
||||||
worktree: string
|
worktree: string
|
||||||
@@ -50,33 +50,43 @@ export function NewSessionView(props: NewSessionViewProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={ROOT_CLASS}>
|
<div class={ROOT_CLASS}>
|
||||||
<div class="text-20-medium text-text-weaker">{language.t("command.session.new")}</div>
|
<div class="h-12 shrink-0" aria-hidden />
|
||||||
<div class="flex justify-center items-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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const TOGGLE_TERMINAL_ID = "terminal.toggle"
|
|||||||
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
|
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
|
||||||
export interface TerminalProps extends ComponentProps<"div"> {
|
export interface TerminalProps extends ComponentProps<"div"> {
|
||||||
pty: LocalPTY
|
pty: LocalPTY
|
||||||
|
autoFocus?: boolean
|
||||||
onSubmit?: () => void
|
onSubmit?: () => void
|
||||||
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
|
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
|
||||||
onConnect?: () => void
|
onConnect?: () => void
|
||||||
@@ -157,7 +158,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
let container!: HTMLDivElement
|
let container!: HTMLDivElement
|
||||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"])
|
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
|
||||||
const id = local.pty.id
|
const id = local.pty.id
|
||||||
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
|
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
|
||||||
const restoreSize =
|
const restoreSize =
|
||||||
@@ -217,7 +218,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
const currentTheme = theme.themes()[theme.themeId()]
|
const currentTheme = theme.themes()[theme.themeId()]
|
||||||
if (!currentTheme) return fallback
|
if (!currentTheme) return fallback
|
||||||
const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
|
const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
|
||||||
if (!variant?.seeds) return fallback
|
if (!variant?.seeds && !variant?.palette) return fallback
|
||||||
const resolved = resolveThemeVariant(variant, mode === "dark")
|
const resolved = resolveThemeVariant(variant, mode === "dark")
|
||||||
const text = resolved["text-stronger"] ?? fallback.foreground
|
const text = resolved["text-stronger"] ?? fallback.foreground
|
||||||
const background = resolved["background-stronger"] ?? fallback.background
|
const background = resolved["background-stronger"] ?? fallback.background
|
||||||
@@ -386,7 +387,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
handleLinkClick,
|
handleLinkClick,
|
||||||
})
|
})
|
||||||
|
|
||||||
focusTerminal()
|
if (local.autoFocus !== false) focusTerminal()
|
||||||
|
|
||||||
if (typeof document !== "undefined" && document.fonts) {
|
if (typeof document !== "undefined" && document.fonts) {
|
||||||
document.fonts.ready.then(scheduleFit)
|
document.fonts.ready.then(scheduleFit)
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export function Titlebar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
|
class="h-10 shrink-0 bg-background-base relative grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center"
|
||||||
style={{ "min-height": minHeight() }}
|
style={{ "min-height": minHeight() }}
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
onMouseDown={drag}
|
onMouseDown={drag}
|
||||||
@@ -269,7 +269,7 @@ export function Titlebar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="min-w-0 flex items-center justify-center pointer-events-none">
|
<div class="min-w-0 flex items-center justify-center pointer-events-none">
|
||||||
<div id="opencode-titlebar-center" class="pointer-events-auto w-full min-w-0 flex justify-center lg:w-fit" />
|
<div id="opencode-titlebar-center" class="pointer-events-auto min-w-0 flex justify-center w-fit max-w-full" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import type { InitError } from "../pages/error"
|
|||||||
import { useGlobalSDK } from "./global-sdk"
|
import { useGlobalSDK } from "./global-sdk"
|
||||||
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
|
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
|
||||||
import { createChildStoreManager } from "./global-sync/child-store"
|
import { createChildStoreManager } from "./global-sync/child-store"
|
||||||
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
|
||||||
import { createRefreshQueue } from "./global-sync/queue"
|
import { createRefreshQueue } from "./global-sync/queue"
|
||||||
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||||
import { trimSessions } from "./global-sync/session-trim"
|
import { trimSessions } from "./global-sync/session-trim"
|
||||||
@@ -189,6 +189,7 @@ function createGlobalSync() {
|
|||||||
})
|
})
|
||||||
if (next.length !== store.session.length) {
|
if (next.length !== store.session.length) {
|
||||||
setStore("session", reconcile(next, { key: "id" }))
|
setStore("session", reconcile(next, { key: "id" }))
|
||||||
|
cleanupDroppedSessionCaches(store, setStore, next, setSessionTodo)
|
||||||
}
|
}
|
||||||
children.unpin(directory)
|
children.unpin(directory)
|
||||||
return
|
return
|
||||||
@@ -220,6 +221,7 @@ function createGlobalSync() {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
setStore("session", reconcile(sessions, { key: "id" }))
|
setStore("session", reconcile(sessions, { key: "id" }))
|
||||||
|
cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
|
||||||
sessionMeta.set(directory, { limit })
|
sessionMeta.set(directory, { limit })
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
|||||||
import { useGlobalSync } from "./global-sync"
|
import { useGlobalSync } from "./global-sync"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
|
||||||
|
|
||||||
function sortParts(parts: Part[]) {
|
function sortParts(parts: Part[]) {
|
||||||
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
||||||
@@ -108,6 +109,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const inflight = new Map<string, Promise<void>>()
|
const inflight = new Map<string, Promise<void>>()
|
||||||
const inflightDiff = new Map<string, Promise<void>>()
|
const inflightDiff = new Map<string, Promise<void>>()
|
||||||
const inflightTodo = new Map<string, Promise<void>>()
|
const inflightTodo = new Map<string, Promise<void>>()
|
||||||
|
const maxDirs = 30
|
||||||
|
const seen = new Map<string, Set<string>>()
|
||||||
const [meta, setMeta] = createStore({
|
const [meta, setMeta] = createStore({
|
||||||
limit: {} as Record<string, number>,
|
limit: {} as Record<string, number>,
|
||||||
complete: {} as Record<string, boolean>,
|
complete: {} as Record<string, boolean>,
|
||||||
@@ -121,6 +124,62 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const seenFor = (directory: string) => {
|
||||||
|
const existing = seen.get(directory)
|
||||||
|
if (existing) {
|
||||||
|
seen.delete(directory)
|
||||||
|
seen.set(directory, existing)
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
const created = new Set<string>()
|
||||||
|
seen.set(directory, created)
|
||||||
|
while (seen.size > maxDirs) {
|
||||||
|
const first = seen.keys().next().value
|
||||||
|
if (!first) break
|
||||||
|
const stale = [...(seen.get(first) ?? [])]
|
||||||
|
seen.delete(first)
|
||||||
|
const [, setStore] = globalSync.child(first, { bootstrap: false })
|
||||||
|
evict(first, setStore, stale)
|
||||||
|
}
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearMeta = (directory: string, sessionIDs: string[]) => {
|
||||||
|
if (sessionIDs.length === 0) return
|
||||||
|
setMeta(
|
||||||
|
produce((draft) => {
|
||||||
|
for (const sessionID of sessionIDs) {
|
||||||
|
const key = keyFor(directory, sessionID)
|
||||||
|
delete draft.limit[key]
|
||||||
|
delete draft.complete[key]
|
||||||
|
delete draft.loading[key]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
|
||||||
|
if (sessionIDs.length === 0) return
|
||||||
|
for (const sessionID of sessionIDs) {
|
||||||
|
globalSync.todo.set(sessionID, undefined)
|
||||||
|
}
|
||||||
|
setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
dropSessionCaches(draft, sessionIDs)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
clearMeta(directory, sessionIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
const touch = (directory: string, setStore: Setter, sessionID: string) => {
|
||||||
|
const stale = pickSessionCacheEvictions({
|
||||||
|
seen: seenFor(directory),
|
||||||
|
keep: sessionID,
|
||||||
|
limit: SESSION_CACHE_LIMIT,
|
||||||
|
})
|
||||||
|
evict(directory, setStore, stale)
|
||||||
|
}
|
||||||
|
|
||||||
const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => {
|
const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => {
|
||||||
const messages = await retry(() =>
|
const messages = await retry(() =>
|
||||||
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
|
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
|
||||||
@@ -135,6 +194,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false
|
||||||
|
|
||||||
const loadMessages = async (input: {
|
const loadMessages = async (input: {
|
||||||
directory: string
|
directory: string
|
||||||
client: typeof sdk.client
|
client: typeof sdk.client
|
||||||
@@ -148,6 +209,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
setMeta("loading", key, true)
|
setMeta("loading", key, true)
|
||||||
await fetchMessages(input)
|
await fetchMessages(input)
|
||||||
.then((next) => {
|
.then((next) => {
|
||||||
|
if (!tracked(input.directory, input.sessionID)) return
|
||||||
batch(() => {
|
batch(() => {
|
||||||
input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
|
input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
|
||||||
for (const p of next.part) {
|
for (const p of next.part) {
|
||||||
@@ -158,6 +220,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
if (!tracked(input.directory, input.sessionID)) return
|
||||||
setMeta("loading", key, false)
|
setMeta("loading", key, false)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -199,6 +262,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
parts: Part[]
|
parts: Part[]
|
||||||
agent: string
|
agent: string
|
||||||
model: { providerID: string; modelID: string }
|
model: { providerID: string; modelID: string }
|
||||||
|
variant?: string
|
||||||
}) {
|
}) {
|
||||||
const message: Message = {
|
const message: Message = {
|
||||||
id: input.messageID,
|
id: input.messageID,
|
||||||
@@ -207,6 +271,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
time: { created: Date.now() },
|
time: { created: Date.now() },
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
model: input.model,
|
model: input.model,
|
||||||
|
variant: input.variant,
|
||||||
}
|
}
|
||||||
const [, setStore] = target()
|
const [, setStore] = target()
|
||||||
setOptimisticAdd(setStore as (...args: unknown[]) => void, {
|
setOptimisticAdd(setStore as (...args: unknown[]) => void, {
|
||||||
@@ -222,11 +287,16 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const key = keyFor(directory, sessionID)
|
const key = keyFor(directory, sessionID)
|
||||||
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
|
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
|
||||||
|
|
||||||
|
touch(directory, setStore, sessionID)
|
||||||
|
|
||||||
|
if (store.message[sessionID] !== undefined && hasSession && meta.limit[key] !== undefined) return
|
||||||
|
|
||||||
const limit = meta.limit[key] ?? messagePageSize
|
const limit = meta.limit[key] ?? messagePageSize
|
||||||
|
|
||||||
const sessionReq = hasSession
|
const sessionReq = hasSession
|
||||||
? Promise.resolve()
|
? Promise.resolve()
|
||||||
: retry(() => client.session.get({ sessionID })).then((session) => {
|
: retry(() => client.session.get({ sessionID })).then((session) => {
|
||||||
|
if (!tracked(directory, sessionID)) return
|
||||||
const data = session.data
|
const data = session.data
|
||||||
if (!data) return
|
if (!data) return
|
||||||
setStore(
|
setStore(
|
||||||
@@ -256,11 +326,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const directory = sdk.directory
|
const directory = sdk.directory
|
||||||
const client = sdk.client
|
const client = sdk.client
|
||||||
const [store, setStore] = globalSync.child(directory)
|
const [store, setStore] = globalSync.child(directory)
|
||||||
|
touch(directory, setStore, sessionID)
|
||||||
if (store.session_diff[sessionID] !== undefined) return
|
if (store.session_diff[sessionID] !== undefined) return
|
||||||
|
|
||||||
const key = keyFor(directory, sessionID)
|
const key = keyFor(directory, sessionID)
|
||||||
return runInflight(inflightDiff, key, () =>
|
return runInflight(inflightDiff, key, () =>
|
||||||
retry(() => client.session.diff({ sessionID })).then((diff) => {
|
retry(() => client.session.diff({ sessionID })).then((diff) => {
|
||||||
|
if (!tracked(directory, sessionID)) return
|
||||||
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
|
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -269,6 +341,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const directory = sdk.directory
|
const directory = sdk.directory
|
||||||
const client = sdk.client
|
const client = sdk.client
|
||||||
const [store, setStore] = globalSync.child(directory)
|
const [store, setStore] = globalSync.child(directory)
|
||||||
|
touch(directory, setStore, sessionID)
|
||||||
const existing = store.todo[sessionID]
|
const existing = store.todo[sessionID]
|
||||||
const cached = globalSync.data.session_todo[sessionID]
|
const cached = globalSync.data.session_todo[sessionID]
|
||||||
if (existing !== undefined) {
|
if (existing !== undefined) {
|
||||||
@@ -285,6 +358,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const key = keyFor(directory, sessionID)
|
const key = keyFor(directory, sessionID)
|
||||||
return runInflight(inflightTodo, key, () =>
|
return runInflight(inflightTodo, key, () =>
|
||||||
retry(() => client.session.todo({ sessionID })).then((todo) => {
|
retry(() => client.session.todo({ sessionID })).then((todo) => {
|
||||||
|
if (!tracked(directory, sessionID)) return
|
||||||
const list = todo.data ?? []
|
const list = todo.data ?? []
|
||||||
setStore("todo", sessionID, reconcile(list, { key: "id" }))
|
setStore("todo", sessionID, reconcile(list, { key: "id" }))
|
||||||
globalSync.todo.set(sessionID, list)
|
globalSync.todo.set(sessionID, list)
|
||||||
@@ -308,6 +382,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const directory = sdk.directory
|
const directory = sdk.directory
|
||||||
const client = sdk.client
|
const client = sdk.client
|
||||||
const [, setStore] = globalSync.child(directory)
|
const [, setStore] = globalSync.child(directory)
|
||||||
|
touch(directory, setStore, sessionID)
|
||||||
const key = keyFor(directory, sessionID)
|
const key = keyFor(directory, sessionID)
|
||||||
const step = count ?? messagePageSize
|
const step = count ?? messagePageSize
|
||||||
if (meta.loading[key]) return
|
if (meta.loading[key]) return
|
||||||
@@ -323,6 +398,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
evict(sessionID: string, directory = sdk.directory) {
|
||||||
|
const [, setStore] = globalSync.child(directory)
|
||||||
|
seenFor(directory).delete(sessionID)
|
||||||
|
evict(directory, setStore, [sessionID])
|
||||||
|
},
|
||||||
fetch: async (count = 10) => {
|
fetch: async (count = 10) => {
|
||||||
const directory = sdk.directory
|
const directory = sdk.directory
|
||||||
const client = sdk.client
|
const client = sdk.client
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { beforeAll, describe, expect, mock, test } from "bun:test"
|
|||||||
|
|
||||||
let getWorkspaceTerminalCacheKey: (dir: string) => string
|
let getWorkspaceTerminalCacheKey: (dir: string) => string
|
||||||
let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
|
let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
|
||||||
|
let migrateTerminalState: (value: unknown) => unknown
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
mock.module("@solidjs/router", () => ({
|
mock.module("@solidjs/router", () => ({
|
||||||
@@ -17,6 +18,7 @@ beforeAll(async () => {
|
|||||||
const mod = await import("./terminal")
|
const mod = await import("./terminal")
|
||||||
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
|
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
|
||||||
getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys
|
getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys
|
||||||
|
migrateTerminalState = mod.migrateTerminalState
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("getWorkspaceTerminalCacheKey", () => {
|
describe("getWorkspaceTerminalCacheKey", () => {
|
||||||
@@ -37,3 +39,44 @@ describe("getLegacyTerminalStorageKeys", () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("migrateTerminalState", () => {
|
||||||
|
test("drops invalid terminals and restores a valid active terminal", () => {
|
||||||
|
expect(
|
||||||
|
migrateTerminalState({
|
||||||
|
active: "missing",
|
||||||
|
all: [
|
||||||
|
null,
|
||||||
|
{ id: "one", title: "Terminal 2" },
|
||||||
|
{ id: "one", title: "duplicate", titleNumber: 9 },
|
||||||
|
{ id: "two", title: "logs", titleNumber: 4, rows: 24, cols: 80 },
|
||||||
|
{ title: "no-id" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
active: "one",
|
||||||
|
all: [
|
||||||
|
{ id: "one", title: "Terminal 2", titleNumber: 2 },
|
||||||
|
{ id: "two", title: "logs", titleNumber: 4, rows: 24, cols: 80 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps a valid active id", () => {
|
||||||
|
expect(
|
||||||
|
migrateTerminalState({
|
||||||
|
active: "two",
|
||||||
|
all: [
|
||||||
|
{ id: "one", title: "Terminal 1" },
|
||||||
|
{ id: "two", title: "shell", titleNumber: 7 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
active: "two",
|
||||||
|
all: [
|
||||||
|
{ id: "one", title: "Terminal 1", titleNumber: 1 },
|
||||||
|
{ id: "two", title: "shell", titleNumber: 7 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js"
|
import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "solid-js"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import type { Platform } from "./platform"
|
import type { Platform } from "./platform"
|
||||||
@@ -20,6 +20,71 @@ export type LocalPTY = {
|
|||||||
const WORKSPACE_KEY = "__workspace__"
|
const WORKSPACE_KEY = "__workspace__"
|
||||||
const MAX_TERMINAL_SESSIONS = 20
|
const MAX_TERMINAL_SESSIONS = 20
|
||||||
|
|
||||||
|
function record(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown) {
|
||||||
|
return typeof value === "string" ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(value: unknown) {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberFromTitle(title: string) {
|
||||||
|
const match = title.match(/^Terminal (\d+)$/)
|
||||||
|
if (!match) return
|
||||||
|
const value = Number(match[1])
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function pty(value: unknown): LocalPTY | undefined {
|
||||||
|
if (!record(value)) return
|
||||||
|
|
||||||
|
const id = text(value.id)
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
const title = text(value.title) ?? ""
|
||||||
|
const number = num(value.titleNumber)
|
||||||
|
const rows = num(value.rows)
|
||||||
|
const cols = num(value.cols)
|
||||||
|
const buffer = text(value.buffer)
|
||||||
|
const scrollY = num(value.scrollY)
|
||||||
|
const cursor = num(value.cursor)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
titleNumber: number && number > 0 ? number : (numberFromTitle(title) ?? 0),
|
||||||
|
...(rows !== undefined ? { rows } : {}),
|
||||||
|
...(cols !== undefined ? { cols } : {}),
|
||||||
|
...(buffer !== undefined ? { buffer } : {}),
|
||||||
|
...(scrollY !== undefined ? { scrollY } : {}),
|
||||||
|
...(cursor !== undefined ? { cursor } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function migrateTerminalState(value: unknown) {
|
||||||
|
if (!record(value)) return value
|
||||||
|
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const all = (Array.isArray(value.all) ? value.all : []).flatMap((item) => {
|
||||||
|
const next = pty(item)
|
||||||
|
if (!next || seen.has(next.id)) return []
|
||||||
|
seen.add(next.id)
|
||||||
|
return [next]
|
||||||
|
})
|
||||||
|
|
||||||
|
const active = text(value.active)
|
||||||
|
|
||||||
|
return {
|
||||||
|
active: active && seen.has(active) ? active : all[0]?.id,
|
||||||
|
all,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getWorkspaceTerminalCacheKey(dir: string) {
|
export function getWorkspaceTerminalCacheKey(dir: string) {
|
||||||
return `${dir}:${WORKSPACE_KEY}`
|
return `${dir}:${WORKSPACE_KEY}`
|
||||||
}
|
}
|
||||||
@@ -38,6 +103,16 @@ type TerminalCacheEntry = {
|
|||||||
|
|
||||||
const caches = new Set<Map<string, TerminalCacheEntry>>()
|
const caches = new Set<Map<string, TerminalCacheEntry>>()
|
||||||
|
|
||||||
|
const trimTerminal = (pty: LocalPTY) => {
|
||||||
|
if (!pty.buffer && pty.cursor === undefined && pty.scrollY === undefined) return pty
|
||||||
|
return {
|
||||||
|
...pty,
|
||||||
|
buffer: undefined,
|
||||||
|
cursor: undefined,
|
||||||
|
scrollY: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) {
|
export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) {
|
||||||
const key = getWorkspaceTerminalCacheKey(dir)
|
const key = getWorkspaceTerminalCacheKey(dir)
|
||||||
for (const cache of caches) {
|
for (const cache of caches) {
|
||||||
@@ -61,16 +136,11 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat
|
|||||||
function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, legacySessionID?: string) {
|
function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, legacySessionID?: string) {
|
||||||
const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
|
const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
|
||||||
|
|
||||||
const numberFromTitle = (title: string) => {
|
|
||||||
const match = title.match(/^Terminal (\d+)$/)
|
|
||||||
if (!match) return
|
|
||||||
const value = Number(match[1])
|
|
||||||
if (!Number.isFinite(value) || value <= 0) return
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
Persist.workspace(dir, "terminal", legacy),
|
{
|
||||||
|
...Persist.workspace(dir, "terminal", legacy),
|
||||||
|
migrate: migrateTerminalState,
|
||||||
|
},
|
||||||
createStore<{
|
createStore<{
|
||||||
active?: string
|
active?: string
|
||||||
all: LocalPTY[]
|
all: LocalPTY[]
|
||||||
@@ -118,26 +188,6 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
|
|||||||
})
|
})
|
||||||
onCleanup(unsub)
|
onCleanup(unsub)
|
||||||
|
|
||||||
const meta = { migrated: false }
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!ready()) return
|
|
||||||
if (meta.migrated) return
|
|
||||||
meta.migrated = true
|
|
||||||
|
|
||||||
setStore("all", (all) => {
|
|
||||||
const next = all.map((pty) => {
|
|
||||||
const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined
|
|
||||||
if (direct !== undefined) return pty
|
|
||||||
const parsed = numberFromTitle(pty.title)
|
|
||||||
if (parsed === undefined) return pty
|
|
||||||
return { ...pty, titleNumber: parsed }
|
|
||||||
})
|
|
||||||
if (next.every((pty, index) => pty === all[index])) return all
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready,
|
||||||
all: createMemo(() => store.all),
|
all: createMemo(() => store.all),
|
||||||
@@ -188,6 +238,18 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
|
|||||||
console.error("Failed to update terminal", error)
|
console.error("Failed to update terminal", error)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
trim(id: string) {
|
||||||
|
const index = store.all.findIndex((x) => x.id === id)
|
||||||
|
if (index === -1) return
|
||||||
|
setStore("all", index, (pty) => trimTerminal(pty))
|
||||||
|
},
|
||||||
|
trimAll() {
|
||||||
|
setStore("all", (all) => {
|
||||||
|
const next = all.map(trimTerminal)
|
||||||
|
if (next.every((pty, index) => pty === all[index])) return all
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
},
|
||||||
async clone(id: string) {
|
async clone(id: string) {
|
||||||
const index = store.all.findIndex((x) => x.id === id)
|
const index = store.all.findIndex((x) => x.id === id)
|
||||||
const pty = store.all[index]
|
const pty = store.all[index]
|
||||||
@@ -322,12 +384,27 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
|||||||
|
|
||||||
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
|
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => ({ dir: params.dir, id: params.id }),
|
||||||
|
(next, prev) => {
|
||||||
|
if (!prev?.dir) return
|
||||||
|
if (next.dir === prev.dir && next.id === prev.id) return
|
||||||
|
if (next.dir === prev.dir && next.id) return
|
||||||
|
loadWorkspace(prev.dir, prev.id).trimAll()
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready: () => workspace().ready(),
|
ready: () => workspace().ready(),
|
||||||
all: () => workspace().all(),
|
all: () => workspace().all(),
|
||||||
active: () => workspace().active(),
|
active: () => workspace().active(),
|
||||||
new: () => workspace().new(),
|
new: () => workspace().new(),
|
||||||
update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty),
|
update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty),
|
||||||
|
trim: (id: string) => workspace().trim(id),
|
||||||
|
trimAll: () => workspace().trimAll(),
|
||||||
clone: (id: string) => workspace().clone(id),
|
clone: (id: string) => workspace().clone(id),
|
||||||
open: (id: string) => workspace().open(id),
|
open: (id: string) => workspace().open(id),
|
||||||
close: (id: string) => workspace().close(id),
|
close: (id: string) => workspace().close(id),
|
||||||
|
|||||||
@@ -456,6 +456,7 @@ export const dict = {
|
|||||||
"session.todo.title": "المهام",
|
"session.todo.title": "المهام",
|
||||||
"session.todo.collapse": "طي",
|
"session.todo.collapse": "طي",
|
||||||
"session.todo.expand": "توسيع",
|
"session.todo.expand": "توسيع",
|
||||||
|
"session.new.title": "ابنِ أي شيء",
|
||||||
"session.new.worktree.main": "الفرع الرئيسي",
|
"session.new.worktree.main": "الفرع الرئيسي",
|
||||||
"session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})",
|
"session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})",
|
||||||
"session.new.worktree.create": "إنشاء شجرة عمل جديدة",
|
"session.new.worktree.create": "إنشاء شجرة عمل جديدة",
|
||||||
|
|||||||
@@ -459,6 +459,7 @@ export const dict = {
|
|||||||
"session.todo.title": "Tarefas",
|
"session.todo.title": "Tarefas",
|
||||||
"session.todo.collapse": "Recolher",
|
"session.todo.collapse": "Recolher",
|
||||||
"session.todo.expand": "Expandir",
|
"session.todo.expand": "Expandir",
|
||||||
|
"session.new.title": "Crie qualquer coisa",
|
||||||
"session.new.worktree.main": "Branch principal",
|
"session.new.worktree.main": "Branch principal",
|
||||||
"session.new.worktree.mainWithBranch": "Branch principal ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Branch principal ({{branch}})",
|
||||||
"session.new.worktree.create": "Criar novo worktree",
|
"session.new.worktree.create": "Criar novo worktree",
|
||||||
|
|||||||
@@ -515,6 +515,7 @@ export const dict = {
|
|||||||
"session.todo.collapse": "Sažmi",
|
"session.todo.collapse": "Sažmi",
|
||||||
"session.todo.expand": "Proširi",
|
"session.todo.expand": "Proširi",
|
||||||
|
|
||||||
|
"session.new.title": "Napravi bilo šta",
|
||||||
"session.new.worktree.main": "Glavna grana",
|
"session.new.worktree.main": "Glavna grana",
|
||||||
"session.new.worktree.mainWithBranch": "Glavna grana ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Glavna grana ({{branch}})",
|
||||||
"session.new.worktree.create": "Kreiraj novi worktree",
|
"session.new.worktree.create": "Kreiraj novi worktree",
|
||||||
|
|||||||
@@ -510,6 +510,7 @@ export const dict = {
|
|||||||
"session.todo.collapse": "Skjul",
|
"session.todo.collapse": "Skjul",
|
||||||
"session.todo.expand": "Udvid",
|
"session.todo.expand": "Udvid",
|
||||||
|
|
||||||
|
"session.new.title": "Byg hvad som helst",
|
||||||
"session.new.worktree.main": "Hovedgren",
|
"session.new.worktree.main": "Hovedgren",
|
||||||
"session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})",
|
||||||
"session.new.worktree.create": "Opret nyt worktree",
|
"session.new.worktree.create": "Opret nyt worktree",
|
||||||
|
|||||||
@@ -467,6 +467,7 @@ export const dict = {
|
|||||||
"session.todo.title": "Aufgaben",
|
"session.todo.title": "Aufgaben",
|
||||||
"session.todo.collapse": "Einklappen",
|
"session.todo.collapse": "Einklappen",
|
||||||
"session.todo.expand": "Ausklappen",
|
"session.todo.expand": "Ausklappen",
|
||||||
|
"session.new.title": "Baue, was du willst",
|
||||||
"session.new.worktree.main": "Haupt-Branch",
|
"session.new.worktree.main": "Haupt-Branch",
|
||||||
"session.new.worktree.mainWithBranch": "Haupt-Branch ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Haupt-Branch ({{branch}})",
|
||||||
"session.new.worktree.create": "Neuen Worktree erstellen",
|
"session.new.worktree.create": "Neuen Worktree erstellen",
|
||||||
|
|||||||
@@ -531,6 +531,7 @@ export const dict = {
|
|||||||
"session.todo.collapse": "Collapse",
|
"session.todo.collapse": "Collapse",
|
||||||
"session.todo.expand": "Expand",
|
"session.todo.expand": "Expand",
|
||||||
|
|
||||||
|
"session.new.title": "Build anything",
|
||||||
"session.new.worktree.main": "Main branch",
|
"session.new.worktree.main": "Main branch",
|
||||||
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
|
||||||
"session.new.worktree.create": "Create new worktree",
|
"session.new.worktree.create": "Create new worktree",
|
||||||
|
|||||||
@@ -516,6 +516,7 @@ export const dict = {
|
|||||||
"session.todo.collapse": "Contraer",
|
"session.todo.collapse": "Contraer",
|
||||||
"session.todo.expand": "Expandir",
|
"session.todo.expand": "Expandir",
|
||||||
|
|
||||||
|
"session.new.title": "Construye lo que quieras",
|
||||||
"session.new.worktree.main": "Rama principal",
|
"session.new.worktree.main": "Rama principal",
|
||||||
"session.new.worktree.mainWithBranch": "Rama principal ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Rama principal ({{branch}})",
|
||||||
"session.new.worktree.create": "Crear nuevo árbol de trabajo",
|
"session.new.worktree.create": "Crear nuevo árbol de trabajo",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+226
-190
@@ -34,6 +34,7 @@ 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 { 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"
|
||||||
@@ -53,6 +54,7 @@ import { useCommand, type CommandOption } from "@/context/command"
|
|||||||
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
import { ConstrainDragXAxis } 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"
|
||||||
@@ -423,6 +425,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"
|
||||||
@@ -657,25 +670,24 @@ export default function Layout(props: ParentProps) {
|
|||||||
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(() => {
|
createEffect(() => {
|
||||||
@@ -724,6 +736,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
|
return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
|
||||||
.then((messages) => {
|
.then((messages) => {
|
||||||
if (prefetchToken.value !== token) return
|
if (prefetchToken.value !== token) return
|
||||||
|
if (!lruFor(directory).has(sessionID)) 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)
|
||||||
@@ -787,7 +800,18 @@ export default function Layout(props: ParentProps) {
|
|||||||
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)
|
const stale = markPrefetched(directory, session.id)
|
||||||
|
if (stale.length > 0) {
|
||||||
|
const [, setStore] = globalSync.child(directory, { bootstrap: false })
|
||||||
|
for (const id of stale) {
|
||||||
|
globalSync.todo.set(id, undefined)
|
||||||
|
}
|
||||||
|
setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
dropSessionCaches(draft, stale)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
@@ -1879,6 +1903,7 @@ 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 projectName = createMemo(() => {
|
const projectName = createMemo(() => {
|
||||||
const project = panelProps.project
|
const project = panelProps.project
|
||||||
if (!project) return ""
|
if (!project) return ""
|
||||||
@@ -1904,11 +1929,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,
|
||||||
}}
|
}}
|
||||||
@@ -2111,193 +2136,204 @@ export default function Layout(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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
|
<SidebarContent
|
||||||
opened={() => layout.sidebar.opened()}
|
opened={() => layout.sidebar.opened()}
|
||||||
aimMove={aim.move}
|
aimMove={aim.move}
|
||||||
projects={() => layout.projects.list()}
|
projects={() => layout.projects.list()}
|
||||||
renderProject={(project) => (
|
renderProject={(project) => (
|
||||||
<SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} />
|
<SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} />
|
||||||
)}
|
)}
|
||||||
handleDragStart={handleDragStart}
|
handleDragStart={handleDragStart}
|
||||||
handleDragEnd={handleDragEnd}
|
handleDragEnd={handleDragEnd}
|
||||||
handleDragOver={handleDragOver}
|
handleDragOver={handleDragOver}
|
||||||
openProjectLabel={language.t("command.project.open")}
|
openProjectLabel={language.t("command.project.open")}
|
||||||
openProjectKeybind={() => command.keybind("project.open")}
|
openProjectKeybind={() => command.keybind("project.open")}
|
||||||
onOpenProject={chooseProject}
|
onOpenProject={chooseProject}
|
||||||
renderProjectOverlay={() => (
|
renderProjectOverlay={() => (
|
||||||
<ProjectDragOverlay projects={() => layout.projects.list()} activeProject={() => store.activeProject} />
|
<ProjectDragOverlay
|
||||||
)}
|
projects={() => layout.projects.list()}
|
||||||
settingsLabel={() => language.t("sidebar.settings")}
|
activeProject={() => store.activeProject}
|
||||||
settingsKeybind={() => command.keybind("settings.open")}
|
/>
|
||||||
onOpenSettings={openSettings}
|
)}
|
||||||
helpLabel={() => language.t("sidebar.help")}
|
settingsLabel={() => language.t("sidebar.settings")}
|
||||||
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
settingsKeybind={() => command.keybind("settings.open")}
|
||||||
renderPanel={() => (
|
onOpenSettings={openSettings}
|
||||||
<Show when={currentProject()} keyed>
|
helpLabel={() => language.t("sidebar.help")}
|
||||||
{(project) => <SidebarPanel project={project} merged />}
|
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||||
</Show>
|
renderPanel={() => (
|
||||||
)}
|
<Show when={currentProject()} keyed>
|
||||||
|
{(project) => <SidebarPanel project={project} merged />}
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Show when={layout.sidebar.opened()}>
|
||||||
|
<div onPointerDown={() => setSizing(true)}>
|
||||||
|
<ResizeHandle
|
||||||
|
direction="horizontal"
|
||||||
|
size={layout.sidebar.width()}
|
||||||
|
min={244}
|
||||||
|
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||||
|
collapseThreshold={244}
|
||||||
|
onResize={(w) => {
|
||||||
|
setSizing(true)
|
||||||
|
if (sizet !== undefined) clearTimeout(sizet)
|
||||||
|
sizet = window.setTimeout(() => setSizing(false), 120)
|
||||||
|
layout.sidebar.resize(w)
|
||||||
|
}}
|
||||||
|
onCollapse={layout.sidebar.close}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
|
||||||
|
style={{ left: "calc(4rem + 12px)" }}
|
||||||
/>
|
/>
|
||||||
</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
|
||||||
|
mobile
|
||||||
|
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>
|
||||||
</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>
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import { Button } from "@opencode-ai/ui/button"
|
|||||||
import { ContextMenu } from "@opencode-ai/ui/context-menu"
|
import { ContextMenu } from "@opencode-ai/ui/context-menu"
|
||||||
import { HoverCard } from "@opencode-ai/ui/hover-card"
|
import { HoverCard } from "@opencode-ai/ui/hover-card"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
|
||||||
import { createSortable } from "@thisbeyond/solid-dnd"
|
import { createSortable } from "@thisbeyond/solid-dnd"
|
||||||
import { useLayout, type LocalProject } from "@/context/layout"
|
import { useLayout, type LocalProject } from "@/context/layout"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
@@ -93,6 +91,7 @@ const ProjectTile = (props: {
|
|||||||
modal={!props.sidebarHovering()}
|
modal={!props.sidebarHovering()}
|
||||||
onOpenChange={(value) => {
|
onOpenChange={(value) => {
|
||||||
props.setMenu(value)
|
props.setMenu(value)
|
||||||
|
props.setSuppressHover(value)
|
||||||
if (value) props.setOpen(false)
|
if (value) props.setOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -109,6 +108,12 @@ const ProjectTile = (props: {
|
|||||||
!props.selected() && !props.active(),
|
!props.selected() && !props.active(),
|
||||||
"bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
|
"bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
|
||||||
}}
|
}}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
if (!props.overlay()) return
|
||||||
|
if (event.button !== 2 && !(event.button === 0 && event.ctrlKey)) return
|
||||||
|
props.setSuppressHover(true)
|
||||||
|
event.preventDefault()
|
||||||
|
}}
|
||||||
onMouseEnter={(event: MouseEvent) => {
|
onMouseEnter={(event: MouseEvent) => {
|
||||||
if (!props.overlay()) return
|
if (!props.overlay()) return
|
||||||
if (props.suppressHover()) return
|
if (props.suppressHover()) return
|
||||||
@@ -194,21 +199,6 @@ const ProjectPreviewPanel = (props: {
|
|||||||
<div class="-m-3 p-2 flex flex-col w-72">
|
<div class="-m-3 p-2 flex flex-col w-72">
|
||||||
<div class="px-4 pt-2 pb-1 flex items-center gap-2">
|
<div class="px-4 pt-2 pb-1 flex items-center gap-2">
|
||||||
<div class="text-14-medium text-text-strong truncate grow">{displayName(props.project)}</div>
|
<div class="text-14-medium text-text-strong truncate grow">{displayName(props.project)}</div>
|
||||||
<Tooltip value={props.language.t("common.close")} placement="top" gutter={6}>
|
|
||||||
<IconButton
|
|
||||||
icon="circle-x"
|
|
||||||
variant="ghost"
|
|
||||||
class="shrink-0"
|
|
||||||
data-action="project-close-hover"
|
|
||||||
data-project={base64Encode(props.project.worktree)}
|
|
||||||
aria-label={props.language.t("common.close")}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
props.setOpen(false)
|
|
||||||
props.ctx.closeProject(props.project.worktree)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="px-4 pb-2 text-12-medium text-text-weak">{props.language.t("sidebar.project.recentSessions")}</div>
|
<div class="px-4 pb-2 text-12-medium text-text-weak">{props.language.t("sidebar.project.recentSessions")}</div>
|
||||||
<div class="px-2 pb-2 flex flex-col gap-2">
|
<div class="px-2 pb-2 flex flex-col gap-2">
|
||||||
|
|||||||
@@ -32,11 +32,12 @@ import { useLayout } from "@/context/layout"
|
|||||||
import { usePrompt } from "@/context/prompt"
|
import { usePrompt } from "@/context/prompt"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
|
import { useTerminal } from "@/context/terminal"
|
||||||
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
|
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
|
||||||
import { createOpenReviewFile } from "@/pages/session/helpers"
|
import { createOpenReviewFile, createSizing, focusTerminalById } from "@/pages/session/helpers"
|
||||||
import { MessageTimeline } from "@/pages/session/message-timeline"
|
import { MessageTimeline } from "@/pages/session/message-timeline"
|
||||||
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
|
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
|
||||||
import { createScrollSpy } from "@/pages/session/scroll-spy"
|
import { resetSessionModel, syncSessionModel } from "@/pages/session/session-model-helpers"
|
||||||
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
|
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
|
||||||
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||||
@@ -267,6 +268,7 @@ export default function Page() {
|
|||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const comments = useComments()
|
const comments = useComments()
|
||||||
|
const terminal = useTerminal()
|
||||||
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -284,6 +286,7 @@ export default function Page() {
|
|||||||
const [ui, setUi] = createStore({
|
const [ui, setUi] = createStore({
|
||||||
git: false,
|
git: false,
|
||||||
pendingMessage: undefined as string | undefined,
|
pendingMessage: undefined as string | undefined,
|
||||||
|
reviewSnap: false,
|
||||||
scrollGesture: 0,
|
scrollGesture: 0,
|
||||||
scroll: {
|
scroll: {
|
||||||
overflow: false,
|
overflow: false,
|
||||||
@@ -336,6 +339,7 @@ export default function Page() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||||
|
const size = createSizing()
|
||||||
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
||||||
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
|
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
|
||||||
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
|
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
|
||||||
@@ -421,15 +425,24 @@ export default function Page() {
|
|||||||
() => {
|
() => {
|
||||||
const msg = lastUserMessage()
|
const msg = lastUserMessage()
|
||||||
if (!msg) return
|
if (!msg) return
|
||||||
if (msg.agent) {
|
syncSessionModel(local, msg)
|
||||||
local.agent.set(msg.agent)
|
|
||||||
if (local.agent.current()?.model) return
|
|
||||||
}
|
|
||||||
if (msg.model) local.model.set(msg.model)
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => ({ dir: params.dir, id: params.id }),
|
||||||
|
(next, prev) => {
|
||||||
|
if (!prev) return
|
||||||
|
if (next.dir === prev.dir && next.id === prev.id) return
|
||||||
|
if (prev.id) sync.session.evict(prev.id, prev.dir)
|
||||||
|
if (!next.id) resetSessionModel(local)
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
messageId: undefined as string | undefined,
|
messageId: undefined as string | undefined,
|
||||||
mobileTab: "session" as "session" | "changes",
|
mobileTab: "session" as "session" | "changes",
|
||||||
@@ -449,6 +462,21 @@ export default function Page() {
|
|||||||
return key
|
return key
|
||||||
}, sessionKey())
|
}, sessionKey())
|
||||||
|
|
||||||
|
let reviewFrame: number | undefined
|
||||||
|
|
||||||
|
createComputed((prev) => {
|
||||||
|
const open = desktopReviewOpen()
|
||||||
|
if (prev === undefined || prev === open) return open
|
||||||
|
|
||||||
|
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
|
||||||
|
setUi("reviewSnap", true)
|
||||||
|
reviewFrame = requestAnimationFrame(() => {
|
||||||
|
reviewFrame = undefined
|
||||||
|
setUi("reviewSnap", false)
|
||||||
|
})
|
||||||
|
return open
|
||||||
|
}, desktopReviewOpen())
|
||||||
|
|
||||||
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
|
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
|
||||||
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
|
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
|
||||||
|
|
||||||
@@ -459,20 +487,49 @@ export default function Page() {
|
|||||||
return "main"
|
return "main"
|
||||||
})
|
})
|
||||||
|
|
||||||
const activeMessage = createMemo(() => {
|
|
||||||
if (!store.messageId) return lastUserMessage()
|
|
||||||
const found = visibleUserMessages()?.find((m) => m.id === store.messageId)
|
|
||||||
return found ?? lastUserMessage()
|
|
||||||
})
|
|
||||||
const setActiveMessage = (message: UserMessage | undefined) => {
|
const setActiveMessage = (message: UserMessage | undefined) => {
|
||||||
|
messageMark = scrollMark
|
||||||
setStore("messageId", message?.id)
|
setStore("messageId", message?.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const anchor = (id: string) => `message-${id}`
|
||||||
|
|
||||||
|
const cursor = () => {
|
||||||
|
const root = scroller
|
||||||
|
if (!root) return store.messageId
|
||||||
|
|
||||||
|
const box = root.getBoundingClientRect()
|
||||||
|
const line = box.top + 100
|
||||||
|
const list = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
|
||||||
|
.map((el) => {
|
||||||
|
const id = el.dataset.messageId
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
return { id, top: rect.top, bottom: rect.bottom }
|
||||||
|
})
|
||||||
|
.filter((item): item is { id: string; top: number; bottom: number } => !!item)
|
||||||
|
|
||||||
|
const shown = list.filter((item) => item.bottom > box.top && item.top < box.bottom)
|
||||||
|
const hit = shown.find((item) => item.top <= line && item.bottom >= line)
|
||||||
|
if (hit) return hit.id
|
||||||
|
|
||||||
|
const near = [...shown].sort((a, b) => {
|
||||||
|
const da = Math.abs(a.top - line)
|
||||||
|
const db = Math.abs(b.top - line)
|
||||||
|
if (da !== db) return da - db
|
||||||
|
return a.top - b.top
|
||||||
|
})[0]
|
||||||
|
if (near) return near.id
|
||||||
|
|
||||||
|
return list.filter((item) => item.top <= line).at(-1)?.id ?? list[0]?.id ?? store.messageId
|
||||||
|
}
|
||||||
|
|
||||||
function navigateMessageByOffset(offset: number) {
|
function navigateMessageByOffset(offset: number) {
|
||||||
const msgs = visibleUserMessages()
|
const msgs = visibleUserMessages()
|
||||||
if (msgs.length === 0) return
|
if (msgs.length === 0) return
|
||||||
|
|
||||||
const current = store.messageId
|
const current = store.messageId && messageMark === scrollMark ? store.messageId : cursor()
|
||||||
const base = current ? msgs.findIndex((m) => m.id === current) : msgs.length
|
const base = current ? msgs.findIndex((m) => m.id === current) : msgs.length
|
||||||
const currentIndex = base === -1 ? msgs.length : base
|
const currentIndex = base === -1 ? msgs.length : base
|
||||||
const targetIndex = currentIndex + offset
|
const targetIndex = currentIndex + offset
|
||||||
@@ -545,6 +602,8 @@ export default function Page() {
|
|||||||
let dockHeight = 0
|
let dockHeight = 0
|
||||||
let scroller: HTMLDivElement | undefined
|
let scroller: HTMLDivElement | undefined
|
||||||
let content: HTMLDivElement | undefined
|
let content: HTMLDivElement | undefined
|
||||||
|
let scrollMark = 0
|
||||||
|
let messageMark = 0
|
||||||
|
|
||||||
const scrollGestureWindowMs = 250
|
const scrollGestureWindowMs = 250
|
||||||
|
|
||||||
@@ -589,6 +648,7 @@ export default function Page() {
|
|||||||
() => {
|
() => {
|
||||||
setStore("messageId", undefined)
|
setStore("messageId", undefined)
|
||||||
setStore("changes", "session")
|
setStore("changes", "session")
|
||||||
|
setUi("pendingMessage", undefined)
|
||||||
},
|
},
|
||||||
{ defer: true },
|
{ defer: true },
|
||||||
),
|
),
|
||||||
@@ -701,8 +761,11 @@ export default function Page() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't autofocus chat if desktop terminal panel is open
|
// Prefer the open terminal over the composer when it can take focus
|
||||||
if (isDesktop() && view().terminal.opened()) return
|
if (view().terminal.opened()) {
|
||||||
|
const id = terminal.active()
|
||||||
|
if (id && focusTerminalById(id)) return
|
||||||
|
}
|
||||||
|
|
||||||
// Only treat explicit scroll keys as potential "user scroll" gestures.
|
// Only treat explicit scroll keys as potential "user scroll" gestures.
|
||||||
if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") {
|
if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") {
|
||||||
@@ -794,7 +857,7 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const emptyTurn = () => (
|
const emptyTurn = () => (
|
||||||
<div class="h-full pb-30 flex flex-col items-center justify-center text-center gap-6">
|
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||||
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
|
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -909,7 +972,7 @@ export default function Page() {
|
|||||||
diffStyle: layout.review.diffStyle(),
|
diffStyle: layout.review.diffStyle(),
|
||||||
onDiffStyleChange: layout.review.setDiffStyle,
|
onDiffStyleChange: layout.review.setDiffStyle,
|
||||||
loadingClass: "px-6 py-4 text-text-weak",
|
loadingClass: "px-6 py-4 text-text-weak",
|
||||||
emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
|
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1033,23 +1096,6 @@ export default function Page() {
|
|||||||
tabs().setActive(next)
|
tabs().setActive(next)
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => layout.fileTree.opened(),
|
|
||||||
(opened, prev) => {
|
|
||||||
if (prev === undefined) return
|
|
||||||
if (!isDesktop()) return
|
|
||||||
|
|
||||||
if (opened) {
|
|
||||||
const active = tabs().active()
|
|
||||||
const tab = active === "review" || (!active && hasReview()) ? "changes" : "all"
|
|
||||||
layout.fileTree.setTab(tab)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const id = params.id
|
const id = params.id
|
||||||
if (!id) return
|
if (!id) return
|
||||||
@@ -1100,12 +1146,6 @@ export default function Page() {
|
|||||||
|
|
||||||
let scrollStateFrame: number | undefined
|
let scrollStateFrame: number | undefined
|
||||||
let scrollStateTarget: HTMLDivElement | undefined
|
let scrollStateTarget: HTMLDivElement | undefined
|
||||||
const scrollSpy = createScrollSpy({
|
|
||||||
onActive: (id) => {
|
|
||||||
if (id === store.messageId) return
|
|
||||||
setStore("messageId", id)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const updateScrollState = (el: HTMLDivElement) => {
|
const updateScrollState = (el: HTMLDivElement) => {
|
||||||
const max = el.scrollHeight - el.clientHeight
|
const max = el.scrollHeight - el.clientHeight
|
||||||
@@ -1153,31 +1193,21 @@ export default function Page() {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
sessionKey,
|
|
||||||
() => {
|
|
||||||
scrollSpy.clear()
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const anchor = (id: string) => `message-${id}`
|
|
||||||
|
|
||||||
const setScrollRef = (el: HTMLDivElement | undefined) => {
|
const setScrollRef = (el: HTMLDivElement | undefined) => {
|
||||||
scroller = el
|
scroller = el
|
||||||
autoScroll.scrollRef(el)
|
autoScroll.scrollRef(el)
|
||||||
scrollSpy.setContainer(el)
|
|
||||||
if (el) scheduleScrollState(el)
|
if (el) scheduleScrollState(el)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const markUserScroll = () => {
|
||||||
|
scrollMark += 1
|
||||||
|
}
|
||||||
|
|
||||||
createResizeObserver(
|
createResizeObserver(
|
||||||
() => content,
|
() => content,
|
||||||
() => {
|
() => {
|
||||||
const el = scroller
|
const el = scroller
|
||||||
if (el) scheduleScrollState(el)
|
if (el) scheduleScrollState(el)
|
||||||
scrollSpy.markDirty()
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1210,7 +1240,6 @@ export default function Page() {
|
|||||||
if (stick) autoScroll.forceScrollToBottom()
|
if (stick) autoScroll.forceScrollToBottom()
|
||||||
|
|
||||||
if (el) scheduleScrollState(el)
|
if (el) scheduleScrollState(el)
|
||||||
scrollSpy.markDirty()
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1238,7 +1267,7 @@ export default function Page() {
|
|||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
document.removeEventListener("keydown", handleKeyDown)
|
document.removeEventListener("keydown", handleKeyDown)
|
||||||
scrollSpy.destroy()
|
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
|
||||||
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
|
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1258,9 +1287,9 @@ export default function Page() {
|
|||||||
{/* Session panel */}
|
{/* Session panel */}
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true,
|
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger flex-1 md:flex-none": true,
|
||||||
"flex-1": true,
|
"transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||||
"md:flex-none": desktopSidePanelOpen(),
|
!size.active() && !ui.reviewSnap,
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
width: sessionPanelWidth(),
|
width: sessionPanelWidth(),
|
||||||
@@ -1269,7 +1298,7 @@ export default function Page() {
|
|||||||
<div class="flex-1 min-h-0 overflow-hidden">
|
<div class="flex-1 min-h-0 overflow-hidden">
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={params.id}>
|
<Match when={params.id}>
|
||||||
<Show when={activeMessage()}>
|
<Show when={lastUserMessage()}>
|
||||||
<MessageTimeline
|
<MessageTimeline
|
||||||
mobileChanges={mobileChanges()}
|
mobileChanges={mobileChanges()}
|
||||||
mobileFallback={reviewContent({
|
mobileFallback={reviewContent({
|
||||||
@@ -1280,7 +1309,7 @@ export default function Page() {
|
|||||||
container: "px-4",
|
container: "px-4",
|
||||||
},
|
},
|
||||||
loadingClass: "px-4 py-4 text-text-weak",
|
loadingClass: "px-4 py-4 text-text-weak",
|
||||||
emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
|
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
|
||||||
})}
|
})}
|
||||||
scroll={ui.scroll}
|
scroll={ui.scroll}
|
||||||
onResumeScroll={resumeScroll}
|
onResumeScroll={resumeScroll}
|
||||||
@@ -1289,8 +1318,7 @@ export default function Page() {
|
|||||||
onAutoScrollHandleScroll={autoScroll.handleScroll}
|
onAutoScrollHandleScroll={autoScroll.handleScroll}
|
||||||
onMarkScrollGesture={markScrollGesture}
|
onMarkScrollGesture={markScrollGesture}
|
||||||
hasScrollGesture={hasScrollGesture}
|
hasScrollGesture={hasScrollGesture}
|
||||||
isDesktop={isDesktop()}
|
onUserScroll={markUserScroll}
|
||||||
onScrollSpyScroll={scrollSpy.onScroll}
|
|
||||||
onTurnBackfillScroll={historyWindow.onScrollerScroll}
|
onTurnBackfillScroll={historyWindow.onScrollerScroll}
|
||||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||||
centered={centered()}
|
centered={centered()}
|
||||||
@@ -1309,8 +1337,6 @@ export default function Page() {
|
|||||||
}}
|
}}
|
||||||
renderedUserMessages={historyWindow.renderedUserMessages()}
|
renderedUserMessages={historyWindow.renderedUserMessages()}
|
||||||
anchor={anchor}
|
anchor={anchor}
|
||||||
onRegisterMessage={scrollSpy.register}
|
|
||||||
onUnregisterMessage={scrollSpy.unregister}
|
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</Match>
|
</Match>
|
||||||
@@ -1356,17 +1382,28 @@ export default function Page() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Show when={desktopReviewOpen()}>
|
<Show when={desktopReviewOpen()}>
|
||||||
<ResizeHandle
|
<div onPointerDown={() => size.start()}>
|
||||||
direction="horizontal"
|
<ResizeHandle
|
||||||
size={layout.session.width()}
|
direction="horizontal"
|
||||||
min={450}
|
size={layout.session.width()}
|
||||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.45}
|
min={450}
|
||||||
onResize={layout.session.resize}
|
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.45}
|
||||||
/>
|
onResize={(width) => {
|
||||||
|
size.touch()
|
||||||
|
layout.session.resize(width)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SessionSidePanel reviewPanel={reviewPanel} activeDiff={tree.activeDiff} focusReviewDiff={focusReviewDiff} />
|
<SessionSidePanel
|
||||||
|
reviewPanel={reviewPanel}
|
||||||
|
activeDiff={tree.activeDiff}
|
||||||
|
focusReviewDiff={focusReviewDiff}
|
||||||
|
reviewSnap={ui.reviewSnap}
|
||||||
|
size={size}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TerminalPanel />
|
<TerminalPanel />
|
||||||
|
|||||||
@@ -138,7 +138,6 @@ export function SessionTodoDock(props: {
|
|||||||
"--tool-motion-mask-height": `${props.countMaskHeight ?? 0}px`,
|
"--tool-motion-mask-height": `${props.countMaskHeight ?? 0}px`,
|
||||||
"--tool-motion-spring-ms": `${props.countWidthDuration ?? 560}ms`,
|
"--tool-motion-spring-ms": `${props.countWidthDuration ?? 560}ms`,
|
||||||
opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`,
|
opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`,
|
||||||
filter: `blur(${Math.max(0, Math.min(1, shut())) * 2}px)`,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AnimatedNumber value={done()} />
|
<AnimatedNumber value={done()} />
|
||||||
@@ -196,7 +195,6 @@ export function SessionTodoDock(props: {
|
|||||||
style={{
|
style={{
|
||||||
visibility: off() ? "hidden" : "visible",
|
visibility: off() ? "hidden" : "visible",
|
||||||
opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
|
opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
|
||||||
filter: `blur(${Math.max(0, Math.min(1, hide())) * 2}px)`,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TodoList todos={props.todos} open={!store.collapsed} />
|
<TodoList todos={props.todos} open={!store.collapsed} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { batch } from "solid-js"
|
import { batch, createEffect, on, onCleanup, onMount, type Accessor } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
|
||||||
export const focusTerminalById = (id: string) => {
|
export const focusTerminalById = (id: string) => {
|
||||||
const wrapper = document.getElementById(`terminal-wrapper-${id}`)
|
const wrapper = document.getElementById(`terminal-wrapper-${id}`)
|
||||||
@@ -69,3 +70,104 @@ export const getTabReorderIndex = (tabs: readonly string[], from: string, to: st
|
|||||||
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return undefined
|
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return undefined
|
||||||
return toIndex
|
return toIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const createSizing = () => {
|
||||||
|
const [state, setState] = createStore({ active: false })
|
||||||
|
let t: number | undefined
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (t !== undefined) {
|
||||||
|
clearTimeout(t)
|
||||||
|
t = undefined
|
||||||
|
}
|
||||||
|
setState("active", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
if (t !== undefined) {
|
||||||
|
clearTimeout(t)
|
||||||
|
t = undefined
|
||||||
|
}
|
||||||
|
setState("active", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
window.addEventListener("pointerup", stop)
|
||||||
|
window.addEventListener("pointercancel", stop)
|
||||||
|
window.addEventListener("blur", stop)
|
||||||
|
onCleanup(() => {
|
||||||
|
window.removeEventListener("pointerup", stop)
|
||||||
|
window.removeEventListener("pointercancel", stop)
|
||||||
|
window.removeEventListener("blur", stop)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (t !== undefined) clearTimeout(t)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
active: () => state.active,
|
||||||
|
start,
|
||||||
|
touch() {
|
||||||
|
start()
|
||||||
|
t = window.setTimeout(stop, 120)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Sizing = ReturnType<typeof createSizing>
|
||||||
|
|
||||||
|
export const createPresence = (open: Accessor<boolean>, wait = 200) => {
|
||||||
|
const [state, setState] = createStore({
|
||||||
|
show: open(),
|
||||||
|
open: open(),
|
||||||
|
})
|
||||||
|
let frame: number | undefined
|
||||||
|
let t: number | undefined
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
if (frame !== undefined) {
|
||||||
|
cancelAnimationFrame(frame)
|
||||||
|
frame = undefined
|
||||||
|
}
|
||||||
|
if (t !== undefined) {
|
||||||
|
clearTimeout(t)
|
||||||
|
t = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(open, (next) => {
|
||||||
|
clear()
|
||||||
|
|
||||||
|
if (next) {
|
||||||
|
if (state.show) {
|
||||||
|
setState("open", true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setState({ show: true, open: false })
|
||||||
|
frame = requestAnimationFrame(() => {
|
||||||
|
frame = undefined
|
||||||
|
setState("open", true)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.show) return
|
||||||
|
setState("open", false)
|
||||||
|
t = window.setTimeout(() => {
|
||||||
|
t = undefined
|
||||||
|
setState("show", false)
|
||||||
|
}, wait)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
onCleanup(clear)
|
||||||
|
|
||||||
|
return {
|
||||||
|
show: () => state.show,
|
||||||
|
open: () => state.open,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
|||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||||
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
||||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||||
import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
|
import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
@@ -192,8 +193,7 @@ export function MessageTimeline(props: {
|
|||||||
onAutoScrollHandleScroll: () => void
|
onAutoScrollHandleScroll: () => void
|
||||||
onMarkScrollGesture: (target?: EventTarget | null) => void
|
onMarkScrollGesture: (target?: EventTarget | null) => void
|
||||||
hasScrollGesture: () => boolean
|
hasScrollGesture: () => boolean
|
||||||
isDesktop: boolean
|
onUserScroll: () => void
|
||||||
onScrollSpyScroll: () => void
|
|
||||||
onTurnBackfillScroll: () => void
|
onTurnBackfillScroll: () => void
|
||||||
onAutoScrollInteraction: (event: MouseEvent) => void
|
onAutoScrollInteraction: (event: MouseEvent) => void
|
||||||
centered: boolean
|
centered: boolean
|
||||||
@@ -204,8 +204,6 @@ export function MessageTimeline(props: {
|
|||||||
onLoadEarlier: () => void
|
onLoadEarlier: () => void
|
||||||
renderedUserMessages: UserMessage[]
|
renderedUserMessages: UserMessage[]
|
||||||
anchor: (id: string) => string
|
anchor: (id: string) => string
|
||||||
onRegisterMessage: (el: HTMLDivElement, id: string) => void
|
|
||||||
onUnregisterMessage: (id: string) => void
|
|
||||||
}) {
|
}) {
|
||||||
let touchGesture: number | undefined
|
let touchGesture: number | undefined
|
||||||
|
|
||||||
@@ -235,6 +233,40 @@ export function MessageTimeline(props: {
|
|||||||
if (!id) return idle
|
if (!id) return idle
|
||||||
return sync.data.session_status[id] ?? idle
|
return sync.data.session_status[id] ?? idle
|
||||||
})
|
})
|
||||||
|
const working = createMemo(() => !!pending() || sessionStatus().type !== "idle")
|
||||||
|
|
||||||
|
const [slot, setSlot] = createStore({
|
||||||
|
open: false,
|
||||||
|
show: false,
|
||||||
|
fade: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
let f: number | undefined
|
||||||
|
const clear = () => {
|
||||||
|
if (f !== undefined) window.clearTimeout(f)
|
||||||
|
f = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
onCleanup(clear)
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
working,
|
||||||
|
(on, prev) => {
|
||||||
|
clear()
|
||||||
|
if (on) {
|
||||||
|
setSlot({ open: true, show: true, fade: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (prev) {
|
||||||
|
setSlot({ open: false, show: true, fade: true })
|
||||||
|
f = window.setTimeout(() => setSlot({ show: false, fade: false }), 260)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSlot({ open: false, show: false, fade: false })
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
const activeMessageID = createMemo(() => {
|
const activeMessageID = createMemo(() => {
|
||||||
const parentID = pending()?.parentID
|
const parentID = pending()?.parentID
|
||||||
if (parentID) {
|
if (parentID) {
|
||||||
@@ -539,9 +571,9 @@ export function MessageTimeline(props: {
|
|||||||
props.onScheduleScrollState(e.currentTarget)
|
props.onScheduleScrollState(e.currentTarget)
|
||||||
props.onTurnBackfillScroll()
|
props.onTurnBackfillScroll()
|
||||||
if (!props.hasScrollGesture()) return
|
if (!props.hasScrollGesture()) return
|
||||||
|
props.onUserScroll()
|
||||||
props.onAutoScrollHandleScroll()
|
props.onAutoScrollHandleScroll()
|
||||||
props.onMarkScrollGesture(e.currentTarget)
|
props.onMarkScrollGesture(e.currentTarget)
|
||||||
if (props.isDesktop) props.onScrollSpyScroll()
|
|
||||||
}}
|
}}
|
||||||
onClick={props.onAutoScrollInteraction}
|
onClick={props.onAutoScrollInteraction}
|
||||||
class="relative min-w-0 w-full h-full"
|
class="relative min-w-0 w-full h-full"
|
||||||
@@ -573,43 +605,64 @@ export function MessageTimeline(props: {
|
|||||||
aria-label={language.t("common.goBack")}
|
aria-label={language.t("common.goBack")}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={titleValue() || title.editing}>
|
<div class="flex items-center min-w-0 grow-1">
|
||||||
<Show
|
<div
|
||||||
when={title.editing}
|
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
||||||
fallback={
|
style={{
|
||||||
<h1
|
width: slot.open ? "16px" : "0px",
|
||||||
class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
|
"margin-right": slot.open ? "8px" : "0px",
|
||||||
onDblClick={openTitleEditor}
|
}}
|
||||||
>
|
aria-hidden="true"
|
||||||
{titleValue()}
|
|
||||||
</h1>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<InlineInput
|
<Show when={slot.show}>
|
||||||
ref={(el) => {
|
<div
|
||||||
titleRef = el
|
class="transition-opacity duration-200 ease-out"
|
||||||
}}
|
classList={{
|
||||||
value={title.draft}
|
"opacity-0": slot.fade,
|
||||||
disabled={title.saving}
|
}}
|
||||||
class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]"
|
>
|
||||||
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
<Spinner class="size-4" style={{ color: "var(--icon-interactive-base)" }} />
|
||||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
</div>
|
||||||
onKeyDown={(event) => {
|
</Show>
|
||||||
event.stopPropagation()
|
</div>
|
||||||
if (event.key === "Enter") {
|
<Show when={titleValue() || title.editing}>
|
||||||
event.preventDefault()
|
<Show
|
||||||
void saveTitleEditor()
|
when={title.editing}
|
||||||
return
|
fallback={
|
||||||
}
|
<h1
|
||||||
if (event.key === "Escape") {
|
class="text-14-medium text-text-strong truncate grow-1 min-w-0"
|
||||||
event.preventDefault()
|
onDblClick={openTitleEditor}
|
||||||
closeTitleEditor()
|
>
|
||||||
}
|
{titleValue()}
|
||||||
}}
|
</h1>
|
||||||
onBlur={closeTitleEditor}
|
}
|
||||||
/>
|
>
|
||||||
|
<InlineInput
|
||||||
|
ref={(el) => {
|
||||||
|
titleRef = el
|
||||||
|
}}
|
||||||
|
value={title.draft}
|
||||||
|
disabled={title.saving}
|
||||||
|
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
|
||||||
|
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
||||||
|
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault()
|
||||||
|
void saveTitleEditor()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault()
|
||||||
|
closeTitleEditor()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={closeTitleEditor}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Show when={sessionID()}>
|
<Show when={sessionID()}>
|
||||||
{(id) => (
|
{(id) => (
|
||||||
@@ -707,10 +760,6 @@ export function MessageTimeline(props: {
|
|||||||
<div
|
<div
|
||||||
id={props.anchor(messageID)}
|
id={props.anchor(messageID)}
|
||||||
data-message-id={messageID}
|
data-message-id={messageID}
|
||||||
ref={(el) => {
|
|
||||||
props.onRegisterMessage(el, messageID)
|
|
||||||
onCleanup(() => props.onUnregisterMessage(messageID))
|
|
||||||
}}
|
|
||||||
classList={{
|
classList={{
|
||||||
"min-w-0 w-full max-w-full": true,
|
"min-w-0 w-full max-w-full": true,
|
||||||
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import { createScrollSpy, pickOffsetId, pickVisibleId } from "./scroll-spy"
|
|
||||||
|
|
||||||
const rect = (top: number, height = 80): DOMRect =>
|
|
||||||
({
|
|
||||||
x: 0,
|
|
||||||
y: top,
|
|
||||||
top,
|
|
||||||
left: 0,
|
|
||||||
right: 800,
|
|
||||||
bottom: top + height,
|
|
||||||
width: 800,
|
|
||||||
height,
|
|
||||||
toJSON: () => ({}),
|
|
||||||
}) as DOMRect
|
|
||||||
|
|
||||||
const setRect = (el: Element, top: number, height = 80) => {
|
|
||||||
Object.defineProperty(el, "getBoundingClientRect", {
|
|
||||||
configurable: true,
|
|
||||||
value: () => rect(top, height),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("pickVisibleId", () => {
|
|
||||||
test("prefers higher intersection ratio", () => {
|
|
||||||
const id = pickVisibleId(
|
|
||||||
[
|
|
||||||
{ id: "a", ratio: 0.2, top: 100 },
|
|
||||||
{ id: "b", ratio: 0.8, top: 300 },
|
|
||||||
],
|
|
||||||
120,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(id).toBe("b")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("breaks ratio ties by nearest line", () => {
|
|
||||||
const id = pickVisibleId(
|
|
||||||
[
|
|
||||||
{ id: "a", ratio: 0.5, top: 90 },
|
|
||||||
{ id: "b", ratio: 0.5, top: 140 },
|
|
||||||
],
|
|
||||||
130,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(id).toBe("b")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("pickOffsetId", () => {
|
|
||||||
test("uses binary search cutoff", () => {
|
|
||||||
const id = pickOffsetId(
|
|
||||||
[
|
|
||||||
{ id: "a", top: 0 },
|
|
||||||
{ id: "b", top: 200 },
|
|
||||||
{ id: "c", top: 400 },
|
|
||||||
],
|
|
||||||
350,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(id).toBe("b")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("createScrollSpy fallback", () => {
|
|
||||||
test("tracks active id from offsets and dirty refresh", () => {
|
|
||||||
const active: string[] = []
|
|
||||||
const root = document.createElement("div") as HTMLDivElement
|
|
||||||
const one = document.createElement("div")
|
|
||||||
const two = document.createElement("div")
|
|
||||||
const three = document.createElement("div")
|
|
||||||
|
|
||||||
root.append(one, two, three)
|
|
||||||
document.body.append(root)
|
|
||||||
|
|
||||||
Object.defineProperty(root, "scrollTop", { configurable: true, writable: true, value: 250 })
|
|
||||||
setRect(root, 0, 800)
|
|
||||||
setRect(one, -250)
|
|
||||||
setRect(two, -50)
|
|
||||||
setRect(three, 150)
|
|
||||||
|
|
||||||
const queue: FrameRequestCallback[] = []
|
|
||||||
const flush = () => {
|
|
||||||
const run = [...queue]
|
|
||||||
queue.length = 0
|
|
||||||
for (const cb of run) cb(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const spy = createScrollSpy({
|
|
||||||
onActive: (id) => active.push(id),
|
|
||||||
raf: (cb) => (queue.push(cb), queue.length),
|
|
||||||
caf: () => {},
|
|
||||||
IntersectionObserver: undefined,
|
|
||||||
ResizeObserver: undefined,
|
|
||||||
MutationObserver: undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
spy.setContainer(root)
|
|
||||||
spy.register(one, "a")
|
|
||||||
spy.register(two, "b")
|
|
||||||
spy.register(three, "c")
|
|
||||||
spy.onScroll()
|
|
||||||
flush()
|
|
||||||
|
|
||||||
expect(spy.getActiveId()).toBe("b")
|
|
||||||
expect(active.at(-1)).toBe("b")
|
|
||||||
|
|
||||||
root.scrollTop = 450
|
|
||||||
setRect(one, -450)
|
|
||||||
setRect(two, -250)
|
|
||||||
setRect(three, -50)
|
|
||||||
spy.onScroll()
|
|
||||||
flush()
|
|
||||||
expect(spy.getActiveId()).toBe("c")
|
|
||||||
|
|
||||||
root.scrollTop = 250
|
|
||||||
setRect(one, -250)
|
|
||||||
setRect(two, 250)
|
|
||||||
setRect(three, 150)
|
|
||||||
spy.markDirty()
|
|
||||||
spy.onScroll()
|
|
||||||
flush()
|
|
||||||
expect(spy.getActiveId()).toBe("a")
|
|
||||||
|
|
||||||
spy.destroy()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
type Visible = {
|
|
||||||
id: string
|
|
||||||
ratio: number
|
|
||||||
top: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type Offset = {
|
|
||||||
id: string
|
|
||||||
top: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type Input = {
|
|
||||||
onActive: (id: string) => void
|
|
||||||
raf?: (cb: FrameRequestCallback) => number
|
|
||||||
caf?: (id: number) => void
|
|
||||||
IntersectionObserver?: typeof globalThis.IntersectionObserver
|
|
||||||
ResizeObserver?: typeof globalThis.ResizeObserver
|
|
||||||
MutationObserver?: typeof globalThis.MutationObserver
|
|
||||||
}
|
|
||||||
|
|
||||||
export const pickVisibleId = (list: Visible[], line: number) => {
|
|
||||||
if (list.length === 0) return
|
|
||||||
|
|
||||||
const sorted = [...list].sort((a, b) => {
|
|
||||||
if (b.ratio !== a.ratio) return b.ratio - a.ratio
|
|
||||||
|
|
||||||
const da = Math.abs(a.top - line)
|
|
||||||
const db = Math.abs(b.top - line)
|
|
||||||
if (da !== db) return da - db
|
|
||||||
|
|
||||||
return a.top - b.top
|
|
||||||
})
|
|
||||||
|
|
||||||
return sorted[0]?.id
|
|
||||||
}
|
|
||||||
|
|
||||||
export const pickOffsetId = (list: Offset[], cutoff: number) => {
|
|
||||||
if (list.length === 0) return
|
|
||||||
|
|
||||||
let lo = 0
|
|
||||||
let hi = list.length - 1
|
|
||||||
let out = 0
|
|
||||||
|
|
||||||
while (lo <= hi) {
|
|
||||||
const mid = (lo + hi) >> 1
|
|
||||||
const top = list[mid]?.top
|
|
||||||
if (top === undefined) break
|
|
||||||
|
|
||||||
if (top <= cutoff) {
|
|
||||||
out = mid
|
|
||||||
lo = mid + 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
hi = mid - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return list[out]?.id
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createScrollSpy = (input: Input) => {
|
|
||||||
const raf = input.raf ?? requestAnimationFrame
|
|
||||||
const caf = input.caf ?? cancelAnimationFrame
|
|
||||||
const CtorIO = input.IntersectionObserver ?? globalThis.IntersectionObserver
|
|
||||||
const CtorRO = input.ResizeObserver ?? globalThis.ResizeObserver
|
|
||||||
const CtorMO = input.MutationObserver ?? globalThis.MutationObserver
|
|
||||||
|
|
||||||
let root: HTMLDivElement | undefined
|
|
||||||
let io: IntersectionObserver | undefined
|
|
||||||
let ro: ResizeObserver | undefined
|
|
||||||
let mo: MutationObserver | undefined
|
|
||||||
let frame: number | undefined
|
|
||||||
let active: string | undefined
|
|
||||||
let dirty = true
|
|
||||||
|
|
||||||
const node = new Map<string, HTMLElement>()
|
|
||||||
const id = new WeakMap<HTMLElement, string>()
|
|
||||||
const visible = new Map<string, { ratio: number; top: number }>()
|
|
||||||
let offset: Offset[] = []
|
|
||||||
|
|
||||||
const schedule = () => {
|
|
||||||
if (frame !== undefined) return
|
|
||||||
frame = raf(() => {
|
|
||||||
frame = undefined
|
|
||||||
update()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshOffset = () => {
|
|
||||||
const el = root
|
|
||||||
if (!el) {
|
|
||||||
offset = []
|
|
||||||
dirty = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const base = el.getBoundingClientRect().top
|
|
||||||
offset = [...node].map(([next, item]) => ({
|
|
||||||
id: next,
|
|
||||||
top: item.getBoundingClientRect().top - base + el.scrollTop,
|
|
||||||
}))
|
|
||||||
offset.sort((a, b) => a.top - b.top)
|
|
||||||
dirty = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const update = () => {
|
|
||||||
const el = root
|
|
||||||
if (!el) return
|
|
||||||
|
|
||||||
const line = el.getBoundingClientRect().top + 100
|
|
||||||
const next =
|
|
||||||
pickVisibleId(
|
|
||||||
[...visible].map(([k, v]) => ({
|
|
||||||
id: k,
|
|
||||||
ratio: v.ratio,
|
|
||||||
top: v.top,
|
|
||||||
})),
|
|
||||||
line,
|
|
||||||
) ??
|
|
||||||
(() => {
|
|
||||||
if (dirty) refreshOffset()
|
|
||||||
return pickOffsetId(offset, el.scrollTop + 100)
|
|
||||||
})()
|
|
||||||
|
|
||||||
if (!next || next === active) return
|
|
||||||
active = next
|
|
||||||
input.onActive(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
const observe = () => {
|
|
||||||
const el = root
|
|
||||||
if (!el) return
|
|
||||||
|
|
||||||
io?.disconnect()
|
|
||||||
io = undefined
|
|
||||||
if (CtorIO) {
|
|
||||||
try {
|
|
||||||
io = new CtorIO(
|
|
||||||
(entries) => {
|
|
||||||
for (const entry of entries) {
|
|
||||||
const item = entry.target
|
|
||||||
if (!(item instanceof HTMLElement)) continue
|
|
||||||
const key = id.get(item)
|
|
||||||
if (!key) continue
|
|
||||||
|
|
||||||
if (!entry.isIntersecting || entry.intersectionRatio <= 0) {
|
|
||||||
visible.delete(key)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
visible.set(key, {
|
|
||||||
ratio: entry.intersectionRatio,
|
|
||||||
top: entry.boundingClientRect.top,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
schedule()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
root: el,
|
|
||||||
threshold: [0, 0.25, 0.5, 0.75, 1],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
io = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (io) {
|
|
||||||
for (const item of node.values()) io.observe(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
ro?.disconnect()
|
|
||||||
ro = undefined
|
|
||||||
if (CtorRO) {
|
|
||||||
ro = new CtorRO(() => {
|
|
||||||
dirty = true
|
|
||||||
schedule()
|
|
||||||
})
|
|
||||||
ro.observe(el)
|
|
||||||
for (const item of node.values()) ro.observe(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
mo?.disconnect()
|
|
||||||
mo = undefined
|
|
||||||
if (CtorMO) {
|
|
||||||
mo = new CtorMO(() => {
|
|
||||||
dirty = true
|
|
||||||
schedule()
|
|
||||||
})
|
|
||||||
mo.observe(el, { subtree: true, childList: true, characterData: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
dirty = true
|
|
||||||
schedule()
|
|
||||||
}
|
|
||||||
|
|
||||||
const setContainer = (el?: HTMLDivElement) => {
|
|
||||||
if (root === el) return
|
|
||||||
|
|
||||||
root = el
|
|
||||||
visible.clear()
|
|
||||||
active = undefined
|
|
||||||
observe()
|
|
||||||
}
|
|
||||||
|
|
||||||
const register = (el: HTMLElement, key: string) => {
|
|
||||||
const prev = node.get(key)
|
|
||||||
if (prev && prev !== el) {
|
|
||||||
io?.unobserve(prev)
|
|
||||||
ro?.unobserve(prev)
|
|
||||||
}
|
|
||||||
|
|
||||||
node.set(key, el)
|
|
||||||
id.set(el, key)
|
|
||||||
if (io) io.observe(el)
|
|
||||||
if (ro) ro.observe(el)
|
|
||||||
dirty = true
|
|
||||||
schedule()
|
|
||||||
}
|
|
||||||
|
|
||||||
const unregister = (key: string) => {
|
|
||||||
const item = node.get(key)
|
|
||||||
if (!item) return
|
|
||||||
|
|
||||||
io?.unobserve(item)
|
|
||||||
ro?.unobserve(item)
|
|
||||||
node.delete(key)
|
|
||||||
visible.delete(key)
|
|
||||||
dirty = true
|
|
||||||
schedule()
|
|
||||||
}
|
|
||||||
|
|
||||||
const markDirty = () => {
|
|
||||||
dirty = true
|
|
||||||
schedule()
|
|
||||||
}
|
|
||||||
|
|
||||||
const clear = () => {
|
|
||||||
for (const item of node.values()) {
|
|
||||||
io?.unobserve(item)
|
|
||||||
ro?.unobserve(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
node.clear()
|
|
||||||
visible.clear()
|
|
||||||
offset = []
|
|
||||||
active = undefined
|
|
||||||
dirty = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const destroy = () => {
|
|
||||||
if (frame !== undefined) caf(frame)
|
|
||||||
frame = undefined
|
|
||||||
clear()
|
|
||||||
io?.disconnect()
|
|
||||||
ro?.disconnect()
|
|
||||||
mo?.disconnect()
|
|
||||||
io = undefined
|
|
||||||
ro = undefined
|
|
||||||
mo = undefined
|
|
||||||
root = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
setContainer,
|
|
||||||
register,
|
|
||||||
unregister,
|
|
||||||
onScroll: schedule,
|
|
||||||
markDirty,
|
|
||||||
clear,
|
|
||||||
destroy,
|
|
||||||
getActiveId: () => active,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
|
import { resetSessionModel, syncSessionModel } from "./session-model-helpers"
|
||||||
|
|
||||||
|
const message = (input?: Partial<Pick<UserMessage, "agent" | "model" | "variant">>) =>
|
||||||
|
({
|
||||||
|
id: "msg",
|
||||||
|
sessionID: "session",
|
||||||
|
role: "user",
|
||||||
|
time: { created: 1 },
|
||||||
|
agent: input?.agent ?? "build",
|
||||||
|
model: input?.model ?? { providerID: "anthropic", modelID: "claude-sonnet-4" },
|
||||||
|
variant: input?.variant,
|
||||||
|
}) as UserMessage
|
||||||
|
|
||||||
|
describe("syncSessionModel", () => {
|
||||||
|
test("restores the last message model and variant", () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
|
||||||
|
syncSessionModel(
|
||||||
|
{
|
||||||
|
agent: {
|
||||||
|
current() {
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
calls.push(["agent", value])
|
||||||
|
},
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
set(value) {
|
||||||
|
calls.push(["model", value])
|
||||||
|
},
|
||||||
|
current() {
|
||||||
|
return { id: "claude-sonnet-4", provider: { id: "anthropic" } }
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
set(value) {
|
||||||
|
calls.push(["variant", value])
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
message({ variant: "high" }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(calls).toEqual([
|
||||||
|
["agent", "build"],
|
||||||
|
["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }],
|
||||||
|
["variant", "high"],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("skips variant when the model falls back", () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
|
||||||
|
syncSessionModel(
|
||||||
|
{
|
||||||
|
agent: {
|
||||||
|
current() {
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
calls.push(["agent", value])
|
||||||
|
},
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
set(value) {
|
||||||
|
calls.push(["model", value])
|
||||||
|
},
|
||||||
|
current() {
|
||||||
|
return { id: "gpt-5", provider: { id: "openai" } }
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
set(value) {
|
||||||
|
calls.push(["variant", value])
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
message({ variant: "high" }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(calls).toEqual([
|
||||||
|
["agent", "build"],
|
||||||
|
["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("resetSessionModel", () => {
|
||||||
|
test("restores the current agent defaults", () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
|
||||||
|
resetSessionModel({
|
||||||
|
agent: {
|
||||||
|
current() {
|
||||||
|
return {
|
||||||
|
model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
|
||||||
|
variant: "high",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
set() {},
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
set(value) {
|
||||||
|
calls.push(["model", value])
|
||||||
|
},
|
||||||
|
current() {
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
set(value) {
|
||||||
|
calls.push(["variant", value])
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(calls).toEqual([
|
||||||
|
["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }],
|
||||||
|
["variant", "high"],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("clears the variant when the agent has none", () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
|
||||||
|
resetSessionModel({
|
||||||
|
agent: {
|
||||||
|
current() {
|
||||||
|
return {
|
||||||
|
model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
set() {},
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
set(value) {
|
||||||
|
calls.push(["model", value])
|
||||||
|
},
|
||||||
|
current() {
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
set(value) {
|
||||||
|
calls.push(["variant", value])
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(calls).toEqual([
|
||||||
|
["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }],
|
||||||
|
["variant", undefined],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
|
import { batch } from "solid-js"
|
||||||
|
|
||||||
|
type Local = {
|
||||||
|
agent: {
|
||||||
|
current():
|
||||||
|
| {
|
||||||
|
model?: UserMessage["model"]
|
||||||
|
variant?: string
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
set(name: string | undefined): void
|
||||||
|
}
|
||||||
|
model: {
|
||||||
|
set(model: UserMessage["model"] | undefined): void
|
||||||
|
current():
|
||||||
|
| {
|
||||||
|
id: string
|
||||||
|
provider: { id: string }
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
variant: {
|
||||||
|
set(value: string | undefined): void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resetSessionModel = (local: Local) => {
|
||||||
|
const agent = local.agent.current()
|
||||||
|
if (!agent) return
|
||||||
|
batch(() => {
|
||||||
|
local.model.set(agent.model)
|
||||||
|
local.model.variant.set(agent.variant)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const syncSessionModel = (local: Local, msg: UserMessage) => {
|
||||||
|
batch(() => {
|
||||||
|
local.agent.set(msg.agent)
|
||||||
|
local.model.set(msg.model)
|
||||||
|
})
|
||||||
|
|
||||||
|
const model = local.model.current()
|
||||||
|
if (!model) return
|
||||||
|
if (model.provider.id !== msg.model.providerID) return
|
||||||
|
if (model.id !== msg.model.modelID) return
|
||||||
|
local.model.variant.set(msg.variant)
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ import { useLayout } from "@/context/layout"
|
|||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
||||||
import { FileTabContent } from "@/pages/session/file-tabs"
|
import { FileTabContent } from "@/pages/session/file-tabs"
|
||||||
import { createOpenSessionFileTab, getTabReorderIndex } from "@/pages/session/helpers"
|
import { createOpenSessionFileTab, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
|
||||||
import { StickyAddButton } from "@/pages/session/review-tab"
|
import { StickyAddButton } from "@/pages/session/review-tab"
|
||||||
import { setSessionHandoff } from "@/pages/session/handoff"
|
import { setSessionHandoff } from "@/pages/session/handoff"
|
||||||
|
|
||||||
@@ -31,6 +31,8 @@ export function SessionSidePanel(props: {
|
|||||||
reviewPanel: () => JSX.Element
|
reviewPanel: () => JSX.Element
|
||||||
activeDiff?: string
|
activeDiff?: string
|
||||||
focusReviewDiff: (path: string) => void
|
focusReviewDiff: (path: string) => void
|
||||||
|
reviewSnap: boolean
|
||||||
|
size: Sizing
|
||||||
}) {
|
}) {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
@@ -46,8 +48,15 @@ export function SessionSidePanel(props: {
|
|||||||
const view = createMemo(() => layout.view(sessionKey))
|
const view = createMemo(() => layout.view(sessionKey))
|
||||||
|
|
||||||
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
||||||
const open = createMemo(() => isDesktop() && (view().reviewPanel.opened() || layout.fileTree.opened()))
|
const fileOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
|
||||||
|
const open = createMemo(() => reviewOpen() || fileOpen())
|
||||||
const reviewTab = createMemo(() => isDesktop())
|
const reviewTab = createMemo(() => isDesktop())
|
||||||
|
const panelWidth = createMemo(() => {
|
||||||
|
if (!open()) return "0px"
|
||||||
|
if (reviewOpen()) return `calc(100% - ${layout.session.width()}px)`
|
||||||
|
return `${layout.fileTree.width()}px`
|
||||||
|
})
|
||||||
|
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
|
||||||
|
|
||||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||||
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
||||||
@@ -95,8 +104,8 @@ export function SessionSidePanel(props: {
|
|||||||
|
|
||||||
const empty = (msg: string) => (
|
const empty = (msg: string) => (
|
||||||
<div class="h-full flex flex-col">
|
<div class="h-full flex flex-col">
|
||||||
<div class="h-12 shrink-0" aria-hidden />
|
<div class="h-6 shrink-0" aria-hidden />
|
||||||
<div class="flex-1 pb-30 flex items-center justify-center text-center">
|
<div class="flex-1 pb-64 flex items-center justify-center text-center">
|
||||||
<div class="text-12-regular text-text-weak">{msg}</div>
|
<div class="text-12-regular text-text-weak">{msg}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,146 +219,169 @@ export function SessionSidePanel(props: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={open()}>
|
<Show when={isDesktop()}>
|
||||||
<aside
|
<aside
|
||||||
id="review-panel"
|
id="review-panel"
|
||||||
aria-label={language.t("session.panel.reviewAndFiles")}
|
aria-label={language.t("session.panel.reviewAndFiles")}
|
||||||
class="relative min-w-0 h-full border-l border-border-weaker-base flex"
|
aria-hidden={!open()}
|
||||||
|
inert={!open()}
|
||||||
|
class="relative min-w-0 h-full flex shrink-0 overflow-hidden bg-background-base"
|
||||||
classList={{
|
classList={{
|
||||||
"flex-1": reviewOpen(),
|
"pointer-events-none": !open(),
|
||||||
"shrink-0": !reviewOpen(),
|
"transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||||
|
!props.size.active() && !props.reviewSnap,
|
||||||
}}
|
}}
|
||||||
style={{ width: reviewOpen() ? undefined : `${layout.fileTree.width()}px` }}
|
style={{ width: panelWidth() }}
|
||||||
>
|
>
|
||||||
<Show when={reviewOpen()}>
|
<div class="size-full flex border-l border-border-weaker-base">
|
||||||
<div class="flex-1 min-w-0 h-full">
|
<div
|
||||||
<DragDropProvider
|
aria-hidden={!reviewOpen()}
|
||||||
onDragStart={handleDragStart}
|
inert={!reviewOpen()}
|
||||||
onDragEnd={handleDragEnd}
|
class="relative min-w-0 h-full flex-1 overflow-hidden bg-background-base"
|
||||||
onDragOver={handleDragOver}
|
classList={{
|
||||||
collisionDetector={closestCenter}
|
"pointer-events-none": !reviewOpen(),
|
||||||
>
|
}}
|
||||||
<DragDropSensors />
|
>
|
||||||
<ConstrainDragYAxis />
|
<div class="size-full min-w-0 h-full bg-background-base">
|
||||||
<Tabs value={activeTab()} onChange={openTab}>
|
<DragDropProvider
|
||||||
<div class="sticky top-0 shrink-0 flex">
|
onDragStart={handleDragStart}
|
||||||
<Tabs.List
|
onDragEnd={handleDragEnd}
|
||||||
ref={(el: HTMLDivElement) => {
|
onDragOver={handleDragOver}
|
||||||
const stop = createFileTabListSync({ el, contextOpen })
|
collisionDetector={closestCenter}
|
||||||
onCleanup(stop)
|
>
|
||||||
}}
|
<DragDropSensors />
|
||||||
>
|
<ConstrainDragYAxis />
|
||||||
<Show when={reviewTab()}>
|
<Tabs value={activeTab()} onChange={openTab}>
|
||||||
<Tabs.Trigger value="review">
|
<div class="sticky top-0 shrink-0 flex">
|
||||||
<div class="flex items-center gap-1.5">
|
<Tabs.List
|
||||||
<div>{language.t("session.tab.review")}</div>
|
ref={(el: HTMLDivElement) => {
|
||||||
<Show when={hasReview()}>
|
const stop = createFileTabListSync({ el, contextOpen })
|
||||||
<div>{reviewCount()}</div>
|
onCleanup(stop)
|
||||||
</Show>
|
}}
|
||||||
</div>
|
>
|
||||||
</Tabs.Trigger>
|
<Show when={reviewTab()}>
|
||||||
</Show>
|
<Tabs.Trigger value="review">
|
||||||
<Show when={contextOpen()}>
|
<div class="flex items-center gap-1.5">
|
||||||
<Tabs.Trigger
|
<div>{language.t("session.tab.review")}</div>
|
||||||
value="context"
|
<Show when={hasReview()}>
|
||||||
closeButton={
|
<div>{reviewCount()}</div>
|
||||||
<TooltipKeybind
|
</Show>
|
||||||
title={language.t("common.closeTab")}
|
</div>
|
||||||
keybind={command.keybind("tab.close")}
|
</Tabs.Trigger>
|
||||||
placement="bottom"
|
</Show>
|
||||||
gutter={10}
|
<Show when={contextOpen()}>
|
||||||
>
|
<Tabs.Trigger
|
||||||
<IconButton
|
value="context"
|
||||||
icon="close-small"
|
closeButton={
|
||||||
variant="ghost"
|
<TooltipKeybind
|
||||||
class="h-5 w-5"
|
title={language.t("common.closeTab")}
|
||||||
onClick={() => tabs().close("context")}
|
keybind={command.keybind("tab.close")}
|
||||||
aria-label={language.t("common.closeTab")}
|
placement="bottom"
|
||||||
/>
|
gutter={10}
|
||||||
</TooltipKeybind>
|
>
|
||||||
}
|
<IconButton
|
||||||
hideCloseButton
|
icon="close-small"
|
||||||
onMiddleClick={() => tabs().close("context")}
|
variant="ghost"
|
||||||
>
|
class="h-5 w-5"
|
||||||
<div class="flex items-center gap-2">
|
onClick={() => tabs().close("context")}
|
||||||
<SessionContextUsage variant="indicator" />
|
aria-label={language.t("common.closeTab")}
|
||||||
<div>{language.t("session.tab.context")}</div>
|
/>
|
||||||
</div>
|
</TooltipKeybind>
|
||||||
</Tabs.Trigger>
|
}
|
||||||
</Show>
|
hideCloseButton
|
||||||
<SortableProvider ids={openedTabs()}>
|
onMiddleClick={() => tabs().close("context")}
|
||||||
<For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
|
>
|
||||||
</SortableProvider>
|
<div class="flex items-center gap-2">
|
||||||
<StickyAddButton>
|
<SessionContextUsage variant="indicator" />
|
||||||
<TooltipKeybind
|
<div>{language.t("session.tab.context")}</div>
|
||||||
title={language.t("command.file.open")}
|
</div>
|
||||||
keybind={command.keybind("file.open")}
|
</Tabs.Trigger>
|
||||||
class="flex items-center"
|
</Show>
|
||||||
>
|
<SortableProvider ids={openedTabs()}>
|
||||||
<IconButton
|
<For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
|
||||||
icon="plus-small"
|
</SortableProvider>
|
||||||
variant="ghost"
|
<StickyAddButton>
|
||||||
iconSize="large"
|
<TooltipKeybind
|
||||||
class="!rounded-md"
|
title={language.t("command.file.open")}
|
||||||
onClick={() => dialog.show(() => <DialogSelectFile mode="files" onOpenFile={showAllFiles} />)}
|
keybind={command.keybind("file.open")}
|
||||||
aria-label={language.t("command.file.open")}
|
class="flex items-center"
|
||||||
/>
|
>
|
||||||
</TooltipKeybind>
|
<IconButton
|
||||||
</StickyAddButton>
|
icon="plus-small"
|
||||||
</Tabs.List>
|
variant="ghost"
|
||||||
</div>
|
iconSize="large"
|
||||||
|
class="!rounded-md"
|
||||||
|
onClick={() =>
|
||||||
|
dialog.show(() => <DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
|
||||||
|
}
|
||||||
|
aria-label={language.t("command.file.open")}
|
||||||
|
/>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</StickyAddButton>
|
||||||
|
</Tabs.List>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Show when={reviewTab()}>
|
<Show when={reviewTab()}>
|
||||||
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||||
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
|
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
|
|
||||||
<Show when={activeTab() === "empty"}>
|
|
||||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
|
||||||
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
|
|
||||||
<Mark class="w-14 opacity-10" />
|
|
||||||
<div class="text-14-regular text-text-weak max-w-56">
|
|
||||||
{language.t("session.files.selectToOpen")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
</Show>
|
||||||
</Tabs.Content>
|
|
||||||
|
|
||||||
<Show when={contextOpen()}>
|
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||||
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
|
<Show when={activeTab() === "empty"}>
|
||||||
<Show when={activeTab() === "context"}>
|
|
||||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||||
<SessionContextTab />
|
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||||
|
<Mark class="w-14 opacity-10" />
|
||||||
|
<div class="text-14-regular text-text-weak max-w-56">
|
||||||
|
{language.t("session.files.selectToOpen")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={activeFileTab()} keyed>
|
<Show when={contextOpen()}>
|
||||||
{(tab) => <FileTabContent tab={tab} />}
|
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||||
</Show>
|
<Show when={activeTab() === "context"}>
|
||||||
</Tabs>
|
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||||
<DragOverlay>
|
<SessionContextTab />
|
||||||
<Show when={store.activeDraggable} keyed>
|
</div>
|
||||||
{(tab) => {
|
</Show>
|
||||||
const path = createMemo(() => file.pathFromTab(tab))
|
</Tabs.Content>
|
||||||
return (
|
</Show>
|
||||||
<div data-component="tabs-drag-preview">
|
|
||||||
<Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
|
<Show when={activeFileTab()} keyed>
|
||||||
</div>
|
{(tab) => <FileTabContent tab={tab} />}
|
||||||
)
|
</Show>
|
||||||
}}
|
</Tabs>
|
||||||
</Show>
|
<DragOverlay>
|
||||||
</DragOverlay>
|
<Show when={store.activeDraggable} keyed>
|
||||||
</DragDropProvider>
|
{(tab) => {
|
||||||
|
const path = createMemo(() => file.pathFromTab(tab))
|
||||||
|
return (
|
||||||
|
<div data-component="tabs-drag-preview">
|
||||||
|
<Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
</DragOverlay>
|
||||||
|
</DragDropProvider>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={layout.fileTree.opened()}>
|
<div
|
||||||
<div id="file-tree-panel" class="relative shrink-0 h-full" style={{ width: `${layout.fileTree.width()}px` }}>
|
id="file-tree-panel"
|
||||||
|
aria-hidden={!fileOpen()}
|
||||||
|
inert={!fileOpen()}
|
||||||
|
class="relative min-w-0 h-full shrink-0 overflow-hidden"
|
||||||
|
classList={{
|
||||||
|
"pointer-events-none": !fileOpen(),
|
||||||
|
"transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||||
|
!props.size.active(),
|
||||||
|
}}
|
||||||
|
style={{ width: treeWidth() }}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="h-full flex flex-col overflow-hidden group/filetree"
|
class="h-full flex flex-col overflow-hidden group/filetree"
|
||||||
classList={{ "border-l border-border-weaker-base": reviewOpen() }}
|
classList={{ "border-l border-border-weaker-base": reviewOpen() }}
|
||||||
@@ -393,7 +425,11 @@ export function SessionSidePanel(props: {
|
|||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>{empty(language.t(reviewEmptyKey()))}</Match>
|
<Match when={true}>
|
||||||
|
{empty(
|
||||||
|
language.t(sync.project && !sync.project.vcs ? "session.review.noChanges" : reviewEmptyKey()),
|
||||||
|
)}
|
||||||
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
|
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
|
||||||
@@ -412,18 +448,25 @@ export function SessionSidePanel(props: {
|
|||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
<ResizeHandle
|
<Show when={fileOpen()}>
|
||||||
direction="horizontal"
|
<div onPointerDown={() => props.size.start()}>
|
||||||
edge="start"
|
<ResizeHandle
|
||||||
size={layout.fileTree.width()}
|
direction="horizontal"
|
||||||
min={200}
|
edge="start"
|
||||||
max={480}
|
size={layout.fileTree.width()}
|
||||||
collapseThreshold={160}
|
min={200}
|
||||||
onResize={layout.fileTree.resize}
|
max={480}
|
||||||
onCollapse={layout.fileTree.close}
|
collapseThreshold={160}
|
||||||
/>
|
onResize={(width) => {
|
||||||
|
props.size.touch()
|
||||||
|
layout.fileTree.resize(width)
|
||||||
|
}}
|
||||||
|
onCollapse={layout.fileTree.close}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { For, Show, createEffect, createMemo, on } from "solid-js"
|
import { For, Show, createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createMediaQuery } from "@solid-primitives/media"
|
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||||
@@ -17,7 +16,7 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
||||||
import { terminalTabLabel } from "@/pages/session/terminal-label"
|
import { terminalTabLabel } from "@/pages/session/terminal-label"
|
||||||
import { focusTerminalById } from "@/pages/session/helpers"
|
import { createSizing, focusTerminalById } from "@/pages/session/helpers"
|
||||||
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
|
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
|
||||||
|
|
||||||
export function TerminalPanel() {
|
export function TerminalPanel() {
|
||||||
@@ -27,18 +26,37 @@ export function TerminalPanel() {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
|
|
||||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
const view = createMemo(() => layout.view(sessionKey))
|
const view = createMemo(() => layout.view(sessionKey))
|
||||||
|
|
||||||
const opened = createMemo(() => view().terminal.opened())
|
const opened = createMemo(() => view().terminal.opened())
|
||||||
const open = createMemo(() => isDesktop() && opened())
|
const size = createSizing()
|
||||||
const height = createMemo(() => layout.terminal.height())
|
const height = createMemo(() => layout.terminal.height())
|
||||||
const close = () => view().terminal.close()
|
const close = () => view().terminal.close()
|
||||||
|
let root: HTMLDivElement | undefined
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
autoCreated: false,
|
autoCreated: false,
|
||||||
activeDraggable: undefined as string | undefined,
|
activeDraggable: undefined as string | undefined,
|
||||||
|
view: typeof window === "undefined" ? 1000 : (window.visualViewport?.height ?? window.innerHeight),
|
||||||
|
})
|
||||||
|
|
||||||
|
const max = () => store.view * 0.6
|
||||||
|
const pane = () => Math.min(height(), max())
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
|
||||||
|
const sync = () => setStore("view", window.visualViewport?.height ?? window.innerHeight)
|
||||||
|
const port = window.visualViewport
|
||||||
|
|
||||||
|
sync()
|
||||||
|
window.addEventListener("resize", sync)
|
||||||
|
port?.addEventListener("resize", sync)
|
||||||
|
onCleanup(() => {
|
||||||
|
window.removeEventListener("resize", sync)
|
||||||
|
port?.removeEventListener("resize", sync)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -63,19 +81,48 @@ export function TerminalPanel() {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const focus = (id: string) => {
|
||||||
|
focusTerminalById(id)
|
||||||
|
|
||||||
|
const frame = requestAnimationFrame(() => {
|
||||||
|
if (!opened()) return
|
||||||
|
if (terminal.active() !== id) return
|
||||||
|
focusTerminalById(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
const timers = [120, 240].map((ms) =>
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (!opened()) return
|
||||||
|
if (terminal.active() !== id) return
|
||||||
|
focusTerminalById(id)
|
||||||
|
}, ms),
|
||||||
|
)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(frame)
|
||||||
|
for (const timer of timers) clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => terminal.active(),
|
() => [opened(), terminal.active()] as const,
|
||||||
(activeId) => {
|
([next, id]) => {
|
||||||
if (!activeId || !open()) return
|
if (!next || !id) return
|
||||||
if (document.activeElement instanceof HTMLElement) {
|
const stop = focus(id)
|
||||||
document.activeElement.blur()
|
onCleanup(stop)
|
||||||
}
|
|
||||||
setTimeout(() => focusTerminalById(activeId), 0)
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (opened()) return
|
||||||
|
const active = document.activeElement
|
||||||
|
if (!(active instanceof HTMLElement)) return
|
||||||
|
if (!root?.contains(active)) return
|
||||||
|
active.blur()
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const dir = params.dir
|
const dir = params.dir
|
||||||
if (!dir) return
|
if (!dir) return
|
||||||
@@ -127,29 +174,52 @@ export function TerminalPanel() {
|
|||||||
|
|
||||||
const activeId = terminal.active()
|
const activeId = terminal.active()
|
||||||
if (!activeId) return
|
if (!activeId) return
|
||||||
setTimeout(() => {
|
requestAnimationFrame(() => {
|
||||||
|
if (terminal.active() !== activeId) return
|
||||||
focusTerminalById(activeId)
|
focusTerminalById(activeId)
|
||||||
}, 0)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={open()}>
|
<div
|
||||||
|
ref={root}
|
||||||
|
id="terminal-panel"
|
||||||
|
role="region"
|
||||||
|
aria-label={language.t("terminal.title")}
|
||||||
|
aria-hidden={!opened()}
|
||||||
|
inert={!opened()}
|
||||||
|
class="relative w-full shrink-0 overflow-hidden bg-background-stronger"
|
||||||
|
classList={{
|
||||||
|
"border-t border-border-weak-base": opened(),
|
||||||
|
"transition-[height] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[height] motion-reduce:transition-none":
|
||||||
|
!size.active(),
|
||||||
|
}}
|
||||||
|
style={{ height: opened() ? `${pane()}px` : "0px" }}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
id="terminal-panel"
|
class="absolute inset-x-0 top-0 flex flex-col"
|
||||||
role="region"
|
classList={{
|
||||||
aria-label={language.t("terminal.title")}
|
"translate-y-0": opened(),
|
||||||
class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
|
"translate-y-full pointer-events-none": !opened(),
|
||||||
style={{ height: `${height()}px` }}
|
"transition-transform duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform motion-reduce:transition-none":
|
||||||
|
!size.active(),
|
||||||
|
}}
|
||||||
|
style={{ height: `${pane()}px` }}
|
||||||
>
|
>
|
||||||
<ResizeHandle
|
<div class="hidden md:block" onPointerDown={() => size.start()}>
|
||||||
direction="vertical"
|
<ResizeHandle
|
||||||
size={height()}
|
direction="vertical"
|
||||||
min={100}
|
size={pane()}
|
||||||
max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6}
|
min={100}
|
||||||
collapseThreshold={50}
|
max={max()}
|
||||||
onResize={layout.terminal.resize}
|
collapseThreshold={50}
|
||||||
onCollapse={close}
|
onResize={(next) => {
|
||||||
/>
|
size.touch()
|
||||||
|
layout.terminal.resize(next)
|
||||||
|
}}
|
||||||
|
onCollapse={close}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Show
|
<Show
|
||||||
when={terminal.ready()}
|
when={terminal.ready()}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -220,7 +290,13 @@ export function TerminalPanel() {
|
|||||||
<Show when={byId().get(id)}>
|
<Show when={byId().get(id)}>
|
||||||
{(pty) => (
|
{(pty) => (
|
||||||
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
|
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
|
||||||
<Terminal pty={pty()} onCleanup={terminal.update} onConnectError={() => terminal.clone(id)} />
|
<Terminal
|
||||||
|
pty={pty()}
|
||||||
|
autoFocus={opened()}
|
||||||
|
onConnect={() => terminal.trim(id)}
|
||||||
|
onCleanup={terminal.update}
|
||||||
|
onConnectError={() => terminal.clone(id)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -248,6 +324,6 @@ export function TerminalPanel() {
|
|||||||
</DragDropProvider>
|
</DragDropProvider>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
import { useLocation, useNavigate } from "@solidjs/router"
|
import { useLocation, useNavigate } from "@solidjs/router"
|
||||||
import { createEffect, createMemo, onMount } from "solid-js"
|
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||||
import { messageIdFromHash } from "./message-id-from-hash"
|
import { messageIdFromHash } from "./message-id-from-hash"
|
||||||
|
|
||||||
export { messageIdFromHash } from "./message-id-from-hash"
|
export { messageIdFromHash } from "./message-id-from-hash"
|
||||||
@@ -26,17 +26,38 @@ export const useSessionHashScroll = (input: {
|
|||||||
const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m])))
|
const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m])))
|
||||||
const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
|
const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
|
||||||
let pendingKey = ""
|
let pendingKey = ""
|
||||||
|
let clearing = false
|
||||||
|
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const frames = new Set<number>()
|
||||||
|
const queue = (fn: () => void) => {
|
||||||
|
const id = requestAnimationFrame(() => {
|
||||||
|
frames.delete(id)
|
||||||
|
fn()
|
||||||
|
})
|
||||||
|
frames.add(id)
|
||||||
|
}
|
||||||
|
const cancel = () => {
|
||||||
|
for (const id of frames) cancelAnimationFrame(id)
|
||||||
|
frames.clear()
|
||||||
|
}
|
||||||
|
|
||||||
const clearMessageHash = () => {
|
const clearMessageHash = () => {
|
||||||
|
cancel()
|
||||||
|
input.consumePendingMessage(input.sessionKey())
|
||||||
|
if (input.pendingMessage()) input.setPendingMessage(undefined)
|
||||||
if (!location.hash) return
|
if (!location.hash) return
|
||||||
|
clearing = true
|
||||||
navigate(location.pathname + location.search, { replace: true })
|
navigate(location.pathname + location.search, { replace: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateHash = (id: string) => {
|
const updateHash = (id: string) => {
|
||||||
navigate(location.pathname + location.search + `#${input.anchor(id)}`, {
|
const hash = `#${input.anchor(id)}`
|
||||||
|
if (location.hash === hash) return
|
||||||
|
clearing = false
|
||||||
|
navigate(location.pathname + location.search + hash, {
|
||||||
replace: true,
|
replace: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -54,51 +75,37 @@ export const useSessionHashScroll = (input: {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const seek = (id: string, behavior: ScrollBehavior, left = 4): boolean => {
|
||||||
|
const el = document.getElementById(input.anchor(id))
|
||||||
|
if (el) return scrollToElement(el, behavior)
|
||||||
|
if (left <= 0) return false
|
||||||
|
queue(() => {
|
||||||
|
seek(id, behavior, left - 1)
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
|
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
|
||||||
console.log({ message, behavior })
|
cancel()
|
||||||
if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
|
if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
|
||||||
|
|
||||||
const index = messageIndex().get(message.id) ?? -1
|
const index = messageIndex().get(message.id) ?? -1
|
||||||
if (index !== -1 && index < input.turnStart()) {
|
if (index !== -1 && index < input.turnStart()) {
|
||||||
input.setTurnStart(index)
|
input.setTurnStart(index)
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
queue(() => {
|
||||||
const el = document.getElementById(input.anchor(message.id))
|
seek(message.id, behavior)
|
||||||
if (!el) {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const next = document.getElementById(input.anchor(message.id))
|
|
||||||
if (!next) return
|
|
||||||
scrollToElement(next, behavior)
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
scrollToElement(el, behavior)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
updateHash(message.id)
|
updateHash(message.id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const el = document.getElementById(input.anchor(message.id))
|
if (seek(message.id, behavior)) {
|
||||||
if (!el) {
|
|
||||||
updateHash(message.id)
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const next = document.getElementById(input.anchor(message.id))
|
|
||||||
if (!next) return
|
|
||||||
if (!scrollToElement(next, behavior)) return
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (scrollToElement(el, behavior)) {
|
|
||||||
updateHash(message.id)
|
updateHash(message.id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const next = document.getElementById(input.anchor(message.id))
|
|
||||||
if (!next) return
|
|
||||||
if (!scrollToElement(next, behavior)) return
|
|
||||||
})
|
|
||||||
updateHash(message.id)
|
updateHash(message.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,9 +142,11 @@ export const useSessionHashScroll = (input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
location.hash
|
const hash = location.hash
|
||||||
|
if (!hash) clearing = false
|
||||||
if (!input.sessionID() || !input.messagesReady()) return
|
if (!input.sessionID() || !input.messagesReady()) return
|
||||||
requestAnimationFrame(() => applyHash("auto"))
|
cancel()
|
||||||
|
queue(() => applyHash("auto"))
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -159,16 +168,19 @@ export const useSessionHashScroll = (input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!targetId) targetId = messageIdFromHash(location.hash)
|
if (!targetId && !clearing) targetId = messageIdFromHash(location.hash)
|
||||||
if (!targetId) return
|
if (!targetId) return
|
||||||
if (input.currentMessageId() === targetId) return
|
|
||||||
|
|
||||||
|
const pending = input.pendingMessage() === targetId
|
||||||
const msg = messageById().get(targetId)
|
const msg = messageById().get(targetId)
|
||||||
if (!msg) return
|
if (!msg) return
|
||||||
|
|
||||||
if (input.pendingMessage() === targetId) input.setPendingMessage(undefined)
|
if (pending) input.setPendingMessage(undefined)
|
||||||
|
if (input.currentMessageId() === targetId && !pending) return
|
||||||
|
|
||||||
input.autoScroll.pause()
|
input.autoScroll.pause()
|
||||||
requestAnimationFrame(() => scrollToMessage(msg, "auto"))
|
cancel()
|
||||||
|
queue(() => scrollToMessage(msg, "auto"))
|
||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -177,6 +189,8 @@ export const useSessionHashScroll = (input: {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onCleanup(cancel)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
clearMessageHash,
|
clearMessageHash,
|
||||||
scrollToMessage,
|
scrollToMessage,
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { beforeEach, describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
|
const src = await Bun.file(new URL("../public/oc-theme-preload.js", import.meta.url)).text()
|
||||||
|
|
||||||
|
const run = () => Function(src)()
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.head.innerHTML = ""
|
||||||
|
document.documentElement.removeAttribute("data-theme")
|
||||||
|
document.documentElement.removeAttribute("data-color-scheme")
|
||||||
|
localStorage.clear()
|
||||||
|
Object.defineProperty(window, "matchMedia", {
|
||||||
|
value: () =>
|
||||||
|
({
|
||||||
|
matches: false,
|
||||||
|
}) as MediaQueryList,
|
||||||
|
configurable: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("theme preload", () => {
|
||||||
|
test("migrates legacy oc-1 to oc-2 before mount", () => {
|
||||||
|
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;")
|
||||||
|
|
||||||
|
run()
|
||||||
|
|
||||||
|
expect(document.documentElement.dataset.theme).toBe("oc-2")
|
||||||
|
expect(document.documentElement.dataset.colorScheme).toBe("light")
|
||||||
|
expect(localStorage.getItem("opencode-theme-id")).toBe("oc-2")
|
||||||
|
expect(localStorage.getItem("opencode-theme-css-light")).toBeNull()
|
||||||
|
expect(localStorage.getItem("opencode-theme-css-dark")).toBeNull()
|
||||||
|
expect(document.getElementById("oc-theme-preload")).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps cached css for non-default themes", () => {
|
||||||
|
localStorage.setItem("opencode-theme-id", "nightowl")
|
||||||
|
localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;")
|
||||||
|
|
||||||
|
run()
|
||||||
|
|
||||||
|
expect(document.documentElement.dataset.theme).toBe("nightowl")
|
||||||
|
expect(document.getElementById("oc-theme-preload")?.textContent).toContain("--background-base:#fff;")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -104,4 +104,12 @@ describe("persist localStorage resilience", () => {
|
|||||||
const result = persistTesting.normalize({ value: "ok" }, '{"value":"\\x"}')
|
const result = persistTesting.normalize({ value: "ok" }, '{"value":"\\x"}')
|
||||||
expect(result).toBeUndefined()
|
expect(result).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("workspace storage sanitizes Windows filename characters", () => {
|
||||||
|
const result = persistTesting.workspaceStorage("C:\\Users\\foo")
|
||||||
|
|
||||||
|
expect(result).toStartWith("opencode.workspace.")
|
||||||
|
expect(result.endsWith(".dat")).toBeTrue()
|
||||||
|
expect(/[:\\/]/.test(result)).toBeFalse()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ function normalize(defaults: unknown, raw: string, migrate?: (value: unknown) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
function workspaceStorage(dir: string) {
|
function workspaceStorage(dir: string) {
|
||||||
const head = dir.slice(0, 12) || "workspace"
|
const head = (dir.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||||
const sum = checksum(dir) ?? "0"
|
const sum = checksum(dir) ?? "0"
|
||||||
return `opencode.workspace.${head}.${sum}.dat`
|
return `opencode.workspace.${head}.${sum}.dat`
|
||||||
}
|
}
|
||||||
@@ -300,6 +300,7 @@ export const PersistTesting = {
|
|||||||
localStorageDirect,
|
localStorageDirect,
|
||||||
localStorageWithPrefix,
|
localStorageWithPrefix,
|
||||||
normalize,
|
normalize,
|
||||||
|
workspaceStorage,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Persist = {
|
export const Persist = {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.2.20",
|
"version": "1.2.24",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ export const config = {
|
|||||||
github: {
|
github: {
|
||||||
repoUrl: "https://github.com/anomalyco/opencode",
|
repoUrl: "https://github.com/anomalyco/opencode",
|
||||||
starsFormatted: {
|
starsFormatted: {
|
||||||
compact: "100K",
|
compact: "120K",
|
||||||
full: "100,000",
|
full: "120,000",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -22,8 +22,8 @@ export const config = {
|
|||||||
|
|
||||||
// Static stats (used on landing page)
|
// Static stats (used on landing page)
|
||||||
stats: {
|
stats: {
|
||||||
contributors: "700",
|
contributors: "800",
|
||||||
commits: "9,000",
|
commits: "10,000",
|
||||||
monthlyUsers: "2.5M",
|
monthlyUsers: "5M",
|
||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -480,7 +480,6 @@ export const dict = {
|
|||||||
"workspace.cost.deletedSuffix": "(محذوف)",
|
"workspace.cost.deletedSuffix": "(محذوف)",
|
||||||
"workspace.cost.empty": "لا توجد بيانات استخدام متاحة للفترة المحددة.",
|
"workspace.cost.empty": "لا توجد بيانات استخدام متاحة للفترة المحددة.",
|
||||||
"workspace.cost.subscriptionShort": "اشتراك",
|
"workspace.cost.subscriptionShort": "اشتراك",
|
||||||
"workspace.cost.liteShort": "lite",
|
|
||||||
|
|
||||||
"workspace.keys.title": "مفاتيح API",
|
"workspace.keys.title": "مفاتيح API",
|
||||||
"workspace.keys.subtitle": "إدارة مفاتيح API الخاصة بك للوصول إلى خدمات opencode.",
|
"workspace.keys.subtitle": "إدارة مفاتيح API الخاصة بك للوصول إلى خدمات opencode.",
|
||||||
|
|||||||
@@ -488,7 +488,6 @@ export const dict = {
|
|||||||
"workspace.cost.deletedSuffix": "(excluído)",
|
"workspace.cost.deletedSuffix": "(excluído)",
|
||||||
"workspace.cost.empty": "Nenhum dado de uso disponível para o período selecionado.",
|
"workspace.cost.empty": "Nenhum dado de uso disponível para o período selecionado.",
|
||||||
"workspace.cost.subscriptionShort": "ass",
|
"workspace.cost.subscriptionShort": "ass",
|
||||||
"workspace.cost.liteShort": "lite",
|
|
||||||
|
|
||||||
"workspace.keys.title": "Chaves de API",
|
"workspace.keys.title": "Chaves de API",
|
||||||
"workspace.keys.subtitle": "Gerencie suas chaves de API para acessar os serviços opencode.",
|
"workspace.keys.subtitle": "Gerencie suas chaves de API para acessar os serviços opencode.",
|
||||||
|
|||||||
@@ -484,7 +484,6 @@ export const dict = {
|
|||||||
"workspace.cost.deletedSuffix": "(slettet)",
|
"workspace.cost.deletedSuffix": "(slettet)",
|
||||||
"workspace.cost.empty": "Ingen brugsdata tilgængelige for den valgte periode.",
|
"workspace.cost.empty": "Ingen brugsdata tilgængelige for den valgte periode.",
|
||||||
"workspace.cost.subscriptionShort": "sub",
|
"workspace.cost.subscriptionShort": "sub",
|
||||||
"workspace.cost.liteShort": "lite",
|
|
||||||
|
|
||||||
"workspace.keys.title": "API-nøgler",
|
"workspace.keys.title": "API-nøgler",
|
||||||
"workspace.keys.subtitle": "Administrer dine API-nøgler for at få adgang til opencode-tjenester.",
|
"workspace.keys.subtitle": "Administrer dine API-nøgler for at få adgang til opencode-tjenester.",
|
||||||
|
|||||||
@@ -487,7 +487,6 @@ export const dict = {
|
|||||||
"workspace.cost.deletedSuffix": "(gelöscht)",
|
"workspace.cost.deletedSuffix": "(gelöscht)",
|
||||||
"workspace.cost.empty": "Keine Nutzungsdaten für den gewählten Zeitraum verfügbar.",
|
"workspace.cost.empty": "Keine Nutzungsdaten für den gewählten Zeitraum verfügbar.",
|
||||||
"workspace.cost.subscriptionShort": "Abo",
|
"workspace.cost.subscriptionShort": "Abo",
|
||||||
"workspace.cost.liteShort": "lite",
|
|
||||||
|
|
||||||
"workspace.keys.title": "API Keys",
|
"workspace.keys.title": "API Keys",
|
||||||
"workspace.keys.subtitle": "Verwalte deine API Keys für den Zugriff auf OpenCode-Dienste.",
|
"workspace.keys.subtitle": "Verwalte deine API Keys für den Zugriff auf OpenCode-Dienste.",
|
||||||
|
|||||||
@@ -480,7 +480,6 @@ export const dict = {
|
|||||||
"workspace.cost.deletedSuffix": "(deleted)",
|
"workspace.cost.deletedSuffix": "(deleted)",
|
||||||
"workspace.cost.empty": "No usage data available for the selected period.",
|
"workspace.cost.empty": "No usage data available for the selected period.",
|
||||||
"workspace.cost.subscriptionShort": "sub",
|
"workspace.cost.subscriptionShort": "sub",
|
||||||
"workspace.cost.liteShort": "lite",
|
|
||||||
|
|
||||||
"workspace.keys.title": "API Keys",
|
"workspace.keys.title": "API Keys",
|
||||||
"workspace.keys.subtitle": "Manage your API keys for accessing opencode services.",
|
"workspace.keys.subtitle": "Manage your API keys for accessing opencode services.",
|
||||||
|
|||||||
@@ -489,7 +489,6 @@ export const dict = {
|
|||||||
"workspace.cost.deletedSuffix": "(eliminado)",
|
"workspace.cost.deletedSuffix": "(eliminado)",
|
||||||
"workspace.cost.empty": "No hay datos de uso disponibles para el periodo seleccionado.",
|
"workspace.cost.empty": "No hay datos de uso disponibles para el periodo seleccionado.",
|
||||||
"workspace.cost.subscriptionShort": "sub",
|
"workspace.cost.subscriptionShort": "sub",
|
||||||
"workspace.cost.liteShort": "lite",
|
|
||||||
|
|
||||||
"workspace.keys.title": "Claves API",
|
"workspace.keys.title": "Claves API",
|
||||||
"workspace.keys.subtitle": "Gestiona tus claves API para acceder a los servicios de opencode.",
|
"workspace.keys.subtitle": "Gestiona tus claves API para acceder a los servicios de opencode.",
|
||||||
|
|||||||
@@ -490,7 +490,6 @@ export const dict = {
|
|||||||
"workspace.cost.deletedSuffix": "(supprimé)",
|
"workspace.cost.deletedSuffix": "(supprimé)",
|
||||||
"workspace.cost.empty": "Aucune donnée d'utilisation disponible pour la période sélectionnée.",
|
"workspace.cost.empty": "Aucune donnée d'utilisation disponible pour la période sélectionnée.",
|
||||||
"workspace.cost.subscriptionShort": "abo",
|
"workspace.cost.subscriptionShort": "abo",
|
||||||
"workspace.cost.liteShort": "lite",
|
|
||||||
|
|
||||||
"workspace.keys.title": "Clés API",
|
"workspace.keys.title": "Clés API",
|
||||||
"workspace.keys.subtitle": "Gérez vos clés API pour accéder aux services OpenCode.",
|
"workspace.keys.subtitle": "Gérez vos clés API pour accéder aux services OpenCode.",
|
||||||
|
|||||||
@@ -487,7 +487,6 @@ export const dict = {
|
|||||||
"workspace.cost.deletedSuffix": "(eliminato)",
|
"workspace.cost.deletedSuffix": "(eliminato)",
|
||||||
"workspace.cost.empty": "Nessun dato di utilizzo disponibile per il periodo selezionato.",
|
"workspace.cost.empty": "Nessun dato di utilizzo disponibile per il periodo selezionato.",
|
||||||
"workspace.cost.subscriptionShort": "sub",
|
"workspace.cost.subscriptionShort": "sub",
|
||||||
"workspace.cost.liteShort": "lite",
|
|
||||||
|
|
||||||
"workspace.keys.title": "Chiavi API",
|
"workspace.keys.title": "Chiavi API",
|
||||||
"workspace.keys.subtitle": "Gestisci le tue chiavi API per accedere ai servizi opencode.",
|
"workspace.keys.subtitle": "Gestisci le tue chiavi API per accedere ai servizi opencode.",
|
||||||
|
|||||||
@@ -486,7 +486,6 @@ export const dict = {
|
|||||||
"workspace.cost.deletedSuffix": "(削除済み)",
|
"workspace.cost.deletedSuffix": "(削除済み)",
|
||||||
"workspace.cost.empty": "選択した期間の使用状況データはありません。",
|
"workspace.cost.empty": "選択した期間の使用状況データはありません。",
|
||||||
"workspace.cost.subscriptionShort": "サブ",
|
"workspace.cost.subscriptionShort": "サブ",
|
||||||
"workspace.cost.liteShort": "lite",
|
|
||||||
|
|
||||||
"workspace.keys.title": "APIキー",
|
"workspace.keys.title": "APIキー",
|
||||||
"workspace.keys.subtitle": "OpenCodeサービスにアクセスするためのAPIキーを管理します。",
|
"workspace.keys.subtitle": "OpenCodeサービスにアクセスするためのAPIキーを管理します。",
|
||||||
|
|||||||
@@ -480,7 +480,6 @@ export const dict = {
|
|||||||
"workspace.cost.deletedSuffix": "(삭제됨)",
|
"workspace.cost.deletedSuffix": "(삭제됨)",
|
||||||
"workspace.cost.empty": "선택한 기간에 사용 데이터가 없습니다.",
|
"workspace.cost.empty": "선택한 기간에 사용 데이터가 없습니다.",
|
||||||
"workspace.cost.subscriptionShort": "구독",
|
"workspace.cost.subscriptionShort": "구독",
|
||||||
"workspace.cost.liteShort": "lite",
|
|
||||||
|
|
||||||
"workspace.keys.title": "API 키",
|
"workspace.keys.title": "API 키",
|
||||||
"workspace.keys.subtitle": "OpenCode 서비스 액세스를 위한 API 키를 관리하세요.",
|
"workspace.keys.subtitle": "OpenCode 서비스 액세스를 위한 API 키를 관리하세요.",
|
||||||
|
|||||||
@@ -485,7 +485,6 @@ export const dict = {
|
|||||||
"workspace.cost.deletedSuffix": "(slettet)",
|
"workspace.cost.deletedSuffix": "(slettet)",
|
||||||
"workspace.cost.empty": "Ingen bruksdata tilgjengelig for den valgte perioden.",
|
"workspace.cost.empty": "Ingen bruksdata tilgjengelig for den valgte perioden.",
|
||||||
"workspace.cost.subscriptionShort": "sub",
|
"workspace.cost.subscriptionShort": "sub",
|
||||||
"workspace.cost.liteShort": "lite",
|
|
||||||
|
|
||||||
"workspace.keys.title": "API-nøkler",
|
"workspace.keys.title": "API-nøkler",
|
||||||
"workspace.keys.subtitle": "Administrer API-nøklene dine for å få tilgang til opencode-tjenester.",
|
"workspace.keys.subtitle": "Administrer API-nøklene dine for å få tilgang til opencode-tjenester.",
|
||||||
|
|||||||
@@ -486,7 +486,6 @@ export const dict = {
|
|||||||
"workspace.cost.deletedSuffix": "(usunięte)",
|
"workspace.cost.deletedSuffix": "(usunięte)",
|
||||||
"workspace.cost.empty": "Brak danych o użyciu dla wybranego okresu.",
|
"workspace.cost.empty": "Brak danych o użyciu dla wybranego okresu.",
|
||||||
"workspace.cost.subscriptionShort": "sub",
|
"workspace.cost.subscriptionShort": "sub",
|
||||||
"workspace.cost.liteShort": "lite",
|
|
||||||
|
|
||||||
"workspace.keys.title": "Klucze API",
|
"workspace.keys.title": "Klucze API",
|
||||||
"workspace.keys.subtitle": "Zarządzaj kluczami API do usług opencode.",
|
"workspace.keys.subtitle": "Zarządzaj kluczami API do usług opencode.",
|
||||||
|
|||||||
@@ -492,7 +492,6 @@ export const dict = {
|
|||||||
"workspace.cost.deletedSuffix": "(удалено)",
|
"workspace.cost.deletedSuffix": "(удалено)",
|
||||||
"workspace.cost.empty": "Нет данных об использовании за выбранный период.",
|
"workspace.cost.empty": "Нет данных об использовании за выбранный период.",
|
||||||
"workspace.cost.subscriptionShort": "подписка",
|
"workspace.cost.subscriptionShort": "подписка",
|
||||||
"workspace.cost.liteShort": "lite",
|
|
||||||
|
|
||||||
"workspace.keys.title": "API Ключи",
|
"workspace.keys.title": "API Ключи",
|
||||||
"workspace.keys.subtitle": "Управляйте вашими API ключами для доступа к сервисам opencode.",
|
"workspace.keys.subtitle": "Управляйте вашими API ключами для доступа к сервисам opencode.",
|
||||||
|
|||||||
@@ -483,7 +483,6 @@ export const dict = {
|
|||||||
"workspace.cost.deletedSuffix": "(ลบแล้ว)",
|
"workspace.cost.deletedSuffix": "(ลบแล้ว)",
|
||||||
"workspace.cost.empty": "ไม่มีข้อมูลการใช้งานในช่วงเวลาที่เลือก",
|
"workspace.cost.empty": "ไม่มีข้อมูลการใช้งานในช่วงเวลาที่เลือก",
|
||||||
"workspace.cost.subscriptionShort": "sub",
|
"workspace.cost.subscriptionShort": "sub",
|
||||||
"workspace.cost.liteShort": "lite",
|
|
||||||
|
|
||||||
"workspace.keys.title": "API Keys",
|
"workspace.keys.title": "API Keys",
|
||||||
"workspace.keys.subtitle": "จัดการ API keys ของคุณสำหรับการเข้าถึงบริการ OpenCode",
|
"workspace.keys.subtitle": "จัดการ API keys ของคุณสำหรับการเข้าถึงบริการ OpenCode",
|
||||||
|
|||||||
@@ -488,7 +488,6 @@ export const dict = {
|
|||||||
"workspace.cost.deletedSuffix": "(silindi)",
|
"workspace.cost.deletedSuffix": "(silindi)",
|
||||||
"workspace.cost.empty": "Seçilen döneme ait kullanım verisi yok.",
|
"workspace.cost.empty": "Seçilen döneme ait kullanım verisi yok.",
|
||||||
"workspace.cost.subscriptionShort": "abonelik",
|
"workspace.cost.subscriptionShort": "abonelik",
|
||||||
"workspace.cost.liteShort": "lite",
|
|
||||||
|
|
||||||
"workspace.keys.title": "API Anahtarları",
|
"workspace.keys.title": "API Anahtarları",
|
||||||
"workspace.keys.subtitle": "opencode hizmetlerine erişim için API anahtarlarınızı yönetin.",
|
"workspace.keys.subtitle": "opencode hizmetlerine erişim için API anahtarlarınızı yönetin.",
|
||||||
|
|||||||
@@ -463,7 +463,6 @@ export const dict = {
|
|||||||
"workspace.cost.deletedSuffix": "(已删除)",
|
"workspace.cost.deletedSuffix": "(已删除)",
|
||||||
"workspace.cost.empty": "所选期间无可用使用数据。",
|
"workspace.cost.empty": "所选期间无可用使用数据。",
|
||||||
"workspace.cost.subscriptionShort": "订阅",
|
"workspace.cost.subscriptionShort": "订阅",
|
||||||
"workspace.cost.liteShort": "lite",
|
|
||||||
|
|
||||||
"workspace.keys.title": "API 密钥",
|
"workspace.keys.title": "API 密钥",
|
||||||
"workspace.keys.subtitle": "管理访问 OpenCode 服务的 API 密钥。",
|
"workspace.keys.subtitle": "管理访问 OpenCode 服务的 API 密钥。",
|
||||||
|
|||||||
@@ -464,7 +464,6 @@ export const dict = {
|
|||||||
"workspace.cost.deletedSuffix": "(已刪除)",
|
"workspace.cost.deletedSuffix": "(已刪除)",
|
||||||
"workspace.cost.empty": "所選期間沒有可用的使用資料。",
|
"workspace.cost.empty": "所選期間沒有可用的使用資料。",
|
||||||
"workspace.cost.subscriptionShort": "訂",
|
"workspace.cost.subscriptionShort": "訂",
|
||||||
"workspace.cost.liteShort": "lite",
|
|
||||||
|
|
||||||
"workspace.keys.title": "API 金鑰",
|
"workspace.keys.title": "API 金鑰",
|
||||||
"workspace.keys.subtitle": "管理你的 API 金鑰以存取 OpenCode 服務。",
|
"workspace.keys.subtitle": "管理你的 API 金鑰以存取 OpenCode 服務。",
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ export function GraphSection() {
|
|||||||
const colorTextSecondary = styles.getPropertyValue("--color-text-secondary").trim()
|
const colorTextSecondary = styles.getPropertyValue("--color-text-secondary").trim()
|
||||||
const colorBorder = styles.getPropertyValue("--color-border").trim()
|
const colorBorder = styles.getPropertyValue("--color-border").trim()
|
||||||
const subSuffix = ` (${i18n.t("workspace.cost.subscriptionShort")})`
|
const subSuffix = ` (${i18n.t("workspace.cost.subscriptionShort")})`
|
||||||
const liteSuffix = ` (${i18n.t("workspace.cost.liteShort")})`
|
const liteSuffix = " (go)"
|
||||||
|
|
||||||
const dailyDataRegular = new Map<string, Map<string, number>>()
|
const dailyDataRegular = new Map<string, Map<string, number>>()
|
||||||
const dailyDataSub = new Map<string, Map<string, number>>()
|
const dailyDataSub = new Map<string, Map<string, number>>()
|
||||||
|
|||||||
@@ -99,7 +99,13 @@ export async function handler(
|
|||||||
const dataDumper = createDataDumper(sessionId, requestId, projectId)
|
const dataDumper = createDataDumper(sessionId, requestId, projectId)
|
||||||
const trialLimiter = createTrialLimiter(modelInfo.trialProvider, ip)
|
const trialLimiter = createTrialLimiter(modelInfo.trialProvider, ip)
|
||||||
const trialProvider = await trialLimiter?.check()
|
const trialProvider = await trialLimiter?.check()
|
||||||
const rateLimiter = createRateLimiter(modelInfo.allowAnonymous, ip, input.request)
|
const rateLimiter = createRateLimiter(
|
||||||
|
modelInfo.id,
|
||||||
|
modelInfo.allowAnonymous,
|
||||||
|
modelInfo.rateLimit,
|
||||||
|
ip,
|
||||||
|
input.request,
|
||||||
|
)
|
||||||
await rateLimiter?.check()
|
await rateLimiter?.check()
|
||||||
const stickyTracker = createStickyTracker(modelInfo.stickyProvider, sessionId)
|
const stickyTracker = createStickyTracker(modelInfo.stickyProvider, sessionId)
|
||||||
const stickyProvider = await stickyTracker?.get()
|
const stickyProvider = await stickyTracker?.get()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user