mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-12 17:43:56 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3704dbcee1 | ||
|
|
b47ab35ddf | ||
|
|
0ac8f06521 | ||
|
|
4e46d98156 | ||
|
|
4795806b13 |
25
.github/actions/setup-bun/action.yml
vendored
25
.github/actions/setup-bun/action.yml
vendored
@@ -3,6 +3,14 @@ description: "Setup Bun with caching and install dependencies"
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Cache Bun dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Get baseline download URL
|
||||
id: bun-url
|
||||
shell: bash
|
||||
@@ -23,23 +31,6 @@ runs:
|
||||
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
|
||||
bun-download-url: ${{ steps.bun-url.outputs.url }}
|
||||
|
||||
- name: Get cache directory
|
||||
id: cache
|
||||
shell: bash
|
||||
run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache Bun dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Install setuptools for distutils compatibility
|
||||
run: python3 -m pip install setuptools || pip install setuptools || true
|
||||
shell: bash
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
shell: bash
|
||||
|
||||
11
.github/workflows/publish.yml
vendored
11
.github/workflows/publish.yml
vendored
@@ -115,9 +115,6 @@ jobs:
|
||||
target: x86_64-apple-darwin
|
||||
- host: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
|
||||
- host: windows-2025
|
||||
target: aarch64-pc-windows-msvc
|
||||
- host: blacksmith-4vcpu-windows-2025
|
||||
target: x86_64-pc-windows-msvc
|
||||
- host: blacksmith-4vcpu-ubuntu-2404
|
||||
@@ -152,10 +149,6 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Cache apt packages
|
||||
if: contains(matrix.settings.host, 'ubuntu')
|
||||
uses: actions/cache@v4
|
||||
@@ -261,10 +254,6 @@ jobs:
|
||||
- host: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
platform_flag: --mac --arm64
|
||||
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
|
||||
- host: "windows-2025"
|
||||
target: aarch64-pc-windows-msvc
|
||||
platform_flag: --win --arm64
|
||||
- host: "blacksmith-4vcpu-windows-2025"
|
||||
target: x86_64-pc-windows-msvc
|
||||
platform_flag: --win
|
||||
|
||||
38
.github/workflows/storybook.yml
vendored
38
.github/workflows/storybook.yml
vendored
@@ -1,38 +0,0 @@
|
||||
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
|
||||
23
.github/workflows/test.yml
vendored
23
.github/workflows/test.yml
vendored
@@ -6,14 +6,6 @@ on:
|
||||
- dev
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
name: unit (${{ matrix.settings.name }})
|
||||
@@ -94,3 +86,18 @@ jobs:
|
||||
path: |
|
||||
packages/app/e2e/test-results
|
||||
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
.opencode/.gitignore
vendored
1
.opencode/.gitignore
vendored
@@ -1,4 +1,3 @@
|
||||
plans/
|
||||
bun.lock
|
||||
package.json
|
||||
package-lock.json
|
||||
|
||||
@@ -5,11 +5,6 @@
|
||||
"options": {},
|
||||
},
|
||||
},
|
||||
"permission": {
|
||||
"edit": {
|
||||
"packages/opencode/migration/*": "deny",
|
||||
},
|
||||
},
|
||||
"mcp": {},
|
||||
"tools": {
|
||||
"github-triage": false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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:
|
||||
This tool searches PRs in the sst/opencode repository and returns LLM-friendly results including:
|
||||
- PR number and title
|
||||
- Author
|
||||
- State (open/closed/merged)
|
||||
|
||||
@@ -5,8 +5,16 @@ import DESCRIPTION from "./github-triage.txt"
|
||||
const TEAM = {
|
||||
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
|
||||
zen: ["fwang", "MrMushrooooom"],
|
||||
tui: ["thdxr", "kommander", "rekram1-node"],
|
||||
core: ["thdxr", "rekram1-node", "jlongster"],
|
||||
tui: [
|
||||
"thdxr",
|
||||
"kommander",
|
||||
// "rekram1-node" (on vacation)
|
||||
],
|
||||
core: [
|
||||
"thdxr",
|
||||
// "rekram1-node", (on vacation)
|
||||
"jlongster",
|
||||
],
|
||||
docs: ["R44VC0RP"],
|
||||
windows: ["Hona"],
|
||||
} as const
|
||||
@@ -42,10 +50,7 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
||||
export default tool({
|
||||
description: DESCRIPTION,
|
||||
args: {
|
||||
assignee: tool.schema
|
||||
.enum(ASSIGNEES as [string, ...string[]])
|
||||
.describe("The username of the assignee")
|
||||
.default("rekram1-node"),
|
||||
assignee: tool.schema.enum(ASSIGNEES as [string, ...string[]]).describe("The username of the assignee"),
|
||||
labels: tool.schema
|
||||
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
|
||||
.describe("The labels(s) to add to the issue")
|
||||
@@ -68,7 +73,8 @@ export default tool({
|
||||
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) {
|
||||
throw new Error("Only add the zen label when issue title/body contains 'zen'")
|
||||
|
||||
@@ -4,3 +4,5 @@ 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,7 +122,3 @@ const table = sqliteTable("session", {
|
||||
- Avoid mocks as much as possible
|
||||
- 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`.
|
||||
|
||||
## Type Checking
|
||||
|
||||
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
|
||||
|
||||
@@ -137,4 +137,4 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换:
|
||||
|
||||
---
|
||||
|
||||
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)
|
||||
**加入我们的社区** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -137,4 +137,4 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
|
||||
|
||||
---
|
||||
|
||||
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)
|
||||
**加入我們的社群** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -103,12 +103,6 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
|
||||
const zenLiteProduct = new stripe.Product("ZenLite", {
|
||||
name: "OpenCode Go",
|
||||
})
|
||||
const zenLiteCouponFirstMonth50 = new stripe.Coupon("ZenLiteCouponFirstMonth50", {
|
||||
name: "First month 50% off",
|
||||
percentOff: 50,
|
||||
appliesToProducts: [zenLiteProduct.id],
|
||||
duration: "once",
|
||||
})
|
||||
const zenLitePrice = new stripe.Price("ZenLitePrice", {
|
||||
product: zenLiteProduct.id,
|
||||
currency: "usd",
|
||||
@@ -122,7 +116,6 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
|
||||
properties: {
|
||||
product: zenLiteProduct.id,
|
||||
price: zenLitePrice.id,
|
||||
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-WJgo6UclmtQOEubnKMZybdIEhZ1uRTucF61yojjd+l0=",
|
||||
"aarch64-linux": "sha256-QfZ/g7EZFpe6ndR3dG8WvVfMj5Kyd/R/4kkTJfGJxL4=",
|
||||
"aarch64-darwin": "sha256-ezr/R70XJr9eN5l3mgb7HzLF6QsofNEKUOtuxbfli80=",
|
||||
"x86_64-darwin": "sha256-MbsBGS415uEU/n1RQ/5H5pqh+udLY3+oimJ+eS5uJVI="
|
||||
"x86_64-linux": "sha256-pBTIT8Pgdm3272YhBjiAZsmj0SSpHTklh6lGc8YcMoE=",
|
||||
"aarch64-linux": "sha256-prt039++d5UZgtldAN6+RVOR557ifIeusiy5XpzN8QU=",
|
||||
"aarch64-darwin": "sha256-Y3f+cXcIGLqz6oyc5fG22t6CLD4wGkvwqO6RNXjFriQ=",
|
||||
"x86_64-darwin": "sha256-BjbBBhQUgGhrlP56skABcrObvutNUZSWnrnPCg1OTKE="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
"effect": "4.0.0-beta.31",
|
||||
"ai": "5.0.124",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
|
||||
@@ -70,15 +70,7 @@ test("test description", async ({ page, sdk, gotoSession }) => {
|
||||
- `openSettings(page)` - Open settings dialog
|
||||
- `closeDialog(page, dialog)` - Close any dialog
|
||||
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
|
||||
- `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output
|
||||
- `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output
|
||||
- `withSession(sdk, title, callback)` - Create temp session
|
||||
- `withProject(...)` - Create temp project/workspace
|
||||
- `sessionIDFromUrl(url)` - Read session ID from URL
|
||||
- `slugFromUrl(url)` - Read workspace slug from URL
|
||||
- `waitSlug(page, skip?)` - Wait for resolved workspace slug
|
||||
- `trackSession(sessionID, directory?)` - Register session for fixture cleanup
|
||||
- `trackDirectory(directory)` - Register directory for fixture cleanup
|
||||
- `clickListItem(container, filter)` - Click list item by key/text
|
||||
|
||||
**Selectors** (`selectors.ts`):
|
||||
@@ -117,7 +109,7 @@ import { test, expect } from "@playwright/test"
|
||||
|
||||
### Error Handling
|
||||
|
||||
Tests should clean up after themselves. Prefer fixture-managed cleanup:
|
||||
Tests should clean up after themselves:
|
||||
|
||||
```typescript
|
||||
test("test with cleanup", async ({ page, sdk, gotoSession }) => {
|
||||
@@ -128,11 +120,6 @@ test("test with cleanup", async ({ page, sdk, gotoSession }) => {
|
||||
})
|
||||
```
|
||||
|
||||
- Prefer `withSession(...)` for temp sessions
|
||||
- In `withProject(...)` tests that create sessions or extra workspaces, call `trackSession(sessionID, directory?)` and `trackDirectory(directory)`
|
||||
- This lets fixture teardown abort, wait for idle, and clean up safely under CI concurrency
|
||||
- Avoid calling `sdk.session.delete(...)` directly
|
||||
|
||||
### Timeouts
|
||||
|
||||
Default: 60s per test, 10s per assertion. Override when needed:
|
||||
@@ -169,22 +156,14 @@ await page.keyboard.press(`${modKey}+B`) // Toggle sidebar
|
||||
await page.keyboard.press(`${modKey}+Comma`) // Open settings
|
||||
```
|
||||
|
||||
### Terminal Tests
|
||||
|
||||
- In terminal tests, type through the browser. Do not write to the PTY through the SDK.
|
||||
- Use `waitTerminalReady(page, { term? })` and `runTerminal(page, { cmd, token, term?, timeout? })` from `actions.ts`.
|
||||
- These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles.
|
||||
- Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks.
|
||||
|
||||
## Writing New Tests
|
||||
|
||||
1. Choose appropriate folder or create new one
|
||||
2. Import from `../fixtures`
|
||||
3. Use helper functions from `../actions` and `../selectors`
|
||||
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. Clean up any created resources
|
||||
6. Use specific selectors (avoid CSS classes)
|
||||
7. Test one feature per test file
|
||||
4. Clean up any created resources
|
||||
5. Use specific selectors (avoid CSS classes)
|
||||
6. Test one feature per test file
|
||||
|
||||
## Local Development
|
||||
|
||||
|
||||
@@ -3,23 +3,23 @@ import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { execSync } from "node:child_process"
|
||||
import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
|
||||
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
||||
import { modKey, serverUrl } from "./utils"
|
||||
import {
|
||||
sessionItemSelector,
|
||||
dropdownMenuTriggerSelector,
|
||||
dropdownMenuContentSelector,
|
||||
sessionHeaderSelector,
|
||||
projectMenuTriggerSelector,
|
||||
projectCloseMenuSelector,
|
||||
projectWorkspacesToggleSelector,
|
||||
titlebarRightSelector,
|
||||
popoverBodySelector,
|
||||
listItemSelector,
|
||||
listItemKeySelector,
|
||||
listItemKeyStartsWithSelector,
|
||||
terminalSelector,
|
||||
workspaceItemSelector,
|
||||
workspaceMenuTriggerSelector,
|
||||
} from "./selectors"
|
||||
import type { createSdk } from "./utils"
|
||||
|
||||
export async function defocus(page: Page) {
|
||||
await page
|
||||
@@ -30,53 +30,6 @@ export async function defocus(page: Page) {
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
async function terminalID(term: Locator) {
|
||||
const id = await term.getAttribute(terminalAttr)
|
||||
if (id) return id
|
||||
throw new Error(`Active terminal missing ${terminalAttr}`)
|
||||
}
|
||||
|
||||
async function terminalReady(page: Page, term?: Locator) {
|
||||
const next = term ?? page.locator(terminalSelector).first()
|
||||
const id = await terminalID(next)
|
||||
return page.evaluate((id) => {
|
||||
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
|
||||
return !!state?.connected && (state.settled ?? 0) > 0
|
||||
}, id)
|
||||
}
|
||||
|
||||
async function terminalHas(page: Page, input: { term?: Locator; token: string }) {
|
||||
const next = input.term ?? page.locator(terminalSelector).first()
|
||||
const id = await terminalID(next)
|
||||
return page.evaluate(
|
||||
(input) => {
|
||||
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[input.id]
|
||||
return state?.rendered.includes(input.token) ?? false
|
||||
},
|
||||
{ id, token: input.token },
|
||||
)
|
||||
}
|
||||
|
||||
export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) {
|
||||
const term = input?.term ?? page.locator(terminalSelector).first()
|
||||
const timeout = input?.timeout ?? 10_000
|
||||
await expect(term).toBeVisible()
|
||||
await expect(term.locator("textarea")).toHaveCount(1)
|
||||
await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true)
|
||||
}
|
||||
|
||||
export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) {
|
||||
const term = input.term ?? page.locator(terminalSelector).first()
|
||||
const timeout = input.timeout ?? 10_000
|
||||
await waitTerminalReady(page, { term, timeout })
|
||||
const textarea = term.locator("textarea")
|
||||
await term.click()
|
||||
await expect(textarea).toBeFocused()
|
||||
await page.keyboard.type(input.cmd)
|
||||
await page.keyboard.press("Enter")
|
||||
await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
|
||||
}
|
||||
|
||||
export async function openPalette(page: Page) {
|
||||
await defocus(page)
|
||||
await page.keyboard.press(`${modKey}+P`)
|
||||
@@ -109,9 +62,9 @@ export async function closeDialog(page: Page, dialog: Locator) {
|
||||
}
|
||||
|
||||
export async function isSidebarClosed(page: Page) {
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
await expect(button).toBeVisible()
|
||||
return (await button.getAttribute("aria-expanded")) !== "true"
|
||||
const main = page.locator("main")
|
||||
const classes = (await main.getAttribute("class")) ?? ""
|
||||
return classes.includes("xl:border-l")
|
||||
}
|
||||
|
||||
export async function toggleSidebar(page: Page) {
|
||||
@@ -123,34 +76,48 @@ export async function openSidebar(page: Page) {
|
||||
if (!(await isSidebarClosed(page))) return
|
||||
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
await button.click()
|
||||
const visible = await button
|
||||
.isVisible()
|
||||
.then((x) => x)
|
||||
.catch(() => false)
|
||||
|
||||
const opened = await expect(button)
|
||||
.toHaveAttribute("aria-expanded", "true", { timeout: 1500 })
|
||||
if (visible) await button.click()
|
||||
if (!visible) await toggleSidebar(page)
|
||||
|
||||
const main = page.locator("main")
|
||||
const opened = await expect(main)
|
||||
.not.toHaveClass(/xl:border-l/, { timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (opened) return
|
||||
|
||||
await toggleSidebar(page)
|
||||
await expect(button).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(main).not.toHaveClass(/xl:border-l/)
|
||||
}
|
||||
|
||||
export async function closeSidebar(page: Page) {
|
||||
if (await isSidebarClosed(page)) return
|
||||
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
await button.click()
|
||||
const visible = await button
|
||||
.isVisible()
|
||||
.then((x) => x)
|
||||
.catch(() => false)
|
||||
|
||||
const closed = await expect(button)
|
||||
.toHaveAttribute("aria-expanded", "false", { timeout: 1500 })
|
||||
if (visible) await button.click()
|
||||
if (!visible) await toggleSidebar(page)
|
||||
|
||||
const main = page.locator("main")
|
||||
const closed = await expect(main)
|
||||
.toHaveClass(/xl:border-l/, { timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (closed) return
|
||||
|
||||
await toggleSidebar(page)
|
||||
await expect(button).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(main).toHaveClass(/xl:border-l/)
|
||||
}
|
||||
|
||||
export async function openSettings(page: Page) {
|
||||
@@ -231,48 +198,17 @@ export async function createTestProject() {
|
||||
await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
|
||||
|
||||
execSync("git init", { cwd: root, stdio: "ignore" })
|
||||
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
|
||||
execSync("git add -A", { cwd: root, stdio: "ignore" })
|
||||
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
|
||||
cwd: root,
|
||||
stdio: "ignore",
|
||||
})
|
||||
|
||||
return resolveDirectory(root)
|
||||
return root
|
||||
}
|
||||
|
||||
export async function cleanupTestProject(directory: string) {
|
||||
try {
|
||||
execSync("git fsmonitor--daemon stop", { cwd: directory, stdio: "ignore" })
|
||||
} catch {}
|
||||
await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined)
|
||||
}
|
||||
|
||||
export function slugFromUrl(url: string) {
|
||||
return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
|
||||
}
|
||||
|
||||
export async function waitSlug(page: Page, skip: string[] = []) {
|
||||
let prev = ""
|
||||
let next = ""
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const slug = slugFromUrl(page.url())
|
||||
if (!slug) return ""
|
||||
if (skip.includes(slug)) return ""
|
||||
if (slug !== prev) {
|
||||
prev = slug
|
||||
next = ""
|
||||
return ""
|
||||
}
|
||||
next = slug
|
||||
return slug
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
)
|
||||
.not.toBe("")
|
||||
return next
|
||||
await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined)
|
||||
}
|
||||
|
||||
export function sessionIDFromUrl(url: string) {
|
||||
@@ -281,7 +217,7 @@ export function sessionIDFromUrl(url: string) {
|
||||
}
|
||||
|
||||
export async function hoverSessionItem(page: Page, sessionID: string) {
|
||||
const sessionEl = page.locator(`[data-session-id="${sessionID}"]`).last()
|
||||
const sessionEl = page.locator(sessionItemSelector(sessionID)).first()
|
||||
await expect(sessionEl).toBeVisible()
|
||||
await sessionEl.hover()
|
||||
return sessionEl
|
||||
@@ -290,9 +226,9 @@ export async function hoverSessionItem(page: Page, sessionID: string) {
|
||||
export async function openSessionMoreMenu(page: Page, sessionID: string) {
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
|
||||
|
||||
const scroller = page.locator(".scroll-view__viewport").first()
|
||||
await expect(scroller).toBeVisible()
|
||||
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
|
||||
const header = page.locator(sessionHeaderSelector).first()
|
||||
await expect(header).toBeVisible()
|
||||
await expect(header.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const menu = page
|
||||
.locator(dropdownMenuContentSelector)
|
||||
@@ -308,7 +244,7 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) {
|
||||
|
||||
if (opened) return menu
|
||||
|
||||
const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
|
||||
const menuTrigger = header.getByRole("button", { name: /more options/i }).first()
|
||||
await expect(menuTrigger).toBeVisible()
|
||||
await menuTrigger.click()
|
||||
|
||||
@@ -382,57 +318,6 @@ export async function clickListItem(
|
||||
return item
|
||||
}
|
||||
|
||||
async function status(sdk: ReturnType<typeof createSdk>, sessionID: string) {
|
||||
const data = await sdk.session
|
||||
.status()
|
||||
.then((x) => x.data ?? {})
|
||||
.catch(() => undefined)
|
||||
return data?.[sessionID]
|
||||
}
|
||||
|
||||
async function stable(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 10_000) {
|
||||
let prev = ""
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const info = await sdk.session
|
||||
.get({ sessionID })
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
if (!info) return true
|
||||
const next = `${info.title}:${info.time.updated ?? info.time.created}`
|
||||
if (next !== prev) {
|
||||
prev = next
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
{ timeout },
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
export async function waitSessionIdle(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 30_000) {
|
||||
await expect.poll(() => status(sdk, sessionID).then((x) => !x || x.type === "idle"), { timeout }).toBe(true)
|
||||
}
|
||||
|
||||
export async function cleanupSession(input: {
|
||||
sessionID: string
|
||||
directory?: string
|
||||
sdk?: ReturnType<typeof createSdk>
|
||||
}) {
|
||||
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory) : undefined)
|
||||
if (!sdk) throw new Error("cleanupSession requires sdk or directory")
|
||||
await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
|
||||
const current = await status(sdk, input.sessionID).catch(() => undefined)
|
||||
if (current && current.type !== "idle") {
|
||||
await sdk.session.abort({ sessionID: input.sessionID }).catch(() => undefined)
|
||||
await waitSessionIdle(sdk, input.sessionID).catch(() => undefined)
|
||||
}
|
||||
await stable(sdk, input.sessionID).catch(() => undefined)
|
||||
await sdk.session.delete({ sessionID: input.sessionID }).catch(() => undefined)
|
||||
}
|
||||
|
||||
export async function withSession<T>(
|
||||
sdk: ReturnType<typeof createSdk>,
|
||||
title: string,
|
||||
@@ -444,7 +329,7 @@ export async function withSession<T>(
|
||||
try {
|
||||
return await callback(session)
|
||||
} finally {
|
||||
await cleanupSession({ sdk, sessionID: session.id })
|
||||
await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -682,42 +567,32 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
|
||||
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
|
||||
await expect(trigger).toHaveCount(1)
|
||||
|
||||
const menu = page
|
||||
.locator(dropdownMenuContentSelector)
|
||||
.filter({ has: page.locator(projectCloseMenuSelector(projectSlug)) })
|
||||
.first()
|
||||
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
|
||||
|
||||
const clicked = await trigger
|
||||
.click({ timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (clicked) {
|
||||
const opened = await menu
|
||||
.waitFor({ state: "visible", timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (opened) {
|
||||
await expect(close).toBeVisible()
|
||||
return menu
|
||||
}
|
||||
}
|
||||
|
||||
await trigger.focus()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||
const opened = await menu
|
||||
.waitFor({ state: "visible", timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (opened) {
|
||||
await expect(close).toBeVisible()
|
||||
const viewport = page.viewportSize()
|
||||
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
|
||||
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
|
||||
await page.mouse.move(x, y)
|
||||
return menu
|
||||
}
|
||||
|
||||
throw new Error(`Failed to open project menu: ${projectSlug}`)
|
||||
await trigger.click({ force: true })
|
||||
|
||||
await expect(menu).toBeVisible()
|
||||
|
||||
const viewport = page.viewportSize()
|
||||
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
|
||||
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
|
||||
await page.mouse.move(x, y)
|
||||
return menu
|
||||
}
|
||||
|
||||
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
|
||||
@@ -730,18 +605,11 @@ export async function setWorkspacesEnabled(page: Page, projectSlug: string, enab
|
||||
|
||||
if (current === enabled) return
|
||||
|
||||
const flip = async (timeout?: number) => {
|
||||
const menu = await openProjectMenu(page, projectSlug)
|
||||
const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
|
||||
await expect(toggle).toBeVisible()
|
||||
return toggle.click({ force: true, timeout })
|
||||
}
|
||||
await openProjectMenu(page, projectSlug)
|
||||
|
||||
const flipped = await flip(1500)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (!flipped) await flip()
|
||||
const toggle = page.locator(projectWorkspacesToggleSelector(projectSlug)).first()
|
||||
await expect(toggle).toBeVisible()
|
||||
await toggle.click({ force: true })
|
||||
|
||||
const expected = enabled ? "New workspace" : "New session"
|
||||
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { serverNamePattern } from "../utils"
|
||||
import { serverName } from "../utils"
|
||||
|
||||
test("home renders and shows core entrypoints", async ({ page }) => {
|
||||
await page.goto("/")
|
||||
|
||||
await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: serverName })).toBeVisible()
|
||||
})
|
||||
|
||||
test("server picker dialog opens from home", async ({ page }) => {
|
||||
await page.goto("/")
|
||||
|
||||
const trigger = page.getByRole("button", { name: serverNamePattern })
|
||||
const trigger = page.getByRole("button", { name: serverName })
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { serverNamePattern, serverUrls } from "../utils"
|
||||
import { closeDialog, clickMenuItem } from "../actions"
|
||||
import { serverName, serverUrl } from "../utils"
|
||||
import { clickListItem, closeDialog, clickMenuItem } from "../actions"
|
||||
|
||||
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
|
||||
|
||||
@@ -31,9 +31,10 @@ test("can set a default server on web", async ({ page, gotoSession }) => {
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await expect(dialog.getByText(serverNamePattern).first()).toBeVisible()
|
||||
const row = dialog.locator('[data-slot="list-item"]').filter({ hasText: serverName }).first()
|
||||
await expect(row).toBeVisible()
|
||||
|
||||
const menuTrigger = dialog.locator('[data-slot="dropdown-menu-trigger"]').first()
|
||||
const menuTrigger = row.locator('[data-slot="dropdown-menu-trigger"]').first()
|
||||
await expect(menuTrigger).toBeVisible()
|
||||
await menuTrigger.click({ force: true })
|
||||
|
||||
@@ -41,18 +42,14 @@ test("can set a default server on web", async ({ page, gotoSession }) => {
|
||||
await expect(menu).toBeVisible()
|
||||
await clickMenuItem(menu, /set as default/i)
|
||||
|
||||
await expect
|
||||
.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 expect.poll(() => page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)).toBe(serverUrl)
|
||||
await expect(row.getByText("Default", { exact: true })).toBeVisible()
|
||||
|
||||
await closeDialog(page, dialog)
|
||||
|
||||
await ensurePopoverOpen()
|
||||
|
||||
const serverRow = popover.locator("button").filter({ hasText: serverNamePattern }).first()
|
||||
const serverRow = popover.locator("button").filter({ hasText: serverName }).first()
|
||||
await expect(serverRow).toBeVisible()
|
||||
await expect(serverRow.getByText("Default", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ test("titlebar back/forward navigates between sessions", async ({ page, slug, sd
|
||||
|
||||
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
|
||||
await expect(link).toBeVisible()
|
||||
await link.scrollIntoViewIfNeeded()
|
||||
await link.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||
@@ -55,6 +56,7 @@ test("titlebar forward is cleared after branching history from sidebar", async (
|
||||
|
||||
const second = page.locator(`[data-session-id="${b.id}"] a`).first()
|
||||
await expect(second).toBeVisible()
|
||||
await second.scrollIntoViewIfNeeded()
|
||||
await second.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${b.id}(?:\\?|#|$)`))
|
||||
@@ -74,6 +76,7 @@ test("titlebar forward is cleared after branching history from sidebar", async (
|
||||
|
||||
const third = page.locator(`[data-session-id="${c.id}"] a`).first()
|
||||
await expect(third).toBeVisible()
|
||||
await third.scrollIntoViewIfNeeded()
|
||||
await third.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${c.id}(?:\\?|#|$)`))
|
||||
@@ -99,6 +102,7 @@ test("keyboard shortcuts navigate titlebar history", async ({ page, slug, sdk, g
|
||||
|
||||
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
|
||||
await expect(link).toBeVisible()
|
||||
await link.scrollIntoViewIfNeeded()
|
||||
await link.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||
|
||||
@@ -10,8 +10,6 @@ const expanded = async (el: { getAttribute: (name: string) => Promise<string | n
|
||||
test("review panel can be toggled via keybind", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const reviewPanel = page.locator("#review-panel")
|
||||
|
||||
const treeToggle = page.getByRole("button", { name: "Toggle file tree" }).first()
|
||||
await expect(treeToggle).toBeVisible()
|
||||
if (await expanded(treeToggle)) await treeToggle.click()
|
||||
@@ -21,13 +19,13 @@ test("review panel can be toggled via keybind", async ({ page, gotoSession }) =>
|
||||
await expect(reviewToggle).toBeVisible()
|
||||
if (await expanded(reviewToggle)) await reviewToggle.click()
|
||||
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(reviewPanel).toHaveAttribute("aria-hidden", "true")
|
||||
await expect(page.locator("#review-panel")).toHaveCount(0)
|
||||
|
||||
await page.keyboard.press(`${modKey}+Shift+R`)
|
||||
await expect(reviewToggle).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(reviewPanel).toHaveAttribute("aria-hidden", "false")
|
||||
await expect(page.locator("#review-panel")).toBeVisible()
|
||||
|
||||
await page.keyboard.press(`${modKey}+Shift+R`)
|
||||
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(reviewPanel).toHaveAttribute("aria-hidden", "true")
|
||||
await expect(page.locator("#review-panel")).toHaveCount(0)
|
||||
})
|
||||
|
||||
@@ -43,13 +43,6 @@ test("file tree can expand folders and open a file", async ({ page, gotoSession
|
||||
await tab.click()
|
||||
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()
|
||||
await expect(viewer).toBeVisible()
|
||||
await expect(viewer).toContainText("export default function FileTree")
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test"
|
||||
import type { E2EWindow } from "../src/testing/terminal"
|
||||
import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
|
||||
import { cleanupTestProject, createTestProject, seedProjects } from "./actions"
|
||||
import { promptSelector } from "./selectors"
|
||||
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
||||
|
||||
@@ -14,8 +13,6 @@ type TestFixtures = {
|
||||
directory: string
|
||||
slug: string
|
||||
gotoSession: (sessionID?: string) => Promise<void>
|
||||
trackSession: (sessionID: string, directory?: string) => void
|
||||
trackDirectory: (directory: string) => void
|
||||
}) => Promise<T>,
|
||||
options?: { extra?: string[] },
|
||||
) => Promise<T>
|
||||
@@ -54,36 +51,20 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
},
|
||||
withProject: async ({ page }, use) => {
|
||||
await use(async (callback, options) => {
|
||||
const root = await createTestProject()
|
||||
const slug = dirSlug(root)
|
||||
const sessions = new Map<string, string>()
|
||||
const dirs = new Set<string>()
|
||||
await seedStorage(page, { directory: root, extra: options?.extra })
|
||||
const directory = await createTestProject()
|
||||
const slug = dirSlug(directory)
|
||||
await seedStorage(page, { directory, extra: options?.extra })
|
||||
|
||||
const gotoSession = async (sessionID?: string) => {
|
||||
await page.goto(sessionPath(root, sessionID))
|
||||
await page.goto(sessionPath(directory, sessionID))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
const current = sessionIDFromUrl(page.url())
|
||||
if (current) trackSession(current)
|
||||
}
|
||||
|
||||
const trackSession = (sessionID: string, directory?: string) => {
|
||||
sessions.set(sessionID, directory ?? root)
|
||||
}
|
||||
|
||||
const trackDirectory = (directory: string) => {
|
||||
if (directory !== root) dirs.add(directory)
|
||||
}
|
||||
|
||||
try {
|
||||
await gotoSession()
|
||||
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
|
||||
return await callback({ directory, slug, gotoSession })
|
||||
} finally {
|
||||
await Promise.allSettled(
|
||||
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
|
||||
)
|
||||
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
|
||||
await cleanupTestProject(root)
|
||||
await cleanupTestProject(directory)
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -92,14 +73,6 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
|
||||
await seedProjects(page, input)
|
||||
await page.addInitScript(() => {
|
||||
const win = window as E2EWindow
|
||||
win.__opencode_e2e = {
|
||||
...win.__opencode_e2e,
|
||||
terminal: {
|
||||
enabled: true,
|
||||
terminals: {},
|
||||
},
|
||||
}
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:model",
|
||||
JSON.stringify({
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { clickMenuItem, openProjectMenu, openSidebar } from "../actions"
|
||||
import { openSidebar } from "../actions"
|
||||
|
||||
test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
await withProject(async ({ slug }) => {
|
||||
await withProject(async () => {
|
||||
await openSidebar(page)
|
||||
|
||||
const open = async () => {
|
||||
const menu = await openProjectMenu(page, slug)
|
||||
await clickMenuItem(menu, /^Edit$/i, { force: true })
|
||||
const header = page.locator(".group\\/project").first()
|
||||
await header.hover()
|
||||
const trigger = header.getByRole("button", { name: "More options" }).first()
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click({ force: true })
|
||||
|
||||
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
|
||||
await expect(menu).toBeVisible()
|
||||
|
||||
const editItem = menu.getByRole("menuitem", { name: "Edit" }).first()
|
||||
await expect(editItem).toBeVisible()
|
||||
await editItem.click({ force: true })
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
@@ -1,8 +1,36 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, openProjectMenu } from "../actions"
|
||||
import { projectSwitchSelector } from "../selectors"
|
||||
import { projectCloseHoverSelector, projectSwitchSelector } from "../selectors"
|
||||
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 }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
@@ -25,26 +53,16 @@ test("closing active project navigates to another open project", async ({ page,
|
||||
await clickMenuItem(menu, /^Close$/i, { force: true })
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const pathname = new URL(page.url()).pathname
|
||||
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
|
||||
if (pathname === "/") return "home"
|
||||
return ""
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.poll(() => {
|
||||
const pathname = new URL(page.url()).pathname
|
||||
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
|
||||
if (pathname === "/") return "home"
|
||||
return ""
|
||||
})
|
||||
.toMatch(/^(project|home)$/)
|
||||
|
||||
await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
return await page.locator(projectSwitchSelector(otherSlug)).count()
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBe(0)
|
||||
await expect(otherButton).toHaveCount(0)
|
||||
},
|
||||
{ extra: [other] },
|
||||
)
|
||||
|
||||
@@ -1,39 +1,18 @@
|
||||
import { base64Decode } from "@opencode-ai/util/encode"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions"
|
||||
import {
|
||||
defocus,
|
||||
createTestProject,
|
||||
cleanupTestProject,
|
||||
openSidebar,
|
||||
setWorkspacesEnabled,
|
||||
sessionIDFromUrl,
|
||||
} from "../actions"
|
||||
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||
import { dirSlug, resolveDirectory } from "../utils"
|
||||
import { createSdk, dirSlug, sessionPath } from "../utils"
|
||||
|
||||
async function workspaces(page: Page, directory: string, enabled: boolean) {
|
||||
await page.evaluate(
|
||||
({ directory, enabled }: { directory: string; enabled: boolean }) => {
|
||||
const key = "opencode.global.dat:layout"
|
||||
const raw = localStorage.getItem(key)
|
||||
const data = raw ? JSON.parse(raw) : {}
|
||||
const sidebar = data.sidebar && typeof data.sidebar === "object" ? data.sidebar : {}
|
||||
const current =
|
||||
sidebar.workspaces && typeof sidebar.workspaces === "object" && !Array.isArray(sidebar.workspaces)
|
||||
? sidebar.workspaces
|
||||
: {}
|
||||
const next = { ...current }
|
||||
|
||||
if (enabled) next[directory] = true
|
||||
if (!enabled) delete next[directory]
|
||||
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
...data,
|
||||
sidebar: {
|
||||
...sidebar,
|
||||
workspaces: next,
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
{ directory, enabled },
|
||||
)
|
||||
function slugFromUrl(url: string) {
|
||||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||
}
|
||||
|
||||
test("can switch between projects from sidebar", async ({ page, withProject }) => {
|
||||
@@ -72,39 +51,46 @@ test("switching back to a project opens the latest workspace session", async ({
|
||||
|
||||
const other = await createTestProject()
|
||||
const otherSlug = dirSlug(other)
|
||||
let rootDir: string | undefined
|
||||
let workspaceDir: string | undefined
|
||||
let sessionID: string | undefined
|
||||
|
||||
try {
|
||||
await withProject(
|
||||
async ({ directory, slug, trackSession, trackDirectory }) => {
|
||||
async ({ directory, slug }) => {
|
||||
rootDir = directory
|
||||
await defocus(page)
|
||||
await workspaces(page, directory, true)
|
||||
await page.reload()
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await openSidebar(page)
|
||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||
await setWorkspacesEnabled(page, slug, true)
|
||||
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
|
||||
const raw = await waitSlug(page, [slug])
|
||||
const dir = base64Decode(raw)
|
||||
if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`)
|
||||
const space = await resolveDirectory(dir)
|
||||
const next = dirSlug(space)
|
||||
trackDirectory(space)
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const next = slugFromUrl(page.url())
|
||||
if (!next) return ""
|
||||
if (next === slug) return ""
|
||||
return next
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
)
|
||||
.not.toBe("")
|
||||
|
||||
const workspaceSlug = slugFromUrl(page.url())
|
||||
workspaceDir = base64Decode(workspaceSlug)
|
||||
if (!workspaceDir) throw new Error(`Failed to decode workspace slug: ${workspaceSlug}`)
|
||||
await openSidebar(page)
|
||||
|
||||
const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first()
|
||||
await expect(item).toBeVisible()
|
||||
await item.hover()
|
||||
const workspace = page.locator(workspaceItemSelector(workspaceSlug)).first()
|
||||
await expect(workspace).toBeVisible()
|
||||
await workspace.hover()
|
||||
|
||||
const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first()
|
||||
await expect(btn).toBeVisible()
|
||||
await btn.click({ force: true })
|
||||
const newSession = page.locator(workspaceNewSessionSelector(workspaceSlug)).first()
|
||||
await expect(newSession).toBeVisible()
|
||||
await newSession.click({ force: true })
|
||||
|
||||
// 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(?:[/?#]|$)`))
|
||||
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session(?:[/?#]|$)`))
|
||||
|
||||
// Create a session by sending a prompt
|
||||
const prompt = page.locator(promptSelector)
|
||||
@@ -117,9 +103,9 @@ test("switching back to a project opens the latest workspace session", async ({
|
||||
|
||||
const created = sessionIDFromUrl(page.url())
|
||||
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
|
||||
trackSession(created, space)
|
||||
sessionID = created
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
|
||||
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
|
||||
|
||||
await openSidebar(page)
|
||||
|
||||
@@ -138,6 +124,20 @@ test("switching back to a project opens the latest workspace session", async ({
|
||||
{ extra: [other] },
|
||||
)
|
||||
} finally {
|
||||
if (sessionID) {
|
||||
const id = sessionID
|
||||
const dirs = [rootDir, workspaceDir].filter((x): x is string => !!x)
|
||||
await Promise.all(
|
||||
dirs.map((directory) =>
|
||||
createSdk(directory)
|
||||
.session.delete({ sessionID: id })
|
||||
.catch(() => undefined),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (workspaceDir) {
|
||||
await cleanupTestProject(workspaceDir)
|
||||
}
|
||||
await cleanupTestProject(other)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { base64Decode } from "@opencode-ai/util/encode"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, slugFromUrl, waitSlug } from "../actions"
|
||||
import { cleanupTestProject, openSidebar, sessionIDFromUrl, setWorkspacesEnabled } from "../actions"
|
||||
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||
import { createSdk } from "../utils"
|
||||
|
||||
function slugFromUrl(url: string) {
|
||||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||
}
|
||||
|
||||
async function waitWorkspaceReady(page: Page, slug: string) {
|
||||
await openSidebar(page)
|
||||
await expect
|
||||
@@ -27,7 +31,20 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
|
||||
await openSidebar(page)
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
|
||||
const slug = await waitSlug(page, [root, ...seen])
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const slug = slugFromUrl(page.url())
|
||||
if (!slug) return ""
|
||||
if (slug === root) return ""
|
||||
if (seen.includes(slug)) return ""
|
||||
return slug
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
)
|
||||
.not.toBe("")
|
||||
|
||||
const slug = slugFromUrl(page.url())
|
||||
const directory = base64Decode(slug)
|
||||
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
||||
return { slug, directory }
|
||||
@@ -43,13 +60,12 @@ async function openWorkspaceNewSession(page: Page, slug: string) {
|
||||
await expect(button).toBeVisible()
|
||||
await button.click({ force: true })
|
||||
|
||||
const next = await waitSlug(page)
|
||||
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
|
||||
return next
|
||||
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session(?:[/?#]|$)`))
|
||||
}
|
||||
|
||||
async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
|
||||
const next = await openWorkspaceNewSession(page, slug)
|
||||
await openWorkspaceNewSession(page, slug)
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
await expect(prompt).toBeVisible()
|
||||
@@ -60,13 +76,13 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string
|
||||
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
|
||||
await prompt.press("Enter")
|
||||
|
||||
await expect.poll(() => slugFromUrl(page.url())).toBe(next)
|
||||
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
|
||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
||||
|
||||
const sessionID = sessionIDFromUrl(page.url())
|
||||
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||
await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`))
|
||||
return { sessionID, slug: next }
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${sessionID}(?:[/?#]|$)`))
|
||||
return sessionID
|
||||
}
|
||||
|
||||
async function sessionDirectory(directory: string, sessionID: string) {
|
||||
@@ -81,29 +97,48 @@ async function sessionDirectory(directory: string, sessionID: string) {
|
||||
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => {
|
||||
await openSidebar(page)
|
||||
await setWorkspacesEnabled(page, root, true)
|
||||
await withProject(async ({ directory, slug: root }) => {
|
||||
const workspaces = [] as { slug: string; directory: string }[]
|
||||
const sessions = [] as string[]
|
||||
|
||||
const first = await createWorkspace(page, root, [])
|
||||
trackDirectory(first.directory)
|
||||
await waitWorkspaceReady(page, first.slug)
|
||||
try {
|
||||
await openSidebar(page)
|
||||
await setWorkspacesEnabled(page, root, true)
|
||||
|
||||
const second = await createWorkspace(page, root, [first.slug])
|
||||
trackDirectory(second.directory)
|
||||
await waitWorkspaceReady(page, second.slug)
|
||||
const first = await createWorkspace(page, root, [])
|
||||
workspaces.push(first)
|
||||
await waitWorkspaceReady(page, first.slug)
|
||||
|
||||
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
|
||||
trackSession(firstSession.sessionID, first.directory)
|
||||
const second = await createWorkspace(page, root, [first.slug])
|
||||
workspaces.push(second)
|
||||
await waitWorkspaceReady(page, second.slug)
|
||||
|
||||
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
|
||||
trackSession(secondSession.sessionID, second.directory)
|
||||
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
|
||||
sessions.push(firstSession)
|
||||
|
||||
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
|
||||
trackSession(thirdSession.sessionID, first.directory)
|
||||
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
|
||||
sessions.push(secondSession)
|
||||
|
||||
await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
|
||||
await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
|
||||
await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
|
||||
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
|
||||
sessions.push(thirdSession)
|
||||
|
||||
await expect.poll(() => sessionDirectory(first.directory, firstSession)).toBe(first.directory)
|
||||
await expect.poll(() => sessionDirectory(second.directory, secondSession)).toBe(second.directory)
|
||||
await expect.poll(() => sessionDirectory(first.directory, thirdSession)).toBe(first.directory)
|
||||
} finally {
|
||||
const dirs = [directory, ...workspaces.map((workspace) => workspace.directory)]
|
||||
await Promise.all(
|
||||
sessions.map((sessionID) =>
|
||||
Promise.all(
|
||||
dirs.map((dir) =>
|
||||
createSdk(dir)
|
||||
.session.delete({ sessionID })
|
||||
.catch(() => undefined),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
await Promise.all(workspaces.map((workspace) => cleanupTestProject(workspace.directory)))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,12 +14,14 @@ import {
|
||||
openSidebar,
|
||||
openWorkspaceMenu,
|
||||
setWorkspacesEnabled,
|
||||
slugFromUrl,
|
||||
waitSlug,
|
||||
} from "../actions"
|
||||
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
|
||||
import { createSdk, dirSlug } from "../utils"
|
||||
|
||||
function slugFromUrl(url: string) {
|
||||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||
}
|
||||
|
||||
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
||||
const rootSlug = project.slug
|
||||
await openSidebar(page)
|
||||
@@ -27,7 +29,17 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
||||
await setWorkspacesEnabled(page, rootSlug, true)
|
||||
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
const slug = await waitSlug(page, [rootSlug])
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const slug = slugFromUrl(page.url())
|
||||
return slug.length > 0 && slug !== rootSlug
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const slug = slugFromUrl(page.url())
|
||||
const dir = base64Decode(slug)
|
||||
|
||||
await openSidebar(page)
|
||||
@@ -79,7 +91,18 @@ test("can create a workspace", async ({ page, withProject }) => {
|
||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
const workspaceSlug = await waitSlug(page, [slug])
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const currentSlug = slugFromUrl(page.url())
|
||||
return currentSlug.length > 0 && currentSlug !== slug
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const workspaceSlug = slugFromUrl(page.url())
|
||||
const workspaceDir = base64Decode(workspaceSlug)
|
||||
|
||||
await openSidebar(page)
|
||||
@@ -256,7 +279,7 @@ test("can delete a workspace", async ({ page, withProject }) => {
|
||||
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
||||
await confirmDialog(page, /^Delete workspace$/i)
|
||||
|
||||
await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
|
||||
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
@@ -313,6 +336,9 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
|
||||
const src = page.locator(workspaceItemSelector(from)).first()
|
||||
const dst = page.locator(workspaceItemSelector(to)).first()
|
||||
|
||||
await src.scrollIntoViewIfNeeded()
|
||||
await dst.scrollIntoViewIfNeeded()
|
||||
|
||||
const a = await src.boundingBox()
|
||||
const b = await dst.boundingBox()
|
||||
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
|
||||
@@ -331,7 +357,17 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
|
||||
for (const _ of [0, 1]) {
|
||||
const prev = slugFromUrl(page.url())
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
const slug = await waitSlug(page, [rootSlug, prev])
|
||||
await expect
|
||||
.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)
|
||||
workspaces.push({ slug, directory: dir })
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
|
||||
|
||||
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||
import { sessionIDFromUrl } from "../actions"
|
||||
|
||||
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
|
||||
// the connection open while the agent works, causing "Failed to fetch" over
|
||||
@@ -40,37 +38,6 @@ test("prompt succeeds when sync message endpoint is unreachable", async ({ page,
|
||||
)
|
||||
.toContain(token)
|
||||
} finally {
|
||||
await cleanupSession({ sdk, sessionID })
|
||||
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
|
||||
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
|
||||
await withSession(sdk, `e2e prompt failure ${Date.now()}`, async (session) => {
|
||||
const prompt = page.locator(promptSelector)
|
||||
const value = `restore ${Date.now()}`
|
||||
|
||||
await page.route(`**/session/${session.id}/prompt_async`, (route) =>
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "e2e prompt failure" }),
|
||||
}),
|
||||
)
|
||||
|
||||
await gotoSession(session.id)
|
||||
await prompt.click()
|
||||
await page.keyboard.type(value)
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect.poll(async () => text(await prompt.textContent())).toBe(value)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const messages = await sdk.session.messages({ sessionID: session.id, limit: 50 }).then((r) => r.data ?? [])
|
||||
return messages.length
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { withSession } from "../actions"
|
||||
import { promptSelector } from "../selectors"
|
||||
|
||||
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||
|
||||
const isBash = (part: unknown): part is ToolPart => {
|
||||
if (!part || typeof part !== "object") return false
|
||||
if (!("type" in part) || part.type !== "tool") return false
|
||||
if (!("tool" in part) || part.tool !== "bash") return false
|
||||
return "state" in part
|
||||
}
|
||||
|
||||
async function edge(page: Page, pos: "start" | "end") {
|
||||
await page.locator(promptSelector).evaluate((el: HTMLDivElement, pos: "start" | "end") => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection) return
|
||||
|
||||
const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
|
||||
const nodes: Text[] = []
|
||||
for (let node = walk.nextNode(); node; node = walk.nextNode()) {
|
||||
nodes.push(node as Text)
|
||||
}
|
||||
|
||||
if (nodes.length === 0) {
|
||||
const node = document.createTextNode("")
|
||||
el.appendChild(node)
|
||||
nodes.push(node)
|
||||
}
|
||||
|
||||
const node = pos === "start" ? nodes[0]! : nodes[nodes.length - 1]!
|
||||
const range = document.createRange()
|
||||
range.setStart(node, pos === "start" ? 0 : (node.textContent ?? "").length)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}, pos)
|
||||
}
|
||||
|
||||
async function wait(page: Page, value: string) {
|
||||
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
|
||||
}
|
||||
|
||||
async function reply(sdk: Parameters<typeof withSession>[0], sessionID: string, token: string) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||
return messages
|
||||
.filter((item) => item.info.role === "assistant")
|
||||
.flatMap((item) => item.parts)
|
||||
.filter((item) => item.type === "text")
|
||||
.map((item) => item.text)
|
||||
.join("\n")
|
||||
},
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
.toContain(token)
|
||||
}
|
||||
|
||||
async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string, cmd: string, token: string) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||
const part = messages
|
||||
.filter((item) => item.info.role === "assistant")
|
||||
.flatMap((item) => item.parts)
|
||||
.filter(isBash)
|
||||
.find((item) => item.state.input?.command === cmd && item.state.status === "completed")
|
||||
|
||||
if (!part || part.state.status !== "completed") return
|
||||
return typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
|
||||
},
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
.toContain(token)
|
||||
}
|
||||
|
||||
test("prompt history restores unsent draft with arrow navigation", async ({ page, sdk, gotoSession }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
await withSession(sdk, `e2e prompt history ${Date.now()}`, async (session) => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
|
||||
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
|
||||
const first = `Reply with exactly: ${firstToken}`
|
||||
const second = `Reply with exactly: ${secondToken}`
|
||||
const draft = `draft ${Date.now()}`
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type(first)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await reply(sdk, session.id, firstToken)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type(second)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await reply(sdk, session.id, secondToken)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type(draft)
|
||||
await wait(page, draft)
|
||||
|
||||
await edge(page, "start")
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, second)
|
||||
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, first)
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, second)
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, draft)
|
||||
})
|
||||
})
|
||||
|
||||
test("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
await withSession(sdk, `e2e shell history ${Date.now()}`, async (session) => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
|
||||
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
|
||||
const normalToken = `E2E_NORMAL_${Date.now()}`
|
||||
const first = `echo ${firstToken}`
|
||||
const second = `echo ${secondToken}`
|
||||
const normal = `Reply with exactly: ${normalToken}`
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type("!")
|
||||
await page.keyboard.type(first)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await shell(sdk, session.id, first, firstToken)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type("!")
|
||||
await page.keyboard.type(second)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await shell(sdk, session.id, second, secondToken)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type("!")
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, second)
|
||||
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, first)
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, second)
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, "")
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await wait(page, "")
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type(normal)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await reply(sdk, session.id, normalToken)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, normal)
|
||||
})
|
||||
})
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { sessionIDFromUrl } from "../actions"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { createSdk } from "../utils"
|
||||
|
||||
const isBash = (part: unknown): part is ToolPart => {
|
||||
if (!part || typeof part !== "object") return false
|
||||
if (!("type" in part) || part.type !== "tool") return false
|
||||
if (!("tool" in part) || part.tool !== "bash") return false
|
||||
return "state" in part
|
||||
}
|
||||
|
||||
test("shell mode runs a command in the project directory", async ({ page, withProject }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
await withProject(async ({ directory, gotoSession, trackSession }) => {
|
||||
const sdk = createSdk(directory)
|
||||
const prompt = page.locator(promptSelector)
|
||||
const cmd = process.platform === "win32" ? "dir" : "ls"
|
||||
|
||||
await gotoSession()
|
||||
await prompt.click()
|
||||
await page.keyboard.type("!")
|
||||
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
|
||||
|
||||
await page.keyboard.type(cmd)
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||
|
||||
const id = sessionIDFromUrl(page.url())
|
||||
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||
trackSession(id, directory)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const list = await sdk.session.messages({ sessionID: id, limit: 50 }).then((x) => x.data ?? [])
|
||||
const msg = list.findLast(
|
||||
(item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === directory,
|
||||
)
|
||||
if (!msg) return
|
||||
|
||||
const part = msg.parts
|
||||
.filter(isBash)
|
||||
.find((item) => item.state.input?.command === cmd && item.state.status === "completed")
|
||||
|
||||
if (!part || part.state.status !== "completed") return
|
||||
const output =
|
||||
typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
|
||||
if (!output.includes("README.md")) return
|
||||
|
||||
return { cwd: directory, output }
|
||||
},
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
.toEqual(expect.objectContaining({ cwd: directory, output: expect.stringContaining("README.md") }))
|
||||
|
||||
await expect(prompt).toHaveText("")
|
||||
})
|
||||
})
|
||||
@@ -1,64 +0,0 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { withSession } from "../actions"
|
||||
|
||||
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
|
||||
|
||||
async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
|
||||
await sdk.session.promptAsync({
|
||||
sessionID,
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "e2e share seed" }],
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
|
||||
return messages.length
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
test("/share and /unshare update session share state", async ({ page, sdk, gotoSession }) => {
|
||||
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
||||
|
||||
await withSession(sdk, `e2e slash share ${Date.now()}`, async (session) => {
|
||||
const prompt = page.locator(promptSelector)
|
||||
|
||||
await seed(sdk, session.id)
|
||||
await gotoSession(session.id)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type("/share")
|
||||
await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.share?.url || undefined
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.not.toBeUndefined()
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type("/unshare")
|
||||
await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.share?.url || undefined
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,4 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { waitTerminalReady } from "../actions"
|
||||
import { promptSelector, terminalSelector } from "../selectors"
|
||||
|
||||
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
|
||||
@@ -7,29 +6,18 @@ test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
const terminal = page.locator(terminalSelector)
|
||||
const slash = page.locator('[data-slash-id="terminal.toggle"]').first()
|
||||
|
||||
await expect(terminal).not.toBeVisible()
|
||||
|
||||
await prompt.fill("/terminal")
|
||||
await expect(slash).toBeVisible()
|
||||
await prompt.click()
|
||||
await page.keyboard.type("/terminal")
|
||||
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
await waitTerminalReady(page, { term: terminal })
|
||||
await expect(terminal).toBeVisible()
|
||||
|
||||
// Terminal panel retries focus (immediate, RAF, 120ms, 240ms) after opening,
|
||||
// which can steal focus from the prompt and prevent fill() from triggering
|
||||
// the slash popover. Re-attempt click+fill until all retries are exhausted
|
||||
// and the popover appears.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await prompt.click().catch(() => false)
|
||||
await prompt.fill("/terminal").catch(() => false)
|
||||
return slash.isVisible().catch(() => false)
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
await prompt.click()
|
||||
await page.keyboard.type("/terminal")
|
||||
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(terminal).not.toBeVisible()
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
|
||||
import { sessionIDFromUrl, withSession } from "../actions"
|
||||
|
||||
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
|
||||
test.setTimeout(120_000)
|
||||
@@ -46,7 +46,7 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession })
|
||||
.toContain(token)
|
||||
} finally {
|
||||
page.off("pageerror", onPageError)
|
||||
await cleanupSession({ sdk, sessionID })
|
||||
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
||||
}
|
||||
|
||||
if (pageErrors.length > 0) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export const promptSelector = '[data-component="prompt-input"]'
|
||||
export const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]'
|
||||
export const terminalSelector = `${terminalPanelSelector} [data-component="terminal"]`
|
||||
export const terminalSelector = '[data-component="terminal"]'
|
||||
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
|
||||
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
|
||||
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
|
||||
@@ -31,6 +30,8 @@ export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
|
||||
export const projectSwitchSelector = (slug: string) =>
|
||||
`${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) =>
|
||||
`${sidebarNavSelector} [data-action="project-menu"][data-project="${slug}"]`
|
||||
|
||||
@@ -52,6 +53,8 @@ export const dropdownMenuContentSelector = '[data-component="dropdown-menu-conte
|
||||
|
||||
export const inlineInputSelector = '[data-component="inline-input"]'
|
||||
|
||||
export const sessionHeaderSelector = "[data-session-title]"
|
||||
|
||||
export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
|
||||
|
||||
export const workspaceItemSelector = (slug: string) =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { cleanupSession, clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
|
||||
import { clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
|
||||
import {
|
||||
permissionDockSelector,
|
||||
promptSelector,
|
||||
@@ -26,7 +26,7 @@ async function withDockSession<T>(
|
||||
try {
|
||||
return await fn(session)
|
||||
} finally {
|
||||
await cleanupSession({ sdk, sessionID: session.id })
|
||||
await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,7 +311,7 @@ test("child session question request blocks parent dock and unblocks after submi
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
})
|
||||
} finally {
|
||||
await cleanupSession({ sdk, sessionID: child.id })
|
||||
await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -358,7 +358,7 @@ test("child session permission request blocks parent dock and supports allow onc
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
await cleanupSession({ sdk, sessionID: child.id })
|
||||
await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
import { waitSessionIdle, withSession } from "../actions"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { createSdk } from "../utils"
|
||||
|
||||
const count = 14
|
||||
|
||||
function body(mark: string) {
|
||||
return [
|
||||
`title ${mark}`,
|
||||
`mark ${mark}`,
|
||||
...Array.from({ length: 32 }, (_, i) => `line ${String(i + 1).padStart(2, "0")} ${mark}`),
|
||||
]
|
||||
}
|
||||
|
||||
function files(tag: string) {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const id = String(i).padStart(2, "0")
|
||||
return {
|
||||
file: `review-scroll-${id}.txt`,
|
||||
mark: `${tag}-${id}`,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function seed(list: ReturnType<typeof files>) {
|
||||
const out = ["*** Begin Patch"]
|
||||
|
||||
for (const item of list) {
|
||||
out.push(`*** Add File: ${item.file}`)
|
||||
for (const line of body(item.mark)) out.push(`+${line}`)
|
||||
}
|
||||
|
||||
out.push("*** End Patch")
|
||||
return out.join("\n")
|
||||
}
|
||||
|
||||
function edit(file: string, prev: string, next: string) {
|
||||
return ["*** Begin Patch", `*** Update File: ${file}`, "@@", `-mark ${prev}`, `+mark ${next}`, "*** End Patch"].join(
|
||||
"\n",
|
||||
)
|
||||
}
|
||||
|
||||
async function patch(sdk: ReturnType<typeof createSdk>, sessionID: string, patchText: string) {
|
||||
await sdk.session.promptAsync({
|
||||
sessionID,
|
||||
agent: "build",
|
||||
system: [
|
||||
"You are seeding deterministic e2e UI state.",
|
||||
"Your only valid response is one apply_patch tool call.",
|
||||
`Use this JSON input: ${JSON.stringify({ patchText })}`,
|
||||
"Do not call any other tools.",
|
||||
"Do not output plain text.",
|
||||
].join("\n"),
|
||||
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
|
||||
})
|
||||
|
||||
await waitSessionIdle(sdk, sessionID, 120_000)
|
||||
}
|
||||
|
||||
async function show(page: Parameters<typeof test>[0]["page"]) {
|
||||
const btn = page.getByRole("button", { name: "Toggle review" }).first()
|
||||
await expect(btn).toBeVisible()
|
||||
if ((await btn.getAttribute("aria-expanded")) !== "true") await btn.click()
|
||||
await expect(btn).toHaveAttribute("aria-expanded", "true")
|
||||
}
|
||||
|
||||
async function expand(page: Parameters<typeof test>[0]["page"]) {
|
||||
const close = page.getByRole("button", { name: /^Collapse all$/i }).first()
|
||||
const open = await close
|
||||
.isVisible()
|
||||
.then((value) => value)
|
||||
.catch(() => false)
|
||||
|
||||
const btn = page.getByRole("button", { name: /^Expand all$/i }).first()
|
||||
if (open) {
|
||||
await close.click()
|
||||
await expect(btn).toBeVisible()
|
||||
}
|
||||
|
||||
await expect(btn).toBeVisible()
|
||||
await btn.click()
|
||||
await expect(close).toBeVisible()
|
||||
}
|
||||
|
||||
async function waitMark(page: Parameters<typeof test>[0]["page"], file: string, mark: string) {
|
||||
await page.waitForFunction(
|
||||
({ file, mark }) => {
|
||||
const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport')
|
||||
if (!(view instanceof HTMLElement)) return false
|
||||
|
||||
const head = Array.from(view.querySelectorAll("h3")).find(
|
||||
(node) => node instanceof HTMLElement && node.textContent?.includes(file),
|
||||
)
|
||||
if (!(head instanceof HTMLElement)) return false
|
||||
|
||||
return Array.from(head.parentElement?.querySelectorAll("diffs-container") ?? []).some((host) => {
|
||||
if (!(host instanceof HTMLElement)) return false
|
||||
const root = host.shadowRoot
|
||||
return root?.textContent?.includes(`mark ${mark}`) ?? false
|
||||
})
|
||||
},
|
||||
{ file, mark },
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
}
|
||||
|
||||
async function spot(page: Parameters<typeof test>[0]["page"], file: string) {
|
||||
return page.evaluate((file) => {
|
||||
const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport')
|
||||
if (!(view instanceof HTMLElement)) return null
|
||||
|
||||
const row = Array.from(view.querySelectorAll("h3")).find(
|
||||
(node) => node instanceof HTMLElement && node.textContent?.includes(file),
|
||||
)
|
||||
if (!(row instanceof HTMLElement)) return null
|
||||
|
||||
const a = row.getBoundingClientRect()
|
||||
const b = view.getBoundingClientRect()
|
||||
return {
|
||||
top: a.top - b.top,
|
||||
y: view.scrollTop,
|
||||
}
|
||||
}, file)
|
||||
}
|
||||
|
||||
test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
|
||||
test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
|
||||
test.setTimeout(180_000)
|
||||
|
||||
const tag = `review-${Date.now()}`
|
||||
const list = files(tag)
|
||||
const hit = list[list.length - 4]!
|
||||
const next = `${tag}-live`
|
||||
|
||||
await page.setViewportSize({ width: 1600, height: 1000 })
|
||||
|
||||
await withProject(async (project) => {
|
||||
const sdk = createSdk(project.directory)
|
||||
|
||||
await withSession(sdk, `e2e review ${tag}`, async (session) => {
|
||||
await patch(sdk, session.id, seed(list))
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const info = await sdk.session.get({ sessionID: session.id }).then((res) => res.data)
|
||||
return info?.summary?.files ?? 0
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(list.length)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||
return diff.length
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(list.length)
|
||||
|
||||
await project.gotoSession(session.id)
|
||||
await show(page)
|
||||
|
||||
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
||||
await expect(tab).toBeVisible()
|
||||
await tab.click()
|
||||
|
||||
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
|
||||
await expect(view).toBeVisible()
|
||||
const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
|
||||
await expect(heads).toHaveCount(list.length, {
|
||||
timeout: 60_000,
|
||||
})
|
||||
|
||||
await expand(page)
|
||||
await waitMark(page, hit.file, hit.mark)
|
||||
|
||||
const row = page
|
||||
.getByRole("heading", { level: 3, name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) })
|
||||
.first()
|
||||
await expect(row).toBeVisible()
|
||||
await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
|
||||
|
||||
await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200)
|
||||
const prev = await spot(page, hit.file)
|
||||
if (!prev) throw new Error(`missing review row for ${hit.file}`)
|
||||
|
||||
await patch(sdk, session.id, edit(hit.file, hit.mark, next))
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||
const item = diff.find((item) => item.file === hit.file)
|
||||
return typeof item?.after === "string" ? item.after : ""
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toContain(`mark ${next}`)
|
||||
|
||||
await waitMark(page, hit.file, next)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const next = await spot(page, hit.file)
|
||||
if (!next) return Number.POSITIVE_INFINITY
|
||||
return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y))
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBeLessThanOrEqual(32)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -45,7 +45,7 @@ async function seedConversation(input: {
|
||||
.toBe(true)
|
||||
|
||||
if (!userMessageID) throw new Error("Expected a user message id")
|
||||
await expect(input.page.locator(`[data-message-id="${userMessageID}"]`)).toHaveCount(1, { timeout: 30_000 })
|
||||
await expect(input.page.locator(`[data-message-id="${userMessageID}"]`).first()).toBeVisible({ timeout: 30_000 })
|
||||
return { prompt, userMessageID }
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ test("slash redo clears revert and restores latest state", async ({ page, withPr
|
||||
.toBeUndefined()
|
||||
|
||||
await expect(seeded.prompt).not.toContainText(token)
|
||||
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1)
|
||||
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`).first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`)
|
||||
|
||||
await expect(firstMessage).toHaveCount(1)
|
||||
await expect(secondMessage).toHaveCount(1)
|
||||
await expect(firstMessage.first()).toBeVisible()
|
||||
await expect(secondMessage.first()).toBeVisible()
|
||||
|
||||
await second.prompt.click()
|
||||
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)
|
||||
|
||||
await expect(firstMessage).toHaveCount(1)
|
||||
await expect(firstMessage.first()).toBeVisible()
|
||||
await expect(secondMessage).toHaveCount(0)
|
||||
|
||||
await second.prompt.click()
|
||||
@@ -210,7 +210,7 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
|
||||
})
|
||||
.toBe(second.userMessageID)
|
||||
|
||||
await expect(firstMessage).toHaveCount(1)
|
||||
await expect(firstMessage.first()).toBeVisible()
|
||||
await expect(secondMessage).toHaveCount(0)
|
||||
|
||||
await second.prompt.click()
|
||||
@@ -226,8 +226,8 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
|
||||
})
|
||||
.toBeUndefined()
|
||||
|
||||
await expect(firstMessage).toHaveCount(1)
|
||||
await expect(secondMessage).toHaveCount(1)
|
||||
await expect(firstMessage.first()).toBeVisible()
|
||||
await expect(secondMessage.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
openSharePopover,
|
||||
withSession,
|
||||
} from "../actions"
|
||||
import { sessionItemSelector, inlineInputSelector } from "../selectors"
|
||||
import { sessionHeaderSelector, sessionItemSelector, inlineInputSelector } from "../selectors"
|
||||
|
||||
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
|
||||
|
||||
@@ -44,7 +44,7 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
await clickMenuItem(menu, /rename/i)
|
||||
|
||||
const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
|
||||
const input = page.locator(sessionHeaderSelector).locator(inlineInputSelector).first()
|
||||
await expect(input).toBeVisible()
|
||||
await expect(input).toBeFocused()
|
||||
await input.fill(renamedTitle)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openSettings, closeDialog, waitTerminalReady, withSession } from "../actions"
|
||||
import { openSettings, closeDialog, withSession } from "../actions"
|
||||
import { keybindButtonSelector, terminalSelector } from "../selectors"
|
||||
import { modKey } from "../utils"
|
||||
|
||||
@@ -32,19 +32,22 @@ test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
|
||||
|
||||
await closeDialog(page, dialog)
|
||||
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
const initiallyClosed = (await button.getAttribute("aria-expanded")) !== "true"
|
||||
const main = page.locator("main")
|
||||
const initialClasses = (await main.getAttribute("class")) ?? ""
|
||||
const initiallyClosed = initialClasses.includes("xl:border-l")
|
||||
|
||||
await page.keyboard.press(`${modKey}+Shift+H`)
|
||||
await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "true" : "false")
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const afterToggleClosed = (await button.getAttribute("aria-expanded")) !== "true"
|
||||
const afterToggleClasses = (await main.getAttribute("class")) ?? ""
|
||||
const afterToggleClosed = afterToggleClasses.includes("xl:border-l")
|
||||
expect(afterToggleClosed).toBe(!initiallyClosed)
|
||||
|
||||
await page.keyboard.press(`${modKey}+Shift+H`)
|
||||
await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "false" : "true")
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const finalClosed = (await button.getAttribute("aria-expanded")) !== "true"
|
||||
const finalClasses = (await main.getAttribute("class")) ?? ""
|
||||
const finalClosed = finalClasses.includes("xl:border-l")
|
||||
expect(finalClosed).toBe(initiallyClosed)
|
||||
})
|
||||
|
||||
@@ -302,7 +305,7 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) =>
|
||||
await expect(terminal).not.toBeVisible()
|
||||
|
||||
await page.keyboard.press(`${modKey}+Y`)
|
||||
await waitTerminalReady(page, { term: terminal })
|
||||
await expect(terminal).toBeVisible()
|
||||
|
||||
await page.keyboard.press(`${modKey}+Y`)
|
||||
await expect(terminal).not.toBeVisible()
|
||||
|
||||
@@ -83,23 +83,16 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
|
||||
const select = dialog.locator(settingsThemeSelector)
|
||||
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()
|
||||
|
||||
const items = page.locator('[data-slot="select-select-item"]')
|
||||
const count = await items.count()
|
||||
expect(count).toBeGreaterThan(1)
|
||||
|
||||
const nextTheme = (await items.locator('[data-slot="select-select-item-label"]').allTextContents())
|
||||
.map((x) => x.trim())
|
||||
.find((x) => x && x !== currentTheme)
|
||||
expect(nextTheme).toBeTruthy()
|
||||
const firstTheme = await items.nth(1).locator('[data-slot="select-select-item-label"]').textContent()
|
||||
expect(firstTheme).toBeTruthy()
|
||||
|
||||
await items.filter({ hasText: nextTheme! }).first().click()
|
||||
await items.nth(1).click()
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
|
||||
@@ -108,7 +101,7 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
|
||||
})
|
||||
|
||||
expect(storedThemeId).not.toBeNull()
|
||||
expect(storedThemeId).not.toBe(currentThemeId)
|
||||
expect(storedThemeId).not.toBe("oc-1")
|
||||
|
||||
const dataTheme = await page.evaluate(() => {
|
||||
return document.documentElement.getAttribute("data-theme")
|
||||
@@ -116,42 +109,6 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
|
||||
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 }) => {
|
||||
await gotoSession()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { cleanupSession, closeSidebar, hoverSessionItem } from "../actions"
|
||||
import { projectSwitchSelector } from "../selectors"
|
||||
import { closeSidebar, hoverSessionItem } from "../actions"
|
||||
import { projectSwitchSelector, sessionItemSelector } from "../selectors"
|
||||
|
||||
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
|
||||
const stamp = Date.now()
|
||||
@@ -15,15 +15,12 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
|
||||
await gotoSession(one.id)
|
||||
await closeSidebar(page)
|
||||
|
||||
const oneItem = page.locator(`[data-session-id="${one.id}"]`).last()
|
||||
const twoItem = page.locator(`[data-session-id="${two.id}"]`).last()
|
||||
|
||||
const project = page.locator(projectSwitchSelector(slug)).first()
|
||||
await expect(project).toBeVisible()
|
||||
await project.hover()
|
||||
|
||||
await expect(oneItem).toBeVisible()
|
||||
await expect(twoItem).toBeVisible()
|
||||
await expect(page.locator(sessionItemSelector(one.id)).first()).toBeVisible()
|
||||
await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
|
||||
|
||||
const item = await hoverSessionItem(page, one.id)
|
||||
await item
|
||||
@@ -31,9 +28,9 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
|
||||
.first()
|
||||
.click()
|
||||
|
||||
await expect(twoItem).toBeVisible()
|
||||
await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
|
||||
} finally {
|
||||
await cleanupSession({ sdk, sessionID: one.id })
|
||||
await cleanupSession({ sdk, sessionID: two.id })
|
||||
await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
|
||||
await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { cleanupSession, openSidebar, withSession } from "../actions"
|
||||
import { openSidebar, withSession } from "../actions"
|
||||
import { promptSelector } from "../selectors"
|
||||
|
||||
test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => {
|
||||
@@ -18,13 +18,14 @@ test("sidebar session links navigate to the selected session", async ({ page, sl
|
||||
|
||||
const target = page.locator(`[data-session-id="${two.id}"] a`).first()
|
||||
await expect(target).toBeVisible()
|
||||
await target.scrollIntoViewIfNeeded()
|
||||
await target.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await expect(page.locator(`[data-session-id="${two.id}"] a`).first()).toHaveClass(/\bactive\b/)
|
||||
} finally {
|
||||
await cleanupSession({ sdk, sessionID: one.id })
|
||||
await cleanupSession({ sdk, sessionID: two.id })
|
||||
await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
|
||||
await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5,14 +5,12 @@ test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
await openSidebar(page)
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
await expect(button).toHaveAttribute("aria-expanded", "true")
|
||||
|
||||
await toggleSidebar(page)
|
||||
await expect(button).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
||||
|
||||
await toggleSidebar(page)
|
||||
await expect(button).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
|
||||
})
|
||||
|
||||
test("sidebar collapsed state persists across navigation and reload", async ({ page, sdk, gotoSession }) => {
|
||||
@@ -21,15 +19,14 @@ test("sidebar collapsed state persists across navigation and reload", async ({ p
|
||||
await gotoSession(session1.id)
|
||||
|
||||
await openSidebar(page)
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
await toggleSidebar(page)
|
||||
await expect(button).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
||||
|
||||
await gotoSession(session2.id)
|
||||
await expect(button).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
||||
|
||||
await page.reload()
|
||||
await expect(button).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
||||
|
||||
const opened = await page.evaluate(
|
||||
() => JSON.parse(localStorage.getItem("opencode.global.dat:layout") ?? "{}").sidebar?.opened,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { waitTerminalReady } from "../actions"
|
||||
import { promptSelector, terminalSelector } from "../selectors"
|
||||
import { terminalToggleKey } from "../utils"
|
||||
|
||||
@@ -14,7 +13,8 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
|
||||
await page.keyboard.press(terminalToggleKey)
|
||||
}
|
||||
|
||||
await waitTerminalReady(page, { term: terminals.first() })
|
||||
await expect(terminals.first()).toBeVisible()
|
||||
await expect(terminals.first().locator("textarea")).toHaveCount(1)
|
||||
await expect(terminals).toHaveCount(1)
|
||||
|
||||
// Ghostty captures a lot of keybinds when focused; move focus back
|
||||
@@ -24,5 +24,5 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
|
||||
|
||||
await expect(tabs).toHaveCount(2)
|
||||
await expect(terminals).toHaveCount(1)
|
||||
await waitTerminalReady(page, { term: terminals.first() })
|
||||
await expect(terminals.first().locator("textarea")).toHaveCount(1)
|
||||
})
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { runTerminal, waitTerminalReady } from "../actions"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { terminalSelector } from "../selectors"
|
||||
import { terminalToggleKey, workspacePersistKey } from "../utils"
|
||||
|
||||
type State = {
|
||||
active?: string
|
||||
all: Array<{
|
||||
id: string
|
||||
title: string
|
||||
titleNumber: number
|
||||
buffer?: string
|
||||
}>
|
||||
}
|
||||
|
||||
async function open(page: Page) {
|
||||
const terminal = page.locator(terminalSelector)
|
||||
const visible = await terminal.isVisible().catch(() => false)
|
||||
if (!visible) await page.keyboard.press(terminalToggleKey)
|
||||
await waitTerminalReady(page, { term: terminal })
|
||||
}
|
||||
|
||||
async function store(page: Page, key: string) {
|
||||
return page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (raw) return JSON.parse(raw) as State
|
||||
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const next = localStorage.key(i)
|
||||
if (!next?.endsWith(":workspace:terminal")) continue
|
||||
const value = localStorage.getItem(next)
|
||||
if (!value) continue
|
||||
return JSON.parse(value) as State
|
||||
}
|
||||
}, key)
|
||||
}
|
||||
|
||||
test("inactive terminal tab buffers persist across tab switches", async ({ page, withProject }) => {
|
||||
await withProject(async ({ directory, gotoSession }) => {
|
||||
const key = workspacePersistKey(directory, "terminal")
|
||||
const one = `E2E_TERM_ONE_${Date.now()}`
|
||||
const two = `E2E_TERM_TWO_${Date.now()}`
|
||||
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
||||
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
||||
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
||||
|
||||
await gotoSession()
|
||||
await open(page)
|
||||
|
||||
await runTerminal(page, { cmd: `echo ${one}`, token: one })
|
||||
|
||||
await page.getByRole("button", { name: /new terminal/i }).click()
|
||||
await expect(tabs).toHaveCount(2)
|
||||
|
||||
await runTerminal(page, { cmd: `echo ${two}`, token: two })
|
||||
|
||||
await first.click()
|
||||
await expect(first).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const state = await store(page, key)
|
||||
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
||||
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
|
||||
return {
|
||||
first: first.includes(one),
|
||||
second: second.includes(two),
|
||||
}
|
||||
},
|
||||
{ timeout: 5_000 },
|
||||
)
|
||||
.toEqual({ first: false, second: true })
|
||||
|
||||
await second.click()
|
||||
await expect(second).toHaveAttribute("aria-selected", "true")
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const state = await store(page, key)
|
||||
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
||||
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
|
||||
return {
|
||||
first: first.includes(one),
|
||||
second: second.includes(two),
|
||||
}
|
||||
},
|
||||
{ timeout: 5_000 },
|
||||
)
|
||||
.toEqual({ first: true, second: false })
|
||||
})
|
||||
})
|
||||
|
||||
test("closing the active terminal tab falls back to the previous tab", async ({ page, withProject }) => {
|
||||
await withProject(async ({ directory, gotoSession }) => {
|
||||
const key = workspacePersistKey(directory, "terminal")
|
||||
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
||||
|
||||
await gotoSession()
|
||||
await open(page)
|
||||
|
||||
await page.getByRole("button", { name: /new terminal/i }).click()
|
||||
await expect(tabs).toHaveCount(2)
|
||||
|
||||
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
||||
await second.click()
|
||||
await expect(second).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
await second.hover()
|
||||
await page
|
||||
.getByRole("button", { name: /close terminal/i })
|
||||
.nth(1)
|
||||
.click({ force: true })
|
||||
|
||||
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
||||
await expect(tabs).toHaveCount(1)
|
||||
await expect(first).toHaveAttribute("aria-selected", "true")
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const state = await store(page, key)
|
||||
return {
|
||||
count: state?.all.length ?? 0,
|
||||
first: state?.all.some((item) => item.titleNumber === 1) ?? false,
|
||||
}
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toEqual({ count: 1, first: true })
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,4 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { waitTerminalReady } from "../actions"
|
||||
import { terminalSelector } from "../selectors"
|
||||
import { terminalToggleKey } from "../utils"
|
||||
|
||||
@@ -14,5 +13,5 @@ test("terminal panel can be toggled", async ({ page, gotoSession }) => {
|
||||
}
|
||||
|
||||
await page.keyboard.press(terminalToggleKey)
|
||||
await waitTerminalReady(page, { term: terminal })
|
||||
await expect(terminal).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { base64Encode, checksum } from "@opencode-ai/util/encode"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
|
||||
export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
|
||||
export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
@@ -7,22 +7,6 @@ export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
export const serverUrl = `http://${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 terminalToggleKey = "Control+Backquote"
|
||||
|
||||
@@ -30,12 +14,6 @@ export function createSdk(directory?: string) {
|
||||
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
|
||||
}
|
||||
|
||||
export async function resolveDirectory(directory: string) {
|
||||
return createSdk(directory)
|
||||
.path.get()
|
||||
.then((x) => x.data?.directory ?? directory)
|
||||
}
|
||||
|
||||
export async function getWorktree() {
|
||||
const sdk = createSdk()
|
||||
const result = await sdk.path.get()
|
||||
@@ -55,9 +33,3 @@ export function dirPath(directory: string) {
|
||||
export function sessionPath(directory: string, sessionID?: string) {
|
||||
return `${dirPath(directory)}/session${sessionID ? `/${sessionID}` : ""}`
|
||||
}
|
||||
|
||||
export function workspacePersistKey(directory: string, key: string) {
|
||||
const head = (directory.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
const sum = checksum(directory) ?? "0"
|
||||
return `opencode.workspace.${head}.${sum}.dat:workspace:${key}`
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.24",
|
||||
"version": "1.2.20",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -45,8 +45,8 @@
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
"@solid-primitives/active-element": "2.1.3",
|
||||
"@solid-primitives/audio": "1.4.2",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"@solid-primitives/i18n": "2.2.1",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"@solid-primitives/media": "2.3.3",
|
||||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
"@solid-primitives/scroll": "2.1.3",
|
||||
@@ -56,7 +56,6 @@
|
||||
"@solidjs/router": "catalog:",
|
||||
"@thisbeyond/solid-dnd": "0.7.5",
|
||||
"diff": "catalog:",
|
||||
"effect": "4.0.0-beta.31",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#main",
|
||||
"luxon": "catalog:",
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
;(function () {
|
||||
var key = "opencode-theme-id"
|
||||
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 themeId = localStorage.getItem("opencode-theme-id")
|
||||
if (!themeId) return
|
||||
|
||||
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
|
||||
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
|
||||
@@ -16,9 +9,9 @@
|
||||
document.documentElement.dataset.theme = themeId
|
||||
document.documentElement.dataset.colorScheme = mode
|
||||
|
||||
if (themeId === "oc-2") return
|
||||
if (themeId === "oc-1") return
|
||||
|
||||
var css = localStorage.getItem("opencode-theme-css-" + mode)
|
||||
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
|
||||
if (css) {
|
||||
var style = document.createElement("style")
|
||||
style.id = "oc-theme-preload"
|
||||
|
||||
@@ -1,29 +1,14 @@
|
||||
import "@/index.css"
|
||||
import { File } from "@opencode-ai/ui/file"
|
||||
import { I18nProvider } from "@opencode-ai/ui/context"
|
||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
||||
import { File } from "@opencode-ai/ui/file"
|
||||
import { Font } from "@opencode-ai/ui/font"
|
||||
import { Splash } from "@opencode-ai/ui/logo"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
|
||||
import { type Duration, Effect } from "effect"
|
||||
import {
|
||||
type Component,
|
||||
createResource,
|
||||
createSignal,
|
||||
ErrorBoundary,
|
||||
For,
|
||||
type JSX,
|
||||
lazy,
|
||||
onCleanup,
|
||||
type ParentProps,
|
||||
Show,
|
||||
Suspense,
|
||||
} from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
|
||||
import { Component, ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
|
||||
import { CommandProvider } from "@/context/command"
|
||||
import { CommentsProvider } from "@/context/comments"
|
||||
import { FileProvider } from "@/context/file"
|
||||
@@ -37,13 +22,13 @@ import { NotificationProvider } from "@/context/notification"
|
||||
import { PermissionProvider } from "@/context/permission"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { PromptProvider } from "@/context/prompt"
|
||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||
import { type ServerConnection, ServerProvider, useServer } from "@/context/server"
|
||||
import { SettingsProvider } from "@/context/settings"
|
||||
import { TerminalProvider } from "@/context/terminal"
|
||||
import DirectoryLayout from "@/pages/directory-layout"
|
||||
import Layout from "@/pages/layout"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { useCheckServerHealth } from "./utils/server-health"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
|
||||
const Home = lazy(() => import("@/pages/home"))
|
||||
const Session = lazy(() => import("@/pages/session"))
|
||||
@@ -77,9 +62,6 @@ declare global {
|
||||
deepLinks?: string[]
|
||||
wsl?: boolean
|
||||
}
|
||||
api?: {
|
||||
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,11 +115,7 @@ export function AppBaseProviders(props: ParentProps) {
|
||||
return (
|
||||
<MetaProvider>
|
||||
<Font />
|
||||
<ThemeProvider
|
||||
onThemeApplied={(_, mode) => {
|
||||
void window.api?.setTitlebar?.({ mode })
|
||||
}}
|
||||
>
|
||||
<ThemeProvider>
|
||||
<LanguageProvider>
|
||||
<UiI18nBridge>
|
||||
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
||||
@@ -154,108 +132,15 @@ export function AppBaseProviders(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
const effectMinDuration =
|
||||
(duration: Duration.Input) =>
|
||||
<A, E, R>(e: Effect.Effect<A, E, R>) =>
|
||||
Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0]))
|
||||
|
||||
function ConnectionGate(props: ParentProps) {
|
||||
function ServerKey(props: ParentProps) {
|
||||
const server = useServer()
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
|
||||
const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking")
|
||||
|
||||
// performs repeated health check with a grace period for
|
||||
// non-http connections, otherwise fails instantly
|
||||
const [startupHealthCheck, healthCheckActions] = createResource(() =>
|
||||
Effect.gen(function* () {
|
||||
if (!server.current) return true
|
||||
const { http, type } = server.current
|
||||
|
||||
while (true) {
|
||||
const res = yield* Effect.promise(() => checkServerHealth(http))
|
||||
if (res.healthy) return true
|
||||
if (checkMode() === "background" || type === "http") return false
|
||||
}
|
||||
}).pipe(
|
||||
effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
|
||||
Effect.timeoutOrElse({ duration: "10 seconds", onTimeout: () => Effect.succeed(false) }),
|
||||
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
|
||||
Effect.runPromise,
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
|
||||
fallback={
|
||||
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
|
||||
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={startupHealthCheck()}
|
||||
fallback={
|
||||
<ConnectionError
|
||||
onRetry={() => {
|
||||
if (checkMode() === "background") healthCheckActions.refetch()
|
||||
}}
|
||||
onServerSelected={(key) => {
|
||||
setCheckMode("blocking")
|
||||
server.setActive(key)
|
||||
healthCheckActions.refetch()
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</Show>
|
||||
<Show when={server.key} keyed>
|
||||
{props.children}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) {
|
||||
const server = useServer()
|
||||
const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key)
|
||||
|
||||
const timer = setInterval(() => props.onRetry?.(), 1000)
|
||||
onCleanup(() => clearInterval(timer))
|
||||
|
||||
return (
|
||||
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base gap-6 p-6">
|
||||
<div class="flex flex-col items-center max-w-md text-center">
|
||||
<Splash class="w-12 h-15 mb-4" />
|
||||
<p class="text-14-regular text-text-base">
|
||||
Could not reach <span class="text-text-strong font-medium">{server.name || server.key}</span>
|
||||
</p>
|
||||
<p class="mt-1 text-12-regular text-text-weak">Retrying automatically...</p>
|
||||
</div>
|
||||
<Show when={others().length > 0}>
|
||||
<div class="flex flex-col gap-2 w-full max-w-sm">
|
||||
<span class="text-12-regular text-text-base text-center">Other servers</span>
|
||||
<div class="flex flex-col gap-1 bg-surface-base rounded-lg p-2">
|
||||
<For each={others()}>
|
||||
{(conn) => {
|
||||
const key = ServerConnection.key(conn)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-3 w-full px-3 py-2 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
||||
onClick={() => props.onServerSelected?.(key)}
|
||||
>
|
||||
<span class="text-14-regular text-text-strong truncate">{serverName(conn)}</span>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppInterface(props: {
|
||||
children?: JSX.Element
|
||||
defaultServer: ServerConnection.Key
|
||||
@@ -264,7 +149,7 @@ export function AppInterface(props: {
|
||||
}) {
|
||||
return (
|
||||
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
|
||||
<ConnectionGate>
|
||||
<ServerKey>
|
||||
<GlobalSDKProvider>
|
||||
<GlobalSyncProvider>
|
||||
<Dynamic
|
||||
@@ -279,7 +164,7 @@ export function AppInterface(props: {
|
||||
</Dynamic>
|
||||
</GlobalSyncProvider>
|
||||
</GlobalSDKProvider>
|
||||
</ConnectionGate>
|
||||
</ServerKey>
|
||||
</ServerProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,441 +0,0 @@
|
||||
import { useIsRouting, useLocation } from "@solidjs/router"
|
||||
import { batch, createEffect, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
|
||||
type Mem = Performance & {
|
||||
memory?: {
|
||||
usedJSHeapSize: number
|
||||
jsHeapSizeLimit: number
|
||||
}
|
||||
}
|
||||
|
||||
type Evt = PerformanceEntry & {
|
||||
interactionId?: number
|
||||
processingStart?: number
|
||||
}
|
||||
|
||||
type Shift = PerformanceEntry & {
|
||||
hadRecentInput: boolean
|
||||
value: number
|
||||
}
|
||||
|
||||
type Obs = PerformanceObserverInit & {
|
||||
durationThreshold?: number
|
||||
}
|
||||
|
||||
const span = 5000
|
||||
|
||||
const ms = (n?: number, d = 0) => {
|
||||
if (n === undefined || Number.isNaN(n)) return "n/a"
|
||||
return `${n.toFixed(d)}ms`
|
||||
}
|
||||
|
||||
const time = (n?: number) => {
|
||||
if (n === undefined || Number.isNaN(n)) return "n/a"
|
||||
return `${Math.round(n)}`
|
||||
}
|
||||
|
||||
const mb = (n?: number) => {
|
||||
if (n === undefined || Number.isNaN(n)) return "n/a"
|
||||
const v = n / 1024 / 1024
|
||||
return `${v >= 1024 ? v.toFixed(0) : v.toFixed(1)}MB`
|
||||
}
|
||||
|
||||
const bad = (n: number | undefined, limit: number, low = false) => {
|
||||
if (n === undefined || Number.isNaN(n)) return false
|
||||
return low ? n < limit : n > limit
|
||||
}
|
||||
|
||||
const session = (path: string) => path.includes("/session")
|
||||
|
||||
function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string; value: string; wide?: boolean }) {
|
||||
return (
|
||||
<Tooltip value={props.tip} placement="top">
|
||||
<div
|
||||
classList={{
|
||||
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] bg-white/5 px-0.5 py-1 text-center": true,
|
||||
"col-span-2": !!props.wide,
|
||||
}}
|
||||
>
|
||||
<div class="text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70">{props.label}</div>
|
||||
<div
|
||||
classList={{
|
||||
"text-[13px] leading-none font-bold tabular-nums sm:text-[14px]": true,
|
||||
"text-text-on-critical-base": !!props.bad,
|
||||
"opacity-70": !!props.dim,
|
||||
}}
|
||||
>
|
||||
{props.value}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function DebugBar() {
|
||||
const location = useLocation()
|
||||
const routing = useIsRouting()
|
||||
const [state, setState] = createStore({
|
||||
cls: undefined as number | undefined,
|
||||
delay: undefined as number | undefined,
|
||||
fps: undefined as number | undefined,
|
||||
gap: undefined as number | undefined,
|
||||
heap: {
|
||||
limit: undefined as number | undefined,
|
||||
used: undefined as number | undefined,
|
||||
},
|
||||
inp: undefined as number | undefined,
|
||||
jank: undefined as number | undefined,
|
||||
long: {
|
||||
block: undefined as number | undefined,
|
||||
count: undefined as number | undefined,
|
||||
max: undefined as number | undefined,
|
||||
},
|
||||
nav: {
|
||||
dur: undefined as number | undefined,
|
||||
pending: false,
|
||||
},
|
||||
})
|
||||
|
||||
const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined)
|
||||
const heapv = () => {
|
||||
const value = heap()
|
||||
if (value === undefined) return "n/a"
|
||||
return `${Math.round(value * 100)}%`
|
||||
}
|
||||
const longv = () => (state.long.count === undefined ? "n/a" : `${time(state.long.block)}/${state.long.count}`)
|
||||
const navv = () => (state.nav.pending ? "..." : time(state.nav.dur))
|
||||
|
||||
let prev = ""
|
||||
let start = 0
|
||||
let init = false
|
||||
let one = 0
|
||||
let two = 0
|
||||
|
||||
createEffect(() => {
|
||||
const busy = routing()
|
||||
const next = `${location.pathname}${location.search}`
|
||||
|
||||
if (!init) {
|
||||
init = true
|
||||
prev = next
|
||||
return
|
||||
}
|
||||
|
||||
if (busy) {
|
||||
if (one !== 0) cancelAnimationFrame(one)
|
||||
if (two !== 0) cancelAnimationFrame(two)
|
||||
one = 0
|
||||
two = 0
|
||||
if (start !== 0) return
|
||||
start = performance.now()
|
||||
if (session(prev)) setState("nav", { dur: undefined, pending: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (start === 0) {
|
||||
prev = next
|
||||
return
|
||||
}
|
||||
|
||||
const at = start
|
||||
const from = prev
|
||||
start = 0
|
||||
prev = next
|
||||
|
||||
if (!(session(from) || session(next))) return
|
||||
|
||||
if (one !== 0) cancelAnimationFrame(one)
|
||||
if (two !== 0) cancelAnimationFrame(two)
|
||||
one = requestAnimationFrame(() => {
|
||||
one = 0
|
||||
two = requestAnimationFrame(() => {
|
||||
two = 0
|
||||
setState("nav", { dur: performance.now() - at, pending: false })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
const obs: PerformanceObserver[] = []
|
||||
const fps: Array<{ at: number; dur: number }> = []
|
||||
const long: Array<{ at: number; dur: number }> = []
|
||||
const seen = new Map<number | string, { at: number; delay: number; dur: number }>()
|
||||
let hasLong = false
|
||||
let poll: number | undefined
|
||||
let raf = 0
|
||||
let last = 0
|
||||
let snap = 0
|
||||
|
||||
const trim = (list: Array<{ at: number; dur: number }>, span: number, at: number) => {
|
||||
while (list[0] && at - list[0].at > span) list.shift()
|
||||
}
|
||||
|
||||
const syncFrame = (at: number) => {
|
||||
trim(fps, span, at)
|
||||
const total = fps.reduce((sum, entry) => sum + entry.dur, 0)
|
||||
const gap = fps.reduce((max, entry) => Math.max(max, entry.dur), 0)
|
||||
const jank = fps.filter((entry) => entry.dur > 32).length
|
||||
batch(() => {
|
||||
setState("fps", total > 0 ? (fps.length * 1000) / total : undefined)
|
||||
setState("gap", gap > 0 ? gap : undefined)
|
||||
setState("jank", jank)
|
||||
})
|
||||
}
|
||||
|
||||
const syncLong = (at = performance.now()) => {
|
||||
if (!hasLong) return
|
||||
trim(long, span, at)
|
||||
const block = long.reduce((sum, entry) => sum + Math.max(0, entry.dur - 50), 0)
|
||||
const max = long.reduce((hi, entry) => Math.max(hi, entry.dur), 0)
|
||||
setState("long", { block, count: long.length, max })
|
||||
}
|
||||
|
||||
const syncInp = (at = performance.now()) => {
|
||||
for (const [key, entry] of seen) {
|
||||
if (at - entry.at > span) seen.delete(key)
|
||||
}
|
||||
let delay = 0
|
||||
let inp = 0
|
||||
for (const entry of seen.values()) {
|
||||
delay = Math.max(delay, entry.delay)
|
||||
inp = Math.max(inp, entry.dur)
|
||||
}
|
||||
batch(() => {
|
||||
setState("delay", delay > 0 ? delay : undefined)
|
||||
setState("inp", inp > 0 ? inp : undefined)
|
||||
})
|
||||
}
|
||||
|
||||
const syncHeap = () => {
|
||||
const mem = (performance as Mem).memory
|
||||
if (!mem) return
|
||||
setState("heap", { limit: mem.jsHeapSizeLimit, used: mem.usedJSHeapSize })
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
fps.length = 0
|
||||
long.length = 0
|
||||
seen.clear()
|
||||
last = 0
|
||||
snap = 0
|
||||
batch(() => {
|
||||
setState("fps", undefined)
|
||||
setState("gap", undefined)
|
||||
setState("jank", undefined)
|
||||
setState("delay", undefined)
|
||||
setState("inp", undefined)
|
||||
if (hasLong) setState("long", { block: 0, count: 0, max: 0 })
|
||||
})
|
||||
}
|
||||
|
||||
const watch = (type: string, init: Obs, fn: (entries: PerformanceEntry[]) => void) => {
|
||||
if (typeof PerformanceObserver === "undefined") return false
|
||||
if (!(PerformanceObserver.supportedEntryTypes ?? []).includes(type)) return false
|
||||
const ob = new PerformanceObserver((list) => fn(list.getEntries()))
|
||||
try {
|
||||
ob.observe(init)
|
||||
obs.push(ob)
|
||||
return true
|
||||
} catch {
|
||||
ob.disconnect()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
watch("layout-shift", { buffered: true, type: "layout-shift" }, (entries) => {
|
||||
const add = entries.reduce((sum, entry) => {
|
||||
const item = entry as Shift
|
||||
if (item.hadRecentInput) return sum
|
||||
return sum + item.value
|
||||
}, 0)
|
||||
if (add === 0) return
|
||||
setState("cls", (value) => (value ?? 0) + add)
|
||||
})
|
||||
) {
|
||||
setState("cls", 0)
|
||||
}
|
||||
|
||||
if (
|
||||
watch("longtask", { buffered: true, type: "longtask" }, (entries) => {
|
||||
const at = performance.now()
|
||||
long.push(...entries.map((entry) => ({ at: entry.startTime, dur: entry.duration })))
|
||||
syncLong(at)
|
||||
})
|
||||
) {
|
||||
hasLong = true
|
||||
setState("long", { block: 0, count: 0, max: 0 })
|
||||
}
|
||||
|
||||
watch("event", { buffered: true, durationThreshold: 16, type: "event" }, (entries) => {
|
||||
for (const raw of entries) {
|
||||
const entry = raw as Evt
|
||||
if (entry.duration < 16) continue
|
||||
const key =
|
||||
entry.interactionId && entry.interactionId > 0
|
||||
? entry.interactionId
|
||||
: `${entry.name}:${Math.round(entry.startTime)}`
|
||||
const prev = seen.get(key)
|
||||
const delay = Math.max(0, (entry.processingStart ?? entry.startTime) - entry.startTime)
|
||||
seen.set(key, {
|
||||
at: entry.startTime,
|
||||
delay: Math.max(prev?.delay ?? 0, delay),
|
||||
dur: Math.max(prev?.dur ?? 0, entry.duration),
|
||||
})
|
||||
if (seen.size <= 200) continue
|
||||
const first = seen.keys().next().value
|
||||
if (first !== undefined) seen.delete(first)
|
||||
}
|
||||
syncInp()
|
||||
})
|
||||
|
||||
const loop = (at: number) => {
|
||||
if (document.visibilityState !== "visible") {
|
||||
raf = 0
|
||||
return
|
||||
}
|
||||
|
||||
if (last === 0) {
|
||||
last = at
|
||||
raf = requestAnimationFrame(loop)
|
||||
return
|
||||
}
|
||||
|
||||
fps.push({ at, dur: at - last })
|
||||
last = at
|
||||
|
||||
if (at - snap >= 250) {
|
||||
snap = at
|
||||
syncFrame(at)
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
if (raf !== 0) cancelAnimationFrame(raf)
|
||||
raf = 0
|
||||
if (poll === undefined) return
|
||||
clearInterval(poll)
|
||||
poll = undefined
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
if (document.visibilityState !== "visible") return
|
||||
if (poll === undefined) {
|
||||
poll = window.setInterval(() => {
|
||||
syncLong()
|
||||
syncInp()
|
||||
syncHeap()
|
||||
}, 1000)
|
||||
}
|
||||
if (raf !== 0) return
|
||||
raf = requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
const vis = () => {
|
||||
if (document.visibilityState !== "visible") {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
reset()
|
||||
start()
|
||||
}
|
||||
|
||||
syncHeap()
|
||||
start()
|
||||
document.addEventListener("visibilitychange", vis)
|
||||
|
||||
onCleanup(() => {
|
||||
if (one !== 0) cancelAnimationFrame(one)
|
||||
if (two !== 0) cancelAnimationFrame(two)
|
||||
stop()
|
||||
document.removeEventListener("visibilitychange", vis)
|
||||
for (const ob of obs) ob.disconnect()
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="Development performance diagnostics"
|
||||
class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border p-0.5 text-text-on-interactive-base shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
|
||||
style={{
|
||||
"background-color": "color-mix(in srgb, var(--icon-interactive-base) 42%, black)",
|
||||
"border-color": "color-mix(in srgb, white 14%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div class="grid grid-cols-5 gap-px font-mono">
|
||||
<Cell
|
||||
label="NAV"
|
||||
tip="Last completed route transition touching a session page, measured from router start until the first paint after it settles."
|
||||
value={navv()}
|
||||
bad={bad(state.nav.dur, 400)}
|
||||
dim={state.nav.dur === undefined && !state.nav.pending}
|
||||
/>
|
||||
<Cell
|
||||
label="FPS"
|
||||
tip="Rolling frames per second over the last 5 seconds."
|
||||
value={state.fps === undefined ? "n/a" : `${Math.round(state.fps)}`}
|
||||
bad={bad(state.fps, 50, true)}
|
||||
dim={state.fps === undefined}
|
||||
/>
|
||||
<Cell
|
||||
label="FRAME"
|
||||
tip="Worst frame time over the last 5 seconds."
|
||||
value={time(state.gap)}
|
||||
bad={bad(state.gap, 50)}
|
||||
dim={state.gap === undefined}
|
||||
/>
|
||||
<Cell
|
||||
label="JANK"
|
||||
tip="Frames over 32ms in the last 5 seconds."
|
||||
value={state.jank === undefined ? "n/a" : `${state.jank}`}
|
||||
bad={bad(state.jank, 8)}
|
||||
dim={state.jank === undefined}
|
||||
/>
|
||||
<Cell
|
||||
label="LONG"
|
||||
tip={`Blocked time and long-task count in the last 5 seconds. Max task: ${ms(state.long.max)}.`}
|
||||
value={longv()}
|
||||
bad={bad(state.long.block, 200)}
|
||||
dim={state.long.count === undefined}
|
||||
/>
|
||||
<Cell
|
||||
label="DELAY"
|
||||
tip="Worst observed input delay in the last 5 seconds."
|
||||
value={time(state.delay)}
|
||||
bad={bad(state.delay, 100)}
|
||||
dim={state.delay === undefined}
|
||||
/>
|
||||
<Cell
|
||||
label="INP"
|
||||
tip="Approximate interaction duration over the last 5 seconds. This is INP-like, not the official Web Vitals INP."
|
||||
value={time(state.inp)}
|
||||
bad={bad(state.inp, 200)}
|
||||
dim={state.inp === undefined}
|
||||
/>
|
||||
<Cell
|
||||
label="CLS"
|
||||
tip="Cumulative layout shift for the current app lifetime."
|
||||
value={state.cls === undefined ? "n/a" : state.cls.toFixed(2)}
|
||||
bad={bad(state.cls, 0.1)}
|
||||
dim={state.cls === undefined}
|
||||
/>
|
||||
<Cell
|
||||
label="MEM"
|
||||
tip={
|
||||
state.heap.used === undefined
|
||||
? "Used JS heap vs heap limit. Chromium only."
|
||||
: `Used JS heap vs heap limit. ${mb(state.heap.used)} of ${mb(state.heap.limit)}.`
|
||||
}
|
||||
value={heapv()}
|
||||
bad={bad(heap(), 0.8)}
|
||||
dim={state.heap.used === undefined}
|
||||
wide
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
|
||||
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
|
||||
|
||||
type Translator = (key: string, vars?: Record<string, string | number | boolean>) => string
|
||||
|
||||
export type ModelErr = {
|
||||
id?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export type HeaderErr = {
|
||||
key?: string
|
||||
value?: string
|
||||
}
|
||||
|
||||
export type ModelRow = {
|
||||
row: string
|
||||
id: string
|
||||
name: string
|
||||
err: ModelErr
|
||||
}
|
||||
|
||||
export type HeaderRow = {
|
||||
row: string
|
||||
key: string
|
||||
value: string
|
||||
err: HeaderErr
|
||||
}
|
||||
|
||||
export type FormState = {
|
||||
providerID: string
|
||||
name: string
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
models: ModelRow[]
|
||||
headers: HeaderRow[]
|
||||
saving: boolean
|
||||
err: {
|
||||
providerID?: string
|
||||
name?: string
|
||||
baseURL?: string
|
||||
}
|
||||
}
|
||||
|
||||
type ValidateArgs = {
|
||||
form: FormState
|
||||
t: Translator
|
||||
disabledProviders: string[]
|
||||
existingProviderIDs: Set<string>
|
||||
}
|
||||
|
||||
export function validateCustomProvider(input: ValidateArgs) {
|
||||
const providerID = input.form.providerID.trim()
|
||||
const name = input.form.name.trim()
|
||||
const baseURL = input.form.baseURL.trim()
|
||||
const apiKey = input.form.apiKey.trim()
|
||||
|
||||
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
|
||||
const key = apiKey && !env ? apiKey : undefined
|
||||
|
||||
const idError = !providerID
|
||||
? input.t("provider.custom.error.providerID.required")
|
||||
: !PROVIDER_ID.test(providerID)
|
||||
? input.t("provider.custom.error.providerID.format")
|
||||
: undefined
|
||||
|
||||
const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
|
||||
const urlError = !baseURL
|
||||
? input.t("provider.custom.error.baseURL.required")
|
||||
: !/^https?:\/\//.test(baseURL)
|
||||
? input.t("provider.custom.error.baseURL.format")
|
||||
: undefined
|
||||
|
||||
const disabled = input.disabledProviders.includes(providerID)
|
||||
const existsError = idError
|
||||
? undefined
|
||||
: input.existingProviderIDs.has(providerID) && !disabled
|
||||
? input.t("provider.custom.error.providerID.exists")
|
||||
: undefined
|
||||
|
||||
const seenModels = new Set<string>()
|
||||
const models = input.form.models.map((m) => {
|
||||
const id = m.id.trim()
|
||||
const idError = !id
|
||||
? input.t("provider.custom.error.required")
|
||||
: seenModels.has(id)
|
||||
? input.t("provider.custom.error.duplicate")
|
||||
: (() => {
|
||||
seenModels.add(id)
|
||||
return undefined
|
||||
})()
|
||||
const nameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
|
||||
return { id: idError, name: nameError }
|
||||
})
|
||||
const modelsValid = models.every((m) => !m.id && !m.name)
|
||||
const modelConfig = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
|
||||
|
||||
const seenHeaders = new Set<string>()
|
||||
const headers = input.form.headers.map((h) => {
|
||||
const key = h.key.trim()
|
||||
const value = h.value.trim()
|
||||
|
||||
if (!key && !value) return {}
|
||||
const keyError = !key
|
||||
? input.t("provider.custom.error.required")
|
||||
: seenHeaders.has(key.toLowerCase())
|
||||
? input.t("provider.custom.error.duplicate")
|
||||
: (() => {
|
||||
seenHeaders.add(key.toLowerCase())
|
||||
return undefined
|
||||
})()
|
||||
const valueError = !value ? input.t("provider.custom.error.required") : undefined
|
||||
return { key: keyError, value: valueError }
|
||||
})
|
||||
const headersValid = headers.every((h) => !h.key && !h.value)
|
||||
const headerConfig = Object.fromEntries(
|
||||
input.form.headers
|
||||
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
|
||||
.filter((h) => !!h.key && !!h.value)
|
||||
.map((h) => [h.key, h.value]),
|
||||
)
|
||||
|
||||
const err = {
|
||||
providerID: idError ?? existsError,
|
||||
name: nameError,
|
||||
baseURL: urlError,
|
||||
}
|
||||
|
||||
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
|
||||
if (!ok) return { err, models, headers }
|
||||
|
||||
return {
|
||||
err,
|
||||
models,
|
||||
headers,
|
||||
result: {
|
||||
providerID,
|
||||
name,
|
||||
key,
|
||||
config: {
|
||||
npm: OPENAI_COMPATIBLE,
|
||||
name,
|
||||
...(env ? { env: [env] } : {}),
|
||||
options: {
|
||||
baseURL,
|
||||
...(Object.keys(headerConfig).length ? { headers: headerConfig } : {}),
|
||||
},
|
||||
models: modelConfig,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let row = 0
|
||||
|
||||
const nextRow = () => `row-${row++}`
|
||||
|
||||
export const modelRow = (): ModelRow => ({ row: nextRow(), id: "", name: "", err: {} })
|
||||
export const headerRow = (): HeaderRow => ({ row: nextRow(), key: "", value: "", err: {} })
|
||||
@@ -1,82 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { validateCustomProvider } from "./dialog-custom-provider-form"
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
describe("validateCustomProvider", () => {
|
||||
test("builds trimmed config payload", () => {
|
||||
const result = validateCustomProvider({
|
||||
form: {
|
||||
providerID: "custom-provider",
|
||||
name: " Custom Provider ",
|
||||
baseURL: "https://api.example.com ",
|
||||
apiKey: " {env: CUSTOM_PROVIDER_KEY} ",
|
||||
models: [{ row: "m0", id: " model-a ", name: " Model A ", err: {} }],
|
||||
headers: [
|
||||
{ row: "h0", key: " X-Test ", value: " enabled ", err: {} },
|
||||
{ row: "h1", key: "", value: "", err: {} },
|
||||
],
|
||||
saving: false,
|
||||
err: {},
|
||||
},
|
||||
t,
|
||||
disabledProviders: [],
|
||||
existingProviderIDs: new Set(),
|
||||
})
|
||||
|
||||
expect(result.result).toEqual({
|
||||
providerID: "custom-provider",
|
||||
name: "Custom Provider",
|
||||
key: undefined,
|
||||
config: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "Custom Provider",
|
||||
env: ["CUSTOM_PROVIDER_KEY"],
|
||||
options: {
|
||||
baseURL: "https://api.example.com",
|
||||
headers: {
|
||||
"X-Test": "enabled",
|
||||
},
|
||||
},
|
||||
models: {
|
||||
"model-a": { name: "Model A" },
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("flags duplicate rows and allows reconnecting disabled providers", () => {
|
||||
const result = validateCustomProvider({
|
||||
form: {
|
||||
providerID: "custom-provider",
|
||||
name: "Provider",
|
||||
baseURL: "https://api.example.com",
|
||||
apiKey: "secret",
|
||||
models: [
|
||||
{ row: "m0", id: "model-a", name: "Model A", err: {} },
|
||||
{ row: "m1", id: "model-a", name: "Model A 2", err: {} },
|
||||
],
|
||||
headers: [
|
||||
{ row: "h0", key: "Authorization", value: "one", err: {} },
|
||||
{ row: "h1", key: "authorization", value: "two", err: {} },
|
||||
],
|
||||
saving: false,
|
||||
err: {},
|
||||
},
|
||||
t,
|
||||
disabledProviders: ["custom-provider"],
|
||||
existingProviderIDs: new Set(["custom-provider"]),
|
||||
})
|
||||
|
||||
expect(result.result).toBeUndefined()
|
||||
expect(result.err.providerID).toBeUndefined()
|
||||
expect(result.models[1]).toEqual({
|
||||
id: "provider.custom.error.duplicate",
|
||||
name: undefined,
|
||||
})
|
||||
expect(result.headers[1]).toEqual({
|
||||
key: "provider.custom.error.duplicate",
|
||||
value: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,15 +5,158 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { batch, For } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { For } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Link } from "@/components/link"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
|
||||
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
|
||||
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
|
||||
|
||||
type Translator = ReturnType<typeof useLanguage>["t"]
|
||||
|
||||
type ModelRow = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
type HeaderRow = {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
type FormState = {
|
||||
providerID: string
|
||||
name: string
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
models: ModelRow[]
|
||||
headers: HeaderRow[]
|
||||
saving: boolean
|
||||
}
|
||||
|
||||
type FormErrors = {
|
||||
providerID: string | undefined
|
||||
name: string | undefined
|
||||
baseURL: string | undefined
|
||||
models: Array<{ id?: string; name?: string }>
|
||||
headers: Array<{ key?: string; value?: string }>
|
||||
}
|
||||
|
||||
type ValidateArgs = {
|
||||
form: FormState
|
||||
t: Translator
|
||||
disabledProviders: string[]
|
||||
existingProviderIDs: Set<string>
|
||||
}
|
||||
|
||||
function validateCustomProvider(input: ValidateArgs) {
|
||||
const providerID = input.form.providerID.trim()
|
||||
const name = input.form.name.trim()
|
||||
const baseURL = input.form.baseURL.trim()
|
||||
const apiKey = input.form.apiKey.trim()
|
||||
|
||||
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
|
||||
const key = apiKey && !env ? apiKey : undefined
|
||||
|
||||
const idError = !providerID
|
||||
? input.t("provider.custom.error.providerID.required")
|
||||
: !PROVIDER_ID.test(providerID)
|
||||
? input.t("provider.custom.error.providerID.format")
|
||||
: undefined
|
||||
|
||||
const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
|
||||
const urlError = !baseURL
|
||||
? input.t("provider.custom.error.baseURL.required")
|
||||
: !/^https?:\/\//.test(baseURL)
|
||||
? input.t("provider.custom.error.baseURL.format")
|
||||
: undefined
|
||||
|
||||
const disabled = input.disabledProviders.includes(providerID)
|
||||
const existsError = idError
|
||||
? undefined
|
||||
: input.existingProviderIDs.has(providerID) && !disabled
|
||||
? input.t("provider.custom.error.providerID.exists")
|
||||
: undefined
|
||||
|
||||
const seenModels = new Set<string>()
|
||||
const modelErrors = input.form.models.map((m) => {
|
||||
const id = m.id.trim()
|
||||
const modelIdError = !id
|
||||
? input.t("provider.custom.error.required")
|
||||
: seenModels.has(id)
|
||||
? input.t("provider.custom.error.duplicate")
|
||||
: (() => {
|
||||
seenModels.add(id)
|
||||
return undefined
|
||||
})()
|
||||
const modelNameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
|
||||
return { id: modelIdError, name: modelNameError }
|
||||
})
|
||||
const modelsValid = modelErrors.every((m) => !m.id && !m.name)
|
||||
const models = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
|
||||
|
||||
const seenHeaders = new Set<string>()
|
||||
const headerErrors = input.form.headers.map((h) => {
|
||||
const key = h.key.trim()
|
||||
const value = h.value.trim()
|
||||
|
||||
if (!key && !value) return {}
|
||||
const keyError = !key
|
||||
? input.t("provider.custom.error.required")
|
||||
: seenHeaders.has(key.toLowerCase())
|
||||
? input.t("provider.custom.error.duplicate")
|
||||
: (() => {
|
||||
seenHeaders.add(key.toLowerCase())
|
||||
return undefined
|
||||
})()
|
||||
const valueError = !value ? input.t("provider.custom.error.required") : undefined
|
||||
return { key: keyError, value: valueError }
|
||||
})
|
||||
const headersValid = headerErrors.every((h) => !h.key && !h.value)
|
||||
const headers = Object.fromEntries(
|
||||
input.form.headers
|
||||
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
|
||||
.filter((h) => !!h.key && !!h.value)
|
||||
.map((h) => [h.key, h.value]),
|
||||
)
|
||||
|
||||
const errors: FormErrors = {
|
||||
providerID: idError ?? existsError,
|
||||
name: nameError,
|
||||
baseURL: urlError,
|
||||
models: modelErrors,
|
||||
headers: headerErrors,
|
||||
}
|
||||
|
||||
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
|
||||
if (!ok) return { errors }
|
||||
|
||||
const options = {
|
||||
baseURL,
|
||||
...(Object.keys(headers).length ? { headers } : {}),
|
||||
}
|
||||
|
||||
return {
|
||||
errors,
|
||||
result: {
|
||||
providerID,
|
||||
name,
|
||||
key,
|
||||
config: {
|
||||
npm: OPENAI_COMPATIBLE,
|
||||
name,
|
||||
...(env ? { env: [env] } : {}),
|
||||
options,
|
||||
models,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
back?: "providers" | "close"
|
||||
}
|
||||
@@ -29,10 +172,17 @@ export function DialogCustomProvider(props: Props) {
|
||||
name: "",
|
||||
baseURL: "",
|
||||
apiKey: "",
|
||||
models: [modelRow()],
|
||||
headers: [headerRow()],
|
||||
models: [{ id: "", name: "" }],
|
||||
headers: [{ key: "", value: "" }],
|
||||
saving: false,
|
||||
err: {},
|
||||
})
|
||||
|
||||
const [errors, setErrors] = createStore<FormErrors>({
|
||||
providerID: undefined,
|
||||
name: undefined,
|
||||
baseURL: undefined,
|
||||
models: [{}],
|
||||
headers: [{}],
|
||||
})
|
||||
|
||||
const goBack = () => {
|
||||
@@ -44,61 +194,25 @@ export function DialogCustomProvider(props: Props) {
|
||||
}
|
||||
|
||||
const addModel = () => {
|
||||
setForm(
|
||||
"models",
|
||||
produce((rows) => {
|
||||
rows.push(modelRow())
|
||||
}),
|
||||
)
|
||||
setForm("models", (v) => [...v, { id: "", name: "" }])
|
||||
setErrors("models", (v) => [...v, {}])
|
||||
}
|
||||
|
||||
const removeModel = (index: number) => {
|
||||
if (form.models.length <= 1) return
|
||||
setForm(
|
||||
"models",
|
||||
produce((rows) => {
|
||||
rows.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
setForm("models", (v) => v.filter((_, i) => i !== index))
|
||||
setErrors("models", (v) => v.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const addHeader = () => {
|
||||
setForm(
|
||||
"headers",
|
||||
produce((rows) => {
|
||||
rows.push(headerRow())
|
||||
}),
|
||||
)
|
||||
setForm("headers", (v) => [...v, { key: "", value: "" }])
|
||||
setErrors("headers", (v) => [...v, {}])
|
||||
}
|
||||
|
||||
const removeHeader = (index: number) => {
|
||||
if (form.headers.length <= 1) return
|
||||
setForm(
|
||||
"headers",
|
||||
produce((rows) => {
|
||||
rows.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const setField = (key: "providerID" | "name" | "baseURL" | "apiKey", value: string) => {
|
||||
setForm(key, value)
|
||||
if (key === "apiKey") return
|
||||
setForm("err", key, undefined)
|
||||
}
|
||||
|
||||
const setModel = (index: number, key: "id" | "name", value: string) => {
|
||||
batch(() => {
|
||||
setForm("models", index, key, value)
|
||||
setForm("models", index, "err", key, undefined)
|
||||
})
|
||||
}
|
||||
|
||||
const setHeader = (index: number, key: "key" | "value", value: string) => {
|
||||
batch(() => {
|
||||
setForm("headers", index, key, value)
|
||||
setForm("headers", index, "err", key, undefined)
|
||||
})
|
||||
setForm("headers", (v) => v.filter((_, i) => i !== index))
|
||||
setErrors("headers", (v) => v.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const validate = () => {
|
||||
@@ -108,11 +222,7 @@ export function DialogCustomProvider(props: Props) {
|
||||
disabledProviders: globalSync.data.config.disabled_providers ?? [],
|
||||
existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
|
||||
})
|
||||
batch(() => {
|
||||
setForm("err", output.err)
|
||||
output.models.forEach((err, index) => setForm("models", index, "err", err))
|
||||
output.headers.forEach((err, index) => setForm("headers", index, "err", err))
|
||||
})
|
||||
setErrors(output.errors)
|
||||
return output.result
|
||||
}
|
||||
|
||||
@@ -195,32 +305,32 @@ export function DialogCustomProvider(props: Props) {
|
||||
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
||||
description={language.t("provider.custom.field.providerID.description")}
|
||||
value={form.providerID}
|
||||
onChange={(v) => setField("providerID", v)}
|
||||
validationState={form.err.providerID ? "invalid" : undefined}
|
||||
error={form.err.providerID}
|
||||
onChange={(v) => setForm("providerID", v)}
|
||||
validationState={errors.providerID ? "invalid" : undefined}
|
||||
error={errors.providerID}
|
||||
/>
|
||||
<TextField
|
||||
label={language.t("provider.custom.field.name.label")}
|
||||
placeholder={language.t("provider.custom.field.name.placeholder")}
|
||||
value={form.name}
|
||||
onChange={(v) => setField("name", v)}
|
||||
validationState={form.err.name ? "invalid" : undefined}
|
||||
error={form.err.name}
|
||||
onChange={(v) => setForm("name", v)}
|
||||
validationState={errors.name ? "invalid" : undefined}
|
||||
error={errors.name}
|
||||
/>
|
||||
<TextField
|
||||
label={language.t("provider.custom.field.baseURL.label")}
|
||||
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
|
||||
value={form.baseURL}
|
||||
onChange={(v) => setField("baseURL", v)}
|
||||
validationState={form.err.baseURL ? "invalid" : undefined}
|
||||
error={form.err.baseURL}
|
||||
onChange={(v) => setForm("baseURL", v)}
|
||||
validationState={errors.baseURL ? "invalid" : undefined}
|
||||
error={errors.baseURL}
|
||||
/>
|
||||
<TextField
|
||||
label={language.t("provider.custom.field.apiKey.label")}
|
||||
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
|
||||
description={language.t("provider.custom.field.apiKey.description")}
|
||||
value={form.apiKey}
|
||||
onChange={(v) => setField("apiKey", v)}
|
||||
onChange={(v) => setForm("apiKey", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -228,16 +338,16 @@ export function DialogCustomProvider(props: Props) {
|
||||
<label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
|
||||
<For each={form.models}>
|
||||
{(m, i) => (
|
||||
<div class="flex gap-2 items-start" data-row={m.row}>
|
||||
<div class="flex gap-2 items-start">
|
||||
<div class="flex-1">
|
||||
<TextField
|
||||
label={language.t("provider.custom.models.id.label")}
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.models.id.placeholder")}
|
||||
value={m.id}
|
||||
onChange={(v) => setModel(i(), "id", v)}
|
||||
validationState={m.err.id ? "invalid" : undefined}
|
||||
error={m.err.id}
|
||||
onChange={(v) => setForm("models", i(), "id", v)}
|
||||
validationState={errors.models[i()]?.id ? "invalid" : undefined}
|
||||
error={errors.models[i()]?.id}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
@@ -246,9 +356,9 @@ export function DialogCustomProvider(props: Props) {
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.models.name.placeholder")}
|
||||
value={m.name}
|
||||
onChange={(v) => setModel(i(), "name", v)}
|
||||
validationState={m.err.name ? "invalid" : undefined}
|
||||
error={m.err.name}
|
||||
onChange={(v) => setForm("models", i(), "name", v)}
|
||||
validationState={errors.models[i()]?.name ? "invalid" : undefined}
|
||||
error={errors.models[i()]?.name}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
@@ -272,16 +382,16 @@ export function DialogCustomProvider(props: Props) {
|
||||
<label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
|
||||
<For each={form.headers}>
|
||||
{(h, i) => (
|
||||
<div class="flex gap-2 items-start" data-row={h.row}>
|
||||
<div class="flex gap-2 items-start">
|
||||
<div class="flex-1">
|
||||
<TextField
|
||||
label={language.t("provider.custom.headers.key.label")}
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.headers.key.placeholder")}
|
||||
value={h.key}
|
||||
onChange={(v) => setHeader(i(), "key", v)}
|
||||
validationState={h.err.key ? "invalid" : undefined}
|
||||
error={h.err.key}
|
||||
onChange={(v) => setForm("headers", i(), "key", v)}
|
||||
validationState={errors.headers[i()]?.key ? "invalid" : undefined}
|
||||
error={errors.headers[i()]?.key}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
@@ -290,9 +400,9 @@ export function DialogCustomProvider(props: Props) {
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.headers.value.placeholder")}
|
||||
value={h.value}
|
||||
onChange={(v) => setHeader(i(), "value", v)}
|
||||
validationState={h.err.value ? "invalid" : undefined}
|
||||
error={h.err.value}
|
||||
onChange={(v) => setForm("headers", i(), "value", v)}
|
||||
validationState={errors.headers[i()]?.value ? "invalid" : undefined}
|
||||
error={errors.headers[i()]?.value}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
@@ -14,8 +14,6 @@ import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useFile } from "@/context/file"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { getRelativeTime } from "@/utils/time"
|
||||
|
||||
@@ -134,14 +132,9 @@ function createFileEntries(props: {
|
||||
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const tabState = createSessionTabs({
|
||||
tabs: props.tabs,
|
||||
pathFromTab: props.file.pathFromTab,
|
||||
normalizeTab: (tab) => (tab.startsWith("file://") ? props.file.tab(tab) : tab),
|
||||
})
|
||||
const recent = createMemo(() => {
|
||||
const all = tabState.openedTabs()
|
||||
const active = tabState.activeFileTab()
|
||||
const all = props.tabs().all()
|
||||
const active = props.tabs().active()
|
||||
const order = active ? [active, ...all.filter((item) => item !== active)] : all
|
||||
const seen = new Set<string>()
|
||||
const category = props.language.t("palette.group.files")
|
||||
@@ -266,11 +259,14 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
const layout = useLayout()
|
||||
const file = useFile()
|
||||
const dialog = useDialog()
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const globalSync = useGlobalSync()
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
const filesOnly = () => props.mode === "files"
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
||||
const [grouped, setGrouped] = createSignal(false)
|
||||
const commandEntries = createCommandEntries({ filesOnly, command, language })
|
||||
|
||||
@@ -14,9 +14,7 @@ import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
||||
|
||||
const DEFAULT_USERNAME = "opencode"
|
||||
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||
|
||||
interface ServerFormProps {
|
||||
value: string
|
||||
@@ -43,15 +41,13 @@ function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown
|
||||
})
|
||||
}
|
||||
|
||||
function useDefaultServer() {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const [defaultKey, defaultUrlActions] = createResource(
|
||||
function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: ReturnType<typeof useLanguage>) {
|
||||
const [defaultUrl, defaultUrlActions] = createResource(
|
||||
async () => {
|
||||
try {
|
||||
const key = await platform.getDefaultServer?.()
|
||||
if (!key) return null
|
||||
return key
|
||||
const url = await platform.getDefaultServerUrl?.()
|
||||
if (!url) return null
|
||||
return normalizeServerUrl(url) ?? null
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
return null
|
||||
@@ -60,22 +56,20 @@ function useDefaultServer() {
|
||||
{ initialValue: null },
|
||||
)
|
||||
|
||||
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
|
||||
const setDefault = async (key: ServerConnection.Key | null) => {
|
||||
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
|
||||
const setDefault = async (url: string | null) => {
|
||||
try {
|
||||
await platform.setDefaultServer?.(key)
|
||||
defaultUrlActions.mutate(key)
|
||||
await platform.setDefaultServerUrl?.(url)
|
||||
defaultUrlActions.mutate(url)
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
}
|
||||
}
|
||||
|
||||
return { defaultKey, canDefault, setDefault }
|
||||
return { defaultUrl, canDefault, setDefault }
|
||||
}
|
||||
|
||||
function useServerPreview() {
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
|
||||
function useServerPreview(fetcher: typeof fetch) {
|
||||
const looksComplete = (value: string) => {
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) return false
|
||||
@@ -98,7 +92,7 @@ function useServerPreview() {
|
||||
const http: ServerConnection.HttpBase = { url: normalized }
|
||||
if (username) http.username = username
|
||||
if (password) http.password = password
|
||||
const result = await checkServerHealth(http)
|
||||
const result = await checkServerHealth(http, fetcher)
|
||||
setStatus(result.healthy)
|
||||
}
|
||||
|
||||
@@ -176,15 +170,15 @@ export function DialogSelectServer() {
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const { defaultKey, canDefault, setDefault } = useDefaultServer()
|
||||
const { previewStatus } = useServerPreview()
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
const fetcher = platform.fetch ?? globalThis.fetch
|
||||
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
|
||||
const { previewStatus } = useServerPreview(fetcher)
|
||||
const [store, setStore] = createStore({
|
||||
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
|
||||
addServer: {
|
||||
url: "",
|
||||
name: "",
|
||||
username: DEFAULT_USERNAME,
|
||||
username: "",
|
||||
password: "",
|
||||
adding: false,
|
||||
error: "",
|
||||
@@ -207,7 +201,7 @@ export function DialogSelectServer() {
|
||||
setStore("addServer", {
|
||||
url: "",
|
||||
name: "",
|
||||
username: DEFAULT_USERNAME,
|
||||
username: "",
|
||||
password: "",
|
||||
adding: false,
|
||||
error: "",
|
||||
@@ -270,7 +264,7 @@ export function DialogSelectServer() {
|
||||
const results: Record<ServerConnection.Key, ServerHealth> = {}
|
||||
await Promise.all(
|
||||
items().map(async (conn) => {
|
||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
|
||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
|
||||
}),
|
||||
)
|
||||
setStore("status", reconcile(results))
|
||||
@@ -368,9 +362,9 @@ export function DialogSelectServer() {
|
||||
http: { url: normalized },
|
||||
}
|
||||
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
|
||||
if (store.addServer.username) conn.http.username = store.addServer.username
|
||||
if (store.addServer.password) conn.http.password = store.addServer.password
|
||||
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
|
||||
const result = await checkServerHealth(conn.http)
|
||||
const result = await checkServerHealth(conn.http, fetcher)
|
||||
setStore("addServer", { adding: false })
|
||||
if (!result.healthy) {
|
||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||
@@ -410,7 +404,7 @@ export function DialogSelectServer() {
|
||||
displayName: name,
|
||||
http: { url: normalized, username, password },
|
||||
}
|
||||
const result = await checkServerHealth(conn.http)
|
||||
const result = await checkServerHealth(conn.http, fetcher)
|
||||
setStore("editServer", { busy: false })
|
||||
if (!result.healthy) {
|
||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||
@@ -447,7 +441,7 @@ export function DialogSelectServer() {
|
||||
showForm: true,
|
||||
url: "",
|
||||
name: "",
|
||||
username: DEFAULT_USERNAME,
|
||||
username: "",
|
||||
password: "",
|
||||
error: "",
|
||||
status: undefined,
|
||||
@@ -500,8 +494,8 @@ export function DialogSelectServer() {
|
||||
|
||||
async function handleRemove(url: ServerConnection.Key) {
|
||||
server.remove(url)
|
||||
if ((await platform.getDefaultServer?.()) === url) {
|
||||
platform.setDefaultServer?.(null)
|
||||
if ((await platform.getDefaultServerUrl?.()) === url) {
|
||||
platform.setDefaultServerUrl?.(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,7 +551,7 @@ export function DialogSelectServer() {
|
||||
status={store.status[key]}
|
||||
class="flex items-center gap-3 min-w-0 flex-1"
|
||||
badge={
|
||||
<Show when={defaultKey() === ServerConnection.key(i)}>
|
||||
<Show when={defaultUrl() === i.http.url}>
|
||||
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||
{language.t("dialog.server.status.default")}
|
||||
</span>
|
||||
@@ -590,14 +584,14 @@ export function DialogSelectServer() {
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<Show when={canDefault() && defaultKey() !== key}>
|
||||
<DropdownMenu.Item onSelect={() => setDefault(key)}>
|
||||
<Show when={canDefault() && defaultUrl() !== i.http.url}>
|
||||
<DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.default")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={canDefault() && defaultKey() === key}>
|
||||
<Show when={canDefault() && defaultUrl() === i.http.url}>
|
||||
<DropdownMenu.Item onSelect={() => setDefault(null)}>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.defaultRemove")}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@/context/prompt"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
@@ -36,8 +37,6 @@ import { Persist, persisted } from "@/utils/persist"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
||||
import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
|
||||
import {
|
||||
@@ -103,13 +102,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const prompt = usePrompt()
|
||||
const layout = useLayout()
|
||||
const comments = useComments()
|
||||
const params = useParams()
|
||||
const dialog = useDialog()
|
||||
const providers = useProviders()
|
||||
const command = useCommand()
|
||||
const permission = usePermission()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
let editorRef!: HTMLDivElement
|
||||
let fileInputRef: HTMLInputElement | undefined
|
||||
let scrollRef!: HTMLDivElement
|
||||
@@ -155,11 +154,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
requestAnimationFrame(scrollCursorIntoView)
|
||||
}
|
||||
|
||||
const activeFileTab = createSessionTabs({
|
||||
tabs,
|
||||
pathFromTab: files.pathFromTab,
|
||||
normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab),
|
||||
}).activeFileTab
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
|
||||
const commentInReview = (path: string) => {
|
||||
const sessionID = params.id
|
||||
@@ -212,7 +209,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const recent = createMemo(() => {
|
||||
const all = tabs().all()
|
||||
const active = activeFileTab()
|
||||
const active = tabs().active()
|
||||
const order = active ? [active, ...all.filter((x) => x !== active)] : all
|
||||
const seen = new Set<string>()
|
||||
const paths: string[] = []
|
||||
@@ -247,6 +244,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
draggingType: "image" | "@mention" | null
|
||||
mode: "normal" | "shell"
|
||||
applyingHistory: boolean
|
||||
pendingAutoAccept: boolean
|
||||
}>({
|
||||
popover: null,
|
||||
historyIndex: -1,
|
||||
@@ -255,18 +253,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
draggingType: null,
|
||||
mode: "normal",
|
||||
applyingHistory: false,
|
||||
pendingAutoAccept: false,
|
||||
})
|
||||
|
||||
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
|
||||
const motion = (value: number) => ({
|
||||
opacity: value,
|
||||
transform: `scale(${0.95 + value * 0.05})`,
|
||||
filter: `blur(${(1 - value) * 2}px)`,
|
||||
"pointer-events": value > 0.5 ? ("auto" as const) : ("none" as const),
|
||||
const buttonsSpring = useSpring(
|
||||
() => (store.mode === "normal" ? 1 : 0),
|
||||
{ visualDuration: 0.2, bounce: 0 },
|
||||
)
|
||||
|
||||
const springFade = (t: number): Record<string, string> => ({
|
||||
opacity: `${t}`,
|
||||
transform: `scale(${0.95 + t * 0.05})`,
|
||||
filter: `blur(${(1 - t) * 2}px)`,
|
||||
"pointer-events": t > 0.5 ? "auto" : "none",
|
||||
})
|
||||
const buttons = createMemo(() => motion(buttonsSpring()))
|
||||
const shell = createMemo(() => motion(1 - buttonsSpring()))
|
||||
const control = createMemo(() => ({ height: "28px", ...buttons() }))
|
||||
|
||||
const commentCount = createMemo(() => {
|
||||
if (store.mode === "shell") return 0
|
||||
@@ -316,6 +316,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(sessionKey, () => {
|
||||
setStore("pendingAutoAccept", false)
|
||||
}),
|
||||
)
|
||||
|
||||
const historyComments = () => {
|
||||
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
|
||||
return prompt.context.items().flatMap((item) => {
|
||||
@@ -502,18 +508,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
setComposing(false)
|
||||
}
|
||||
|
||||
const handleCompositionStart = () => {
|
||||
setComposing(true)
|
||||
}
|
||||
|
||||
const handleCompositionEnd = () => {
|
||||
setComposing(false)
|
||||
requestAnimationFrame(() => {
|
||||
if (composing()) return
|
||||
reconcile(prompt.current().filter((part) => part.type !== "image"))
|
||||
})
|
||||
}
|
||||
|
||||
const agentList = createMemo(() =>
|
||||
sync.data.agent
|
||||
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
||||
@@ -704,27 +698,24 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
const reconcile = (input: Prompt) => {
|
||||
if (mirror.input) {
|
||||
mirror.input = false
|
||||
if (isNormalizedEditor()) return
|
||||
|
||||
renderEditorWithCursor(input)
|
||||
return
|
||||
}
|
||||
|
||||
const dom = parseFromDOM()
|
||||
if (isNormalizedEditor() && isPromptEqual(input, dom)) return
|
||||
|
||||
renderEditorWithCursor(input)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => prompt.current(),
|
||||
(parts) => {
|
||||
if (composing()) return
|
||||
reconcile(parts.filter((part) => part.type !== "image"))
|
||||
(currentParts) => {
|
||||
const inputParts = currentParts.filter((part) => part.type !== "image")
|
||||
|
||||
if (mirror.input) {
|
||||
mirror.input = false
|
||||
if (isNormalizedEditor()) return
|
||||
|
||||
renderEditorWithCursor(inputParts)
|
||||
return
|
||||
}
|
||||
|
||||
const domParts = parseFromDOM()
|
||||
if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
|
||||
|
||||
renderEditorWithCursor(inputParts)
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -980,7 +971,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const variants = createMemo(() => ["default", ...local.model.variant.list()])
|
||||
const accepting = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
|
||||
if (!id) return store.pendingAutoAccept
|
||||
return permission.isAutoAccepting(id, sdk.directory)
|
||||
})
|
||||
|
||||
@@ -1235,8 +1226,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
spellcheck={store.mode === "normal"}
|
||||
onInput={handleInput}
|
||||
onPaste={handlePaste}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onCompositionStart={() => setComposing(true)}
|
||||
onCompositionEnd={() => setComposing(false)}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
classList={{
|
||||
@@ -1273,9 +1264,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<div
|
||||
aria-hidden={store.mode !== "normal"}
|
||||
class="flex items-center gap-1"
|
||||
style={{
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
style={{ "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none" }}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
@@ -1287,7 +1276,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="size-8 p-0"
|
||||
style={buttons()}
|
||||
style={springFade(buttonsSpring())}
|
||||
onClick={pick}
|
||||
disabled={store.mode !== "normal"}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
@@ -1325,7 +1314,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
icon={working() ? "stop" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="size-8"
|
||||
style={buttons()}
|
||||
style={springFade(buttonsSpring())}
|
||||
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -1347,7 +1336,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (!params.id) {
|
||||
permission.toggleAutoAcceptDirectory(sdk.directory)
|
||||
setStore("pendingAutoAccept", (value) => !value)
|
||||
return
|
||||
}
|
||||
permission.toggleAutoAccept(params.id, sdk.directory)
|
||||
@@ -1381,10 +1370,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1 relative">
|
||||
<div
|
||||
class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0"
|
||||
style={{
|
||||
padding: "0 4px 0 8px",
|
||||
...shell(),
|
||||
}}
|
||||
style={{ padding: "0 4px 0 8px", ...springFade(1 - buttonsSpring()) }}
|
||||
>
|
||||
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
|
||||
<div class="size-4 shrink-0" />
|
||||
@@ -1403,7 +1389,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onSelect={local.agent.set}
|
||||
class="capitalize max-w-[160px]"
|
||||
valueClass="truncate text-13-regular"
|
||||
triggerStyle={control()}
|
||||
triggerStyle={{ height: "28px", ...springFade(buttonsSpring()) }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
@@ -1421,7 +1407,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[320px] text-13-regular group"
|
||||
style={control()}
|
||||
style={{ height: "28px", ...springFade(buttonsSpring()) }}
|
||||
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
@@ -1450,7 +1436,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
size: "normal",
|
||||
style: control(),
|
||||
style: { height: "28px", ...springFade(buttonsSpring()) },
|
||||
class: "min-w-0 max-w-[320px] text-13-regular group",
|
||||
}}
|
||||
>
|
||||
@@ -1482,7 +1468,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
|
||||
class="capitalize max-w-[160px]"
|
||||
valueClass="truncate text-13-regular"
|
||||
triggerStyle={control()}
|
||||
triggerStyle={{ height: "28px", ...springFade(buttonsSpring()) }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
|
||||
@@ -6,19 +6,10 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||
const createdClients: string[] = []
|
||||
const createdSessions: 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 syncedDirectories: string[] = []
|
||||
|
||||
let params: { id?: string } = {}
|
||||
let selected = "/repo/worktree-a"
|
||||
let variant: string | undefined
|
||||
|
||||
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
|
||||
@@ -35,7 +26,6 @@ const clientFor = (directory: string) => {
|
||||
return { data: undefined }
|
||||
},
|
||||
prompt: async () => ({ data: undefined }),
|
||||
promptAsync: async () => ({ data: undefined }),
|
||||
command: async () => ({ data: undefined }),
|
||||
abort: async () => ({ data: undefined }),
|
||||
},
|
||||
@@ -50,7 +40,7 @@ beforeAll(async () => {
|
||||
|
||||
mock.module("@solidjs/router", () => ({
|
||||
useNavigate: () => () => undefined,
|
||||
useParams: () => params,
|
||||
useParams: () => ({}),
|
||||
}))
|
||||
|
||||
mock.module("@opencode-ai/sdk/v2/client", () => ({
|
||||
@@ -72,7 +62,7 @@ beforeAll(async () => {
|
||||
useLocal: () => ({
|
||||
model: {
|
||||
current: () => ({ id: "model", provider: { id: "provider" } }),
|
||||
variant: { current: () => variant },
|
||||
variant: { current: () => undefined },
|
||||
},
|
||||
agent: {
|
||||
current: () => ({ name: "agent" }),
|
||||
@@ -128,11 +118,7 @@ beforeAll(async () => {
|
||||
data: { command: [] },
|
||||
session: {
|
||||
optimistic: {
|
||||
add: (value: {
|
||||
message: { agent: string; model: { providerID: string; modelID: string }; variant?: string }
|
||||
}) => {
|
||||
optimistic.push(value)
|
||||
},
|
||||
add: () => undefined,
|
||||
remove: () => undefined,
|
||||
},
|
||||
},
|
||||
@@ -169,12 +155,9 @@ beforeEach(() => {
|
||||
createdClients.length = 0
|
||||
createdSessions.length = 0
|
||||
enabledAutoAccept.length = 0
|
||||
optimistic.length = 0
|
||||
params = {}
|
||||
sentShell.length = 0
|
||||
syncedDirectories.length = 0
|
||||
selected = "/repo/worktree-a"
|
||||
variant = undefined
|
||||
})
|
||||
|
||||
describe("prompt submit worktree selection", () => {
|
||||
@@ -236,39 +219,4 @@ describe("prompt submit worktree selection", () => {
|
||||
|
||||
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",
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { errorMessage } from "@/pages/layout/helpers"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import type { Accessor } from "solid-js"
|
||||
import { batch, type Accessor } from "solid-js"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -65,14 +66,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const language = useLanguage()
|
||||
const params = useParams()
|
||||
|
||||
const errorMessage = (err: unknown) => {
|
||||
if (err && typeof err === "object" && "data" in err) {
|
||||
const data = (err as { data?: { message?: string } }).data
|
||||
if (data?.message) return data.message
|
||||
}
|
||||
if (err instanceof Error) return err.message
|
||||
return language.t("common.requestFailed")
|
||||
}
|
||||
const toastError = (err: unknown) => errorMessage(err, language.t("common.requestFailed"))
|
||||
|
||||
const abort = async () => {
|
||||
const sessionID = params.id
|
||||
@@ -158,7 +152,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
description: errorMessage(err),
|
||||
description: toastError(err),
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
@@ -197,7 +191,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||
description: errorMessage(err),
|
||||
description: toastError(err),
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
@@ -255,7 +249,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.shellSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
description: toastError(err),
|
||||
})
|
||||
restoreInput()
|
||||
})
|
||||
@@ -316,7 +310,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
time: { created: Date.now() },
|
||||
agent,
|
||||
model,
|
||||
variant,
|
||||
}
|
||||
|
||||
const addOptimisticMessage = () =>
|
||||
@@ -334,9 +327,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
messageID,
|
||||
})
|
||||
|
||||
removeCommentItems(commentItems)
|
||||
clearInput()
|
||||
addOptimisticMessage()
|
||||
batch(() => {
|
||||
removeCommentItems(commentItems)
|
||||
clearInput()
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync.set("session_status", session.id, { type: "busy" })
|
||||
}
|
||||
addOptimisticMessage()
|
||||
})
|
||||
|
||||
const waitForWorktree = async () => {
|
||||
const worktree = WorktreeState.get(sessionDirectory)
|
||||
@@ -413,7 +411,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
description: toastError(err),
|
||||
})
|
||||
removeOptimisticMessage()
|
||||
restoreCommentItems(commentItems)
|
||||
|
||||
@@ -2,14 +2,12 @@ import { Match, Show, Switch, createMemo } from "solid-js"
|
||||
import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
|
||||
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useParams } from "@solidjs/router"
|
||||
|
||||
import { useFile } from "@/context/file"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
|
||||
interface SessionContextUsageProps {
|
||||
variant?: "button" | "indicator"
|
||||
@@ -29,17 +27,14 @@ function openSessionContext(args: {
|
||||
|
||||
export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
const sync = useSync()
|
||||
const file = useFile()
|
||||
const params = useParams()
|
||||
const layout = useLayout()
|
||||
const language = useLanguage()
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
|
||||
const variant = createMemo(() => props.variant ?? "button")
|
||||
const tabState = createSessionTabs({
|
||||
tabs,
|
||||
pathFromTab: file.pathFromTab,
|
||||
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||
})
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||
|
||||
const usd = createMemo(
|
||||
@@ -59,7 +54,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
const openContext = () => {
|
||||
if (!params.id) return
|
||||
|
||||
if (tabState.activeTab() === "context") {
|
||||
if (tabs().active() === "context") {
|
||||
tabs().close("context")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { findLast } from "@opencode-ai/util/array"
|
||||
import { same } from "@/utils/same"
|
||||
import { findLast, same } from "@opencode-ai/util/array"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
@@ -12,7 +13,6 @@ import { Markdown } from "@opencode-ai/ui/markdown"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { getSessionContextMetrics } from "./session-context-metrics"
|
||||
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
||||
import { createSessionContextFormatter } from "./session-context-format"
|
||||
@@ -90,10 +90,13 @@ const emptyMessages: Message[] = []
|
||||
const emptyUserMessages: UserMessage[] = []
|
||||
|
||||
export function SessionContextTab() {
|
||||
const params = useParams()
|
||||
const sync = useSync()
|
||||
const layout = useLayout()
|
||||
const language = useLanguage()
|
||||
const { params, view } = useSessionLayout()
|
||||
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
|
||||
const messages = createMemo(
|
||||
|
||||
@@ -10,6 +10,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Portal } from "solid-js/web"
|
||||
@@ -20,9 +21,6 @@ import { useLayout } from "@/context/layout"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { focusTerminalById } from "@/pages/session/helpers"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { StatusPopover } from "../status-popover"
|
||||
@@ -225,13 +223,12 @@ function useSessionShare(args: {
|
||||
export function SessionHeader() {
|
||||
const globalSDK = useGlobalSDK()
|
||||
const layout = useLayout()
|
||||
const params = useParams()
|
||||
const command = useCommand()
|
||||
const server = useServer()
|
||||
const sync = useSync()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const terminal = useTerminal()
|
||||
const { params, view } = useSessionLayout()
|
||||
|
||||
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||
const project = createMemo(() => {
|
||||
@@ -249,6 +246,8 @@ export function SessionHeader() {
|
||||
const currentSession = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
||||
const showShare = createMemo(() => shareEnabled() && !!params.id)
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
const os = createMemo(() => detectOS(platform))
|
||||
|
||||
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
||||
@@ -280,7 +279,10 @@ export function SessionHeader() {
|
||||
Promise.resolve(platform.checkAppExists?.(app.openWith))
|
||||
.then((value) => Boolean(value))
|
||||
.catch(() => false)
|
||||
.then((ok) => [app.id, ok] as const),
|
||||
.then((ok) => {
|
||||
console.debug(`[session-header] App "${app.label}" (${app.openWith}): ${ok ? "exists" : "does not exist"}`)
|
||||
return [app.id, ok] as const
|
||||
}),
|
||||
),
|
||||
).then((entries) => {
|
||||
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
|
||||
@@ -294,16 +296,6 @@ export function SessionHeader() {
|
||||
] 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 [menu, setMenu] = createStore({ open: false })
|
||||
const [openRequest, setOpenRequest] = createStore({
|
||||
@@ -311,12 +303,7 @@ export function SessionHeader() {
|
||||
})
|
||||
|
||||
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
|
||||
const current = createMemo(
|
||||
() =>
|
||||
options().find((o) => o.id === prefs.app) ??
|
||||
options()[0] ??
|
||||
({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
|
||||
)
|
||||
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
|
||||
const opening = createMemo(() => openRequest.app !== undefined)
|
||||
|
||||
const selectApp = (app: OpenApp) => {
|
||||
@@ -625,39 +612,39 @@ export function SessionHeader() {
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center gap-1">
|
||||
<TooltipKeybind
|
||||
title={language.t("command.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"
|
||||
>
|
||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||
<Icon
|
||||
size="small"
|
||||
name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"}
|
||||
class="group-hover/terminal-toggle:hidden"
|
||||
/>
|
||||
<Icon
|
||||
size="small"
|
||||
name="layout-bottom-partial"
|
||||
class="hidden group-hover/terminal-toggle:inline-block"
|
||||
/>
|
||||
<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
|
||||
title={language.t("command.terminal.toggle")}
|
||||
keybind={command.keybind("terminal.toggle")}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border"
|
||||
onClick={() => view().terminal.toggle()}
|
||||
aria-label={language.t("command.terminal.toggle")}
|
||||
aria-expanded={view().terminal.opened()}
|
||||
aria-controls="terminal-panel"
|
||||
>
|
||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||
<Icon
|
||||
size="small"
|
||||
name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"}
|
||||
class="group-hover/terminal-toggle:hidden"
|
||||
/>
|
||||
<Icon
|
||||
size="small"
|
||||
name="layout-bottom-partial"
|
||||
class="hidden group-hover/terminal-toggle:inline-block"
|
||||
/>
|
||||
<Icon
|
||||
size="small"
|
||||
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-partial"}
|
||||
class="hidden group-active/terminal-toggle:inline-block"
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
|
||||
<TooltipKeybind
|
||||
title={language.t("command.review.toggle")}
|
||||
keybind={command.keybind("review.toggle")}
|
||||
|
||||
@@ -4,15 +4,16 @@ import { useSync } from "@/context/sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
|
||||
const MAIN_WORKTREE = "main"
|
||||
const CREATE_WORKTREE = "create"
|
||||
const ROOT_CLASS = "size-full flex flex-col"
|
||||
const ROOT_CLASS =
|
||||
"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 {
|
||||
worktree: string
|
||||
onWorktreeChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function NewSessionView(props: NewSessionViewProps) {
|
||||
@@ -49,43 +50,33 @@ export function NewSessionView(props: NewSessionViewProps) {
|
||||
|
||||
return (
|
||||
<div class={ROOT_CLASS}>
|
||||
<div class="h-12 shrink-0" aria-hidden />
|
||||
<div class="flex-1 px-6 pb-30 flex items-center justify-center text-center">
|
||||
<div class="w-full max-w-200 flex flex-col items-center text-center gap-4">
|
||||
<div class="flex flex-col items-center gap-6">
|
||||
<Mark class="w-10" />
|
||||
<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 class="text-20-medium text-text-weaker">{language.t("command.session.new")}</div>
|
||||
<div class="flex justify-center items-start gap-3 min-h-5">
|
||||
<Icon name="folder" size="small" class="mt-0.5 shrink-0" />
|
||||
<div class="text-12-medium text-text-weak select-text leading-5">
|
||||
{getDirectory(projectRoot())}
|
||||
<span class="text-text-strong">{getFilename(projectRoot())}</span>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { focusTerminalById } from "@/pages/session/helpers"
|
||||
|
||||
export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => void }): JSX.Element {
|
||||
const terminal = useTerminal()
|
||||
@@ -54,8 +53,21 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
||||
|
||||
const focus = () => {
|
||||
if (store.editing) return
|
||||
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
|
||||
focusTerminalById(props.terminal.id)
|
||||
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur()
|
||||
}
|
||||
const wrapper = document.getElementById(`terminal-wrapper-${props.terminal.id}`)
|
||||
const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement
|
||||
if (!element) return
|
||||
|
||||
const textarea = element.querySelector("textarea") as HTMLTextAreaElement
|
||||
if (textarea) {
|
||||
textarea.focus()
|
||||
return
|
||||
}
|
||||
element.focus()
|
||||
element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true }))
|
||||
}
|
||||
|
||||
const edit = (e?: Event) => {
|
||||
|
||||
16
packages/app/src/components/settings-agents.tsx
Normal file
16
packages/app/src/components/settings-agents.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Component } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
export const SettingsAgents: Component = () => {
|
||||
// TODO: Replace this placeholder with full agents settings controls.
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto">
|
||||
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.agents.title")}</h2>
|
||||
<p class="text-14-regular text-text-weak">{language.t("settings.agents.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
packages/app/src/components/settings-commands.tsx
Normal file
16
packages/app/src/components/settings-commands.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Component } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
export const SettingsCommands: Component = () => {
|
||||
// TODO: Replace this placeholder with full commands settings controls.
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto">
|
||||
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.commands.title")}</h2>
|
||||
<p class="text-14-regular text-text-weak">{language.t("settings.commands.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -288,29 +288,6 @@ export const SettingsGeneral: Component = () => {
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
|
||||
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
|
||||
>
|
||||
<div data-action="settings-feed-shell-tool-parts-expanded">
|
||||
<Switch
|
||||
checked={settings.general.shellToolPartsExpanded()}
|
||||
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.editToolPartsExpanded.title")}
|
||||
description={language.t("settings.general.row.editToolPartsExpanded.description")}
|
||||
>
|
||||
<div data-action="settings-feed-edit-tool-parts-expanded">
|
||||
<Switch
|
||||
checked={settings.general.editToolPartsExpanded()}
|
||||
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
16
packages/app/src/components/settings-mcp.tsx
Normal file
16
packages/app/src/components/settings-mcp.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Component } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
export const SettingsMcp: Component = () => {
|
||||
// TODO: Replace this placeholder with full MCP settings controls.
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto">
|
||||
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.mcp.title")}</h2>
|
||||
<p class="text-14-regular text-text-weak">{language.t("settings.mcp.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
230
packages/app/src/components/settings-permissions.tsx
Normal file
230
packages/app/src/components/settings-permissions.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { Component, For, createMemo, type JSX } from "solid-js"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
type PermissionAction = "allow" | "ask" | "deny"
|
||||
|
||||
type PermissionObject = Record<string, PermissionAction>
|
||||
type PermissionValue = PermissionAction | PermissionObject | string[] | undefined
|
||||
type PermissionMap = Record<string, PermissionValue>
|
||||
|
||||
type PermissionItem = {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const ACTIONS = [
|
||||
{ value: "allow", label: "settings.permissions.action.allow" },
|
||||
{ value: "ask", label: "settings.permissions.action.ask" },
|
||||
{ value: "deny", label: "settings.permissions.action.deny" },
|
||||
] as const
|
||||
|
||||
const ITEMS = [
|
||||
{
|
||||
id: "read",
|
||||
title: "settings.permissions.tool.read.title",
|
||||
description: "settings.permissions.tool.read.description",
|
||||
},
|
||||
{
|
||||
id: "edit",
|
||||
title: "settings.permissions.tool.edit.title",
|
||||
description: "settings.permissions.tool.edit.description",
|
||||
},
|
||||
{
|
||||
id: "glob",
|
||||
title: "settings.permissions.tool.glob.title",
|
||||
description: "settings.permissions.tool.glob.description",
|
||||
},
|
||||
{
|
||||
id: "grep",
|
||||
title: "settings.permissions.tool.grep.title",
|
||||
description: "settings.permissions.tool.grep.description",
|
||||
},
|
||||
{
|
||||
id: "list",
|
||||
title: "settings.permissions.tool.list.title",
|
||||
description: "settings.permissions.tool.list.description",
|
||||
},
|
||||
{
|
||||
id: "bash",
|
||||
title: "settings.permissions.tool.bash.title",
|
||||
description: "settings.permissions.tool.bash.description",
|
||||
},
|
||||
{
|
||||
id: "task",
|
||||
title: "settings.permissions.tool.task.title",
|
||||
description: "settings.permissions.tool.task.description",
|
||||
},
|
||||
{
|
||||
id: "skill",
|
||||
title: "settings.permissions.tool.skill.title",
|
||||
description: "settings.permissions.tool.skill.description",
|
||||
},
|
||||
{
|
||||
id: "lsp",
|
||||
title: "settings.permissions.tool.lsp.title",
|
||||
description: "settings.permissions.tool.lsp.description",
|
||||
},
|
||||
{
|
||||
id: "todoread",
|
||||
title: "settings.permissions.tool.todoread.title",
|
||||
description: "settings.permissions.tool.todoread.description",
|
||||
},
|
||||
{
|
||||
id: "todowrite",
|
||||
title: "settings.permissions.tool.todowrite.title",
|
||||
description: "settings.permissions.tool.todowrite.description",
|
||||
},
|
||||
{
|
||||
id: "webfetch",
|
||||
title: "settings.permissions.tool.webfetch.title",
|
||||
description: "settings.permissions.tool.webfetch.description",
|
||||
},
|
||||
{
|
||||
id: "websearch",
|
||||
title: "settings.permissions.tool.websearch.title",
|
||||
description: "settings.permissions.tool.websearch.description",
|
||||
},
|
||||
{
|
||||
id: "codesearch",
|
||||
title: "settings.permissions.tool.codesearch.title",
|
||||
description: "settings.permissions.tool.codesearch.description",
|
||||
},
|
||||
{
|
||||
id: "external_directory",
|
||||
title: "settings.permissions.tool.external_directory.title",
|
||||
description: "settings.permissions.tool.external_directory.description",
|
||||
},
|
||||
{
|
||||
id: "doom_loop",
|
||||
title: "settings.permissions.tool.doom_loop.title",
|
||||
description: "settings.permissions.tool.doom_loop.description",
|
||||
},
|
||||
] as const
|
||||
|
||||
const VALID_ACTIONS = new Set<PermissionAction>(["allow", "ask", "deny"])
|
||||
|
||||
function toMap(value: unknown): PermissionMap {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) return value as PermissionMap
|
||||
|
||||
const action = getAction(value)
|
||||
if (action) return { "*": action }
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
function getAction(value: unknown): PermissionAction | undefined {
|
||||
if (typeof value === "string" && VALID_ACTIONS.has(value as PermissionAction)) return value as PermissionAction
|
||||
return
|
||||
}
|
||||
|
||||
function getRuleDefault(value: unknown): PermissionAction | undefined {
|
||||
const action = getAction(value)
|
||||
if (action) return action
|
||||
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return
|
||||
|
||||
return getAction((value as Record<string, unknown>)["*"])
|
||||
}
|
||||
|
||||
export const SettingsPermissions: Component = () => {
|
||||
const globalSync = useGlobalSync()
|
||||
const language = useLanguage()
|
||||
|
||||
const actions = createMemo(
|
||||
(): Array<{ value: PermissionAction; label: string }> =>
|
||||
ACTIONS.map((action) => ({
|
||||
value: action.value,
|
||||
label: language.t(action.label),
|
||||
})),
|
||||
)
|
||||
|
||||
const permission = createMemo(() => {
|
||||
return toMap(globalSync.data.config.permission)
|
||||
})
|
||||
|
||||
const actionFor = (id: string): PermissionAction => {
|
||||
const value = permission()[id]
|
||||
const direct = getRuleDefault(value)
|
||||
if (direct) return direct
|
||||
|
||||
const wildcard = getRuleDefault(permission()["*"])
|
||||
if (wildcard) return wildcard
|
||||
|
||||
return "allow"
|
||||
}
|
||||
|
||||
const setPermission = async (id: string, action: PermissionAction) => {
|
||||
const before = globalSync.data.config.permission
|
||||
const map = toMap(before)
|
||||
const existing = map[id]
|
||||
|
||||
const nextValue =
|
||||
existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing, "*": action } : action
|
||||
|
||||
const rollback = (err: unknown) => {
|
||||
globalSync.set("config", "permission", before)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("settings.permissions.toast.updateFailed.title"), description: message })
|
||||
}
|
||||
|
||||
globalSync.set("config", "permission", { ...map, [id]: nextValue })
|
||||
globalSync.updateConfig({ permission: { [id]: nextValue } }).catch(rollback)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar">
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
<div class="flex flex-col gap-1 px-4 py-8 sm:p-8 max-w-[720px]">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.permissions.title")}</h2>
|
||||
<p class="text-14-regular text-text-weak">{language.t("settings.permissions.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6 px-4 py-6 sm:p-8 sm:pt-6 max-w-[720px]">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="text-14-medium text-text-strong">{language.t("settings.permissions.section.tools")}</h3>
|
||||
<div class="border border-border-weak-base rounded-lg overflow-hidden">
|
||||
<For each={ITEMS}>
|
||||
{(item) => (
|
||||
<SettingsRow title={language.t(item.title)} description={language.t(item.description)}>
|
||||
<Select
|
||||
options={actions()}
|
||||
current={actions().find((o) => o.value === actionFor(item.id))}
|
||||
value={(o) => o.value}
|
||||
label={(o) => o.label}
|
||||
onSelect={(option) => option && setPermission(item.id, option.value)}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
/>
|
||||
</SettingsRow>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SettingsRowProps {
|
||||
title: string
|
||||
description: string
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
const SettingsRow: Component<SettingsRowProps> = (props) => {
|
||||
return (
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 px-4 py-3 border-b border-border-weak-base last:border-none">
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<span class="text-14-medium text-text-strong">{props.title}</span>
|
||||
<span class="text-12-regular text-text-weak">{props.description}</span>
|
||||
</div>
|
||||
<div class="flex-shrink-0">{props.children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||
import { DialogSelectServer } from "./dialog-select-server"
|
||||
|
||||
const pollMs = 10_000
|
||||
@@ -53,8 +53,7 @@ const listServersByHealth = (
|
||||
})
|
||||
}
|
||||
|
||||
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => {
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typeof fetch) => {
|
||||
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
|
||||
|
||||
createEffect(() => {
|
||||
@@ -65,7 +64,7 @@ const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => {
|
||||
const results: Record<string, ServerHealth> = {}
|
||||
await Promise.all(
|
||||
list.map(async (conn) => {
|
||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
|
||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
|
||||
}),
|
||||
)
|
||||
if (dead) return
|
||||
@@ -169,6 +168,7 @@ export function StatusPopover() {
|
||||
const language = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const fetcher = platform.fetch ?? globalThis.fetch
|
||||
const servers = createMemo(() => {
|
||||
const current = server.current
|
||||
const list = server.list
|
||||
@@ -176,10 +176,10 @@ export function StatusPopover() {
|
||||
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
|
||||
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
|
||||
})
|
||||
const health = useServerHealth(servers)
|
||||
const health = useServerHealth(servers, fetcher)
|
||||
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
|
||||
const mcp = useMcpToggle({ sync, sdk, language })
|
||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||
const defaultServer = useDefaultServerKey(platform.getDefaultServerUrl)
|
||||
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useSDK } from "@/context/sdk"
|
||||
import { useServer } from "@/context/server"
|
||||
import { monoFontFamily, useSettings } from "@/context/settings"
|
||||
import type { LocalPTY } from "@/context/terminal"
|
||||
import { terminalAttr, terminalProbe } from "@/testing/terminal"
|
||||
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
||||
import { terminalWriter } from "@/utils/terminal-writer"
|
||||
|
||||
@@ -18,7 +17,6 @@ const TOGGLE_TERMINAL_ID = "terminal.toggle"
|
||||
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
|
||||
export interface TerminalProps extends ComponentProps<"div"> {
|
||||
pty: LocalPTY
|
||||
autoFocus?: boolean
|
||||
onSubmit?: () => void
|
||||
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
|
||||
onConnect?: () => void
|
||||
@@ -159,9 +157,8 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
let container!: HTMLDivElement
|
||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
|
||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"])
|
||||
const id = local.pty.id
|
||||
const probe = terminalProbe(id)
|
||||
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
|
||||
const restoreSize =
|
||||
restore &&
|
||||
@@ -220,7 +217,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const currentTheme = theme.themes()[theme.themeId()]
|
||||
if (!currentTheme) return fallback
|
||||
const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
|
||||
if (!variant?.seeds && !variant?.palette) return fallback
|
||||
if (!variant?.seeds) return fallback
|
||||
const resolved = resolveThemeVariant(variant, mode === "dark")
|
||||
const text = resolved["text-stronger"] ?? fallback.foreground
|
||||
const background = resolved["background-stronger"] ?? fallback.background
|
||||
@@ -328,9 +325,6 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
probe.init()
|
||||
cleanups.push(() => probe.drop())
|
||||
|
||||
const run = async () => {
|
||||
const loaded = await loadGhostty()
|
||||
if (disposed) return
|
||||
@@ -358,13 +352,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
ghostty = g
|
||||
term = t
|
||||
output = terminalWriter((data, done) =>
|
||||
t.write(data, () => {
|
||||
probe.render(data)
|
||||
probe.settle()
|
||||
done?.()
|
||||
}),
|
||||
)
|
||||
output = terminalWriter((data, done) => t.write(data, done))
|
||||
|
||||
t.attachCustomKeyEventHandler((event) => {
|
||||
const key = event.key.toLowerCase()
|
||||
@@ -398,7 +386,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
handleLinkClick,
|
||||
})
|
||||
|
||||
if (local.autoFocus !== false) focusTerminal()
|
||||
focusTerminal()
|
||||
|
||||
if (typeof document !== "undefined" && document.fonts) {
|
||||
document.fonts.ready.then(scheduleFit)
|
||||
@@ -452,6 +440,10 @@ export const Terminal = (props: TerminalProps) => {
|
||||
startResize()
|
||||
}
|
||||
|
||||
// t.onScroll((ydisp) => {
|
||||
// console.log("Scroll position:", ydisp)
|
||||
// })
|
||||
|
||||
const once = { value: false }
|
||||
let closing = false
|
||||
|
||||
@@ -459,7 +451,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
url.searchParams.set("directory", sdk.directory)
|
||||
url.searchParams.set("cursor", String(start !== undefined ? start : restore ? -1 : 0))
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||
url.username = server.current?.http.username ?? "opencode"
|
||||
url.username = server.current?.http.username ?? ""
|
||||
url.password = server.current?.http.password ?? ""
|
||||
|
||||
const socket = new WebSocket(url)
|
||||
@@ -467,7 +459,6 @@ export const Terminal = (props: TerminalProps) => {
|
||||
ws = socket
|
||||
|
||||
const handleOpen = () => {
|
||||
probe.connect()
|
||||
local.onConnect?.()
|
||||
scheduleSize(t.cols, t.rows)
|
||||
}
|
||||
@@ -568,7 +559,6 @@ export const Terminal = (props: TerminalProps) => {
|
||||
<div
|
||||
ref={container}
|
||||
data-component="terminal"
|
||||
{...{ [terminalAttr]: id }}
|
||||
data-prevent-autofocus
|
||||
tabIndex={-1}
|
||||
style={{ "background-color": terminalColors().background }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js"
|
||||
import { createEffect, createMemo, Show, untrack } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
@@ -155,7 +155,7 @@ export function Titlebar() {
|
||||
|
||||
return (
|
||||
<header
|
||||
class="h-10 shrink-0 bg-background-base relative grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center"
|
||||
class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
|
||||
style={{ "min-height": minHeight() }}
|
||||
data-tauri-drag-region
|
||||
onMouseDown={drag}
|
||||
@@ -269,7 +269,7 @@ export function Titlebar() {
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex items-center justify-center pointer-events-none">
|
||||
<div id="opencode-titlebar-center" class="pointer-events-auto min-w-0 flex justify-center w-fit max-w-full" />
|
||||
<div id="opencode-titlebar-center" class="pointer-events-auto w-full min-w-0 flex justify-center lg:w-fit" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -282,7 +282,7 @@ export function Titlebar() {
|
||||
>
|
||||
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
|
||||
<Show when={windows()}>
|
||||
{!tauriApi() && <div class="w-36 shrink-0" />}
|
||||
<div class="w-6 shrink-0" />
|
||||
<div data-tauri-decorum-tb class="flex flex-row" />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { isEditableTarget } from "@/utils/dom"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
@@ -177,14 +178,6 @@ export function formatKeybind(config: string): string {
|
||||
return IS_MAC ? parts.join("") : parts.join("+")
|
||||
}
|
||||
|
||||
function isEditableTarget(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
if (target.isContentEditable) return true
|
||||
if (target.closest("[contenteditable='true']")) return true
|
||||
if (target.closest("input, textarea, select")) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
|
||||
name: "Command",
|
||||
init: () => {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
||||
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||
import {
|
||||
canDisposeDirectory,
|
||||
estimateRootSessionTotal,
|
||||
loadRootSessionsWithFallback,
|
||||
pickDirectoriesToEvict,
|
||||
} from "./global-sync"
|
||||
|
||||
describe("pickDirectoriesToEvict", () => {
|
||||
test("keeps pinned stores and evicts idle stores", () => {
|
||||
|
||||
@@ -27,9 +27,8 @@ import type { InitError } from "../pages/error"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
|
||||
import { createChildStoreManager } from "./global-sync/child-store"
|
||||
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
|
||||
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
||||
import { createRefreshQueue } from "./global-sync/queue"
|
||||
import { clearSessionPrefetchDirectory } from "./global-sync/session-prefetch"
|
||||
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||
import { trimSessions } from "./global-sync/session-trim"
|
||||
import type { ProjectMeta } from "./global-sync/types"
|
||||
@@ -162,7 +161,6 @@ function createGlobalSync() {
|
||||
queue.clear(directory)
|
||||
sessionMeta.delete(directory)
|
||||
sdkCache.delete(directory)
|
||||
clearSessionPrefetchDirectory(directory)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -191,7 +189,6 @@ function createGlobalSync() {
|
||||
})
|
||||
if (next.length !== store.session.length) {
|
||||
setStore("session", reconcile(next, { key: "id" }))
|
||||
cleanupDroppedSessionCaches(store, setStore, next, setSessionTodo)
|
||||
}
|
||||
children.unpin(directory)
|
||||
return
|
||||
@@ -223,7 +220,6 @@ function createGlobalSync() {
|
||||
}),
|
||||
)
|
||||
setStore("session", reconcile(sessions, { key: "id" }))
|
||||
cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
|
||||
sessionMeta.set(directory, { limit })
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -357,7 +353,6 @@ function createGlobalSync() {
|
||||
.update({ config })
|
||||
.then(bootstrap)
|
||||
.then(() => {
|
||||
queue.refresh()
|
||||
setGlobalStore("reload", undefined)
|
||||
queue.refresh()
|
||||
})
|
||||
@@ -404,3 +399,6 @@ export function useGlobalSync() {
|
||||
if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
|
||||
return context
|
||||
}
|
||||
|
||||
export { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
||||
export { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { State } from "./types"
|
||||
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
|
||||
import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer"
|
||||
|
||||
const rootSession = (input: { id: string; parentID?: string; archived?: number }) =>
|
||||
({
|
||||
@@ -248,62 +248,6 @@ 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", () => {
|
||||
const sessionID = "ses_1"
|
||||
const [store, setStore] = createStore(
|
||||
|
||||
@@ -13,7 +13,6 @@ import type {
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { trimSessions } from "./session-trim"
|
||||
import { dropSessionCaches } from "./session-cache"
|
||||
|
||||
export function applyGlobalEvent(input: {
|
||||
event: { type: string; properties?: unknown }
|
||||
@@ -41,44 +40,37 @@ export function applyGlobalEvent(input: {
|
||||
}
|
||||
|
||||
function cleanupSessionCaches(
|
||||
store: Store<State>,
|
||||
setStore: SetStoreFunction<State>,
|
||||
sessionID: string,
|
||||
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
|
||||
) {
|
||||
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)
|
||||
if (!hasAny) return
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
dropSessionCaches(draft, [sessionID])
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function cleanupDroppedSessionCaches(
|
||||
store: Store<State>,
|
||||
setStore: SetStoreFunction<State>,
|
||||
next: Session[],
|
||||
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
|
||||
) {
|
||||
const keep = new Set(next.map((item) => item.id))
|
||||
const stale = [
|
||||
...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)
|
||||
const messages = draft.message[sessionID]
|
||||
if (messages) {
|
||||
for (const message of messages) {
|
||||
const id = message?.id
|
||||
if (!id) continue
|
||||
delete draft.part[id]
|
||||
}
|
||||
}
|
||||
delete draft.message[sessionID]
|
||||
delete draft.session_diff[sessionID]
|
||||
delete draft.todo[sessionID]
|
||||
delete draft.permission[sessionID]
|
||||
delete draft.question[sessionID]
|
||||
delete draft.session_status[sessionID]
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -110,7 +102,6 @@ export function applyDirectoryEvent(input: {
|
||||
next.splice(result.index, 0, info)
|
||||
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
|
||||
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
||||
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
|
||||
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
|
||||
break
|
||||
}
|
||||
@@ -126,7 +117,7 @@ export function applyDirectoryEvent(input: {
|
||||
}),
|
||||
)
|
||||
}
|
||||
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
|
||||
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
|
||||
if (info.parentID) break
|
||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||
break
|
||||
@@ -139,7 +130,6 @@ export function applyDirectoryEvent(input: {
|
||||
next.splice(result.index, 0, info)
|
||||
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
|
||||
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
||||
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
|
||||
break
|
||||
}
|
||||
case "session.deleted": {
|
||||
@@ -153,7 +143,7 @@ export function applyDirectoryEvent(input: {
|
||||
}),
|
||||
)
|
||||
}
|
||||
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
|
||||
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
|
||||
if (info.parentID) break
|
||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||
break
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
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"])
|
||||
})
|
||||
})
|
||||
@@ -1,62 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
clearSessionPrefetch,
|
||||
clearSessionPrefetchDirectory,
|
||||
getSessionPrefetch,
|
||||
runSessionPrefetch,
|
||||
setSessionPrefetch,
|
||||
} from "./session-prefetch"
|
||||
|
||||
describe("session prefetch", () => {
|
||||
test("stores and clears message metadata by directory", () => {
|
||||
clearSessionPrefetch("/tmp/a", ["ses_1"])
|
||||
clearSessionPrefetch("/tmp/b", ["ses_1"])
|
||||
|
||||
setSessionPrefetch({
|
||||
directory: "/tmp/a",
|
||||
sessionID: "ses_1",
|
||||
limit: 200,
|
||||
complete: false,
|
||||
at: 123,
|
||||
})
|
||||
|
||||
expect(getSessionPrefetch("/tmp/a", "ses_1")).toEqual({ limit: 200, complete: false, at: 123 })
|
||||
expect(getSessionPrefetch("/tmp/b", "ses_1")).toBeUndefined()
|
||||
|
||||
clearSessionPrefetch("/tmp/a", ["ses_1"])
|
||||
|
||||
expect(getSessionPrefetch("/tmp/a", "ses_1")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("dedupes inflight work", async () => {
|
||||
clearSessionPrefetch("/tmp/c", ["ses_2"])
|
||||
|
||||
let calls = 0
|
||||
const run = () =>
|
||||
runSessionPrefetch({
|
||||
directory: "/tmp/c",
|
||||
sessionID: "ses_2",
|
||||
task: async () => {
|
||||
calls += 1
|
||||
return { limit: 100, complete: true, at: 456 }
|
||||
},
|
||||
})
|
||||
|
||||
const [a, b] = await Promise.all([run(), run()])
|
||||
|
||||
expect(calls).toBe(1)
|
||||
expect(a).toEqual({ limit: 100, complete: true, at: 456 })
|
||||
expect(b).toEqual({ limit: 100, complete: true, at: 456 })
|
||||
})
|
||||
|
||||
test("clears a whole directory", () => {
|
||||
setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_1", limit: 10, complete: true, at: 1 })
|
||||
setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_2", limit: 20, complete: false, at: 2 })
|
||||
setSessionPrefetch({ directory: "/tmp/e", sessionID: "ses_1", limit: 30, complete: true, at: 3 })
|
||||
|
||||
clearSessionPrefetchDirectory("/tmp/d")
|
||||
|
||||
expect(getSessionPrefetch("/tmp/d", "ses_1")).toBeUndefined()
|
||||
expect(getSessionPrefetch("/tmp/d", "ses_2")).toBeUndefined()
|
||||
expect(getSessionPrefetch("/tmp/e", "ses_1")).toEqual({ limit: 30, complete: true, at: 3 })
|
||||
})
|
||||
})
|
||||
@@ -1,85 +0,0 @@
|
||||
const key = (directory: string, sessionID: string) => `${directory}\n${sessionID}`
|
||||
|
||||
export const SESSION_PREFETCH_TTL = 15_000
|
||||
|
||||
type Meta = {
|
||||
limit: number
|
||||
complete: boolean
|
||||
at: number
|
||||
}
|
||||
|
||||
const cache = new Map<string, Meta>()
|
||||
const inflight = new Map<string, Promise<Meta | undefined>>()
|
||||
const rev = new Map<string, number>()
|
||||
|
||||
const version = (id: string) => rev.get(id) ?? 0
|
||||
|
||||
export function getSessionPrefetch(directory: string, sessionID: string) {
|
||||
return cache.get(key(directory, sessionID))
|
||||
}
|
||||
|
||||
export function getSessionPrefetchPromise(directory: string, sessionID: string) {
|
||||
return inflight.get(key(directory, sessionID))
|
||||
}
|
||||
|
||||
export function clearSessionPrefetchInflight() {
|
||||
inflight.clear()
|
||||
}
|
||||
|
||||
export function isSessionPrefetchCurrent(directory: string, sessionID: string, value: number) {
|
||||
return version(key(directory, sessionID)) === value
|
||||
}
|
||||
|
||||
export function runSessionPrefetch(input: {
|
||||
directory: string
|
||||
sessionID: string
|
||||
task: (value: number) => Promise<Meta | undefined>
|
||||
}) {
|
||||
const id = key(input.directory, input.sessionID)
|
||||
const pending = inflight.get(id)
|
||||
if (pending) return pending
|
||||
|
||||
const value = version(id)
|
||||
|
||||
const promise = input.task(value).finally(() => {
|
||||
if (inflight.get(id) === promise) inflight.delete(id)
|
||||
})
|
||||
|
||||
inflight.set(id, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
export function setSessionPrefetch(input: {
|
||||
directory: string
|
||||
sessionID: string
|
||||
limit: number
|
||||
complete: boolean
|
||||
at?: number
|
||||
}) {
|
||||
cache.set(key(input.directory, input.sessionID), {
|
||||
limit: input.limit,
|
||||
complete: input.complete,
|
||||
at: input.at ?? Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
export function clearSessionPrefetch(directory: string, sessionIDs: Iterable<string>) {
|
||||
for (const sessionID of sessionIDs) {
|
||||
if (!sessionID) continue
|
||||
const id = key(directory, sessionID)
|
||||
rev.set(id, version(id) + 1)
|
||||
cache.delete(id)
|
||||
inflight.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSessionPrefetchDirectory(directory: string) {
|
||||
const prefix = `${directory}\n`
|
||||
const keys = new Set([...cache.keys(), ...inflight.keys()])
|
||||
for (const id of keys) {
|
||||
if (!id.startsWith(prefix)) continue
|
||||
rev.set(id, version(id) + 1)
|
||||
cache.delete(id)
|
||||
inflight.delete(id)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, onCleanup } from "solid-js"
|
||||
import { createEffect, createSignal, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
@@ -146,10 +146,8 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
|
||||
const settings = useSettings()
|
||||
const [store, setStore, _, ready] = persisted("highlights.v1", createStore<Store>({ version: undefined }))
|
||||
|
||||
const [range, setRange] = createStore({
|
||||
from: undefined as string | undefined,
|
||||
to: undefined as string | undefined,
|
||||
})
|
||||
const [from, setFrom] = createSignal<string | undefined>(undefined)
|
||||
const [to, setTo] = createSignal<string | undefined>(undefined)
|
||||
const state = { started: false }
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
@@ -216,14 +214,15 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
|
||||
|
||||
if (previous === platform.version) return
|
||||
|
||||
setRange({ from: previous, to: platform.version })
|
||||
setFrom(previous)
|
||||
setTo(platform.version)
|
||||
start(previous)
|
||||
})
|
||||
|
||||
return {
|
||||
ready,
|
||||
from: () => range.from,
|
||||
to: () => range.to,
|
||||
from,
|
||||
to,
|
||||
get last() {
|
||||
return store.version
|
||||
},
|
||||
|
||||
@@ -146,7 +146,6 @@ const DICT: Record<Locale, Dictionary> = {
|
||||
}
|
||||
|
||||
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
|
||||
{ locale: "en", match: (language) => language.startsWith("en") },
|
||||
{ locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") },
|
||||
{ locale: "zh", match: (language) => language.startsWith("zh") },
|
||||
{ locale: "ko", match: (language) => language.startsWith("ko") },
|
||||
@@ -218,7 +217,6 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
||||
)
|
||||
|
||||
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
|
||||
console.log("locale", locale())
|
||||
const intl = createMemo(() => INTL[locale()])
|
||||
|
||||
const dict = createMemo<Dictionary>(() => DICT[locale()])
|
||||
|
||||
@@ -8,7 +8,7 @@ import { usePlatform } from "./platform"
|
||||
import { Project } from "@opencode-ai/sdk/v2"
|
||||
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { same } from "@/utils/same"
|
||||
import { same } from "@opencode-ai/util/array"
|
||||
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
|
||||
import { createPathHelpers } from "./file/path"
|
||||
|
||||
@@ -793,67 +793,20 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
},
|
||||
review: {
|
||||
open: createMemo(() => s().reviewOpen ?? []),
|
||||
open: createMemo(() => s().reviewOpen),
|
||||
setOpen(open: string[]) {
|
||||
const session = key()
|
||||
const next = Array.from(new Set(open))
|
||||
const current = store.sessionView[session]
|
||||
if (!current) {
|
||||
setStore("sessionView", session, {
|
||||
scroll: {},
|
||||
reviewOpen: next,
|
||||
reviewOpen: open,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (same(current.reviewOpen, next)) return
|
||||
setStore("sessionView", session, "reviewOpen", next)
|
||||
},
|
||||
openPath(path: string) {
|
||||
const session = key()
|
||||
const current = store.sessionView[session]
|
||||
if (!current) {
|
||||
setStore("sessionView", session, {
|
||||
scroll: {},
|
||||
reviewOpen: [path],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!current.reviewOpen) {
|
||||
setStore("sessionView", session, "reviewOpen", [path])
|
||||
return
|
||||
}
|
||||
|
||||
if (current.reviewOpen.includes(path)) return
|
||||
setStore("sessionView", session, "reviewOpen", current.reviewOpen.length, path)
|
||||
},
|
||||
closePath(path: string) {
|
||||
const session = key()
|
||||
const current = store.sessionView[session]?.reviewOpen
|
||||
if (!current) return
|
||||
|
||||
const index = current.indexOf(path)
|
||||
if (index === -1) return
|
||||
setStore(
|
||||
"sessionView",
|
||||
session,
|
||||
"reviewOpen",
|
||||
produce((draft) => {
|
||||
if (!draft) return
|
||||
draft.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
},
|
||||
togglePath(path: string) {
|
||||
const session = key()
|
||||
const current = store.sessionView[session]?.reviewOpen
|
||||
if (!current || !current.includes(path)) {
|
||||
this.openPath(path)
|
||||
return
|
||||
}
|
||||
|
||||
this.closePath(path)
|
||||
if (same(current.reviewOpen, open)) return
|
||||
setStore("sessionView", session, "reviewOpen", open)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
66
packages/app/src/context/notification-index.ts
Normal file
66
packages/app/src/context/notification-index.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
type NotificationIndexItem = {
|
||||
directory?: string
|
||||
session?: string
|
||||
viewed: boolean
|
||||
type: string
|
||||
}
|
||||
|
||||
export function buildNotificationIndex<T extends NotificationIndexItem>(list: T[]) {
|
||||
const sessionAll = new Map<string, T[]>()
|
||||
const sessionUnseen = new Map<string, T[]>()
|
||||
const sessionUnseenCount = new Map<string, number>()
|
||||
const sessionUnseenHasError = new Map<string, boolean>()
|
||||
const projectAll = new Map<string, T[]>()
|
||||
const projectUnseen = new Map<string, T[]>()
|
||||
const projectUnseenCount = new Map<string, number>()
|
||||
const projectUnseenHasError = new Map<string, boolean>()
|
||||
|
||||
for (const notification of list) {
|
||||
const session = notification.session
|
||||
if (session) {
|
||||
const all = sessionAll.get(session)
|
||||
if (all) all.push(notification)
|
||||
else sessionAll.set(session, [notification])
|
||||
|
||||
if (!notification.viewed) {
|
||||
const unseen = sessionUnseen.get(session)
|
||||
if (unseen) unseen.push(notification)
|
||||
else sessionUnseen.set(session, [notification])
|
||||
|
||||
sessionUnseenCount.set(session, (sessionUnseenCount.get(session) ?? 0) + 1)
|
||||
if (notification.type === "error") sessionUnseenHasError.set(session, true)
|
||||
}
|
||||
}
|
||||
|
||||
const directory = notification.directory
|
||||
if (directory) {
|
||||
const all = projectAll.get(directory)
|
||||
if (all) all.push(notification)
|
||||
else projectAll.set(directory, [notification])
|
||||
|
||||
if (!notification.viewed) {
|
||||
const unseen = projectUnseen.get(directory)
|
||||
if (unseen) unseen.push(notification)
|
||||
else projectUnseen.set(directory, [notification])
|
||||
|
||||
projectUnseenCount.set(directory, (projectUnseenCount.get(directory) ?? 0) + 1)
|
||||
if (notification.type === "error") projectUnseenHasError.set(directory, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
session: {
|
||||
all: sessionAll,
|
||||
unseen: sessionUnseen,
|
||||
unseenCount: sessionUnseenCount,
|
||||
unseenHasError: sessionUnseenHasError,
|
||||
},
|
||||
project: {
|
||||
all: projectAll,
|
||||
unseen: projectUnseen,
|
||||
unseenCount: projectUnseenCount,
|
||||
unseenHasError: projectUnseenHasError,
|
||||
},
|
||||
}
|
||||
}
|
||||
73
packages/app/src/context/notification.test.ts
Normal file
73
packages/app/src/context/notification.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { buildNotificationIndex } from "./notification-index"
|
||||
|
||||
type Notification = {
|
||||
type: "turn-complete" | "error"
|
||||
session: string
|
||||
directory: string
|
||||
viewed: boolean
|
||||
time: number
|
||||
}
|
||||
|
||||
const turn = (session: string, directory: string, viewed = false): Notification => ({
|
||||
type: "turn-complete",
|
||||
session,
|
||||
directory,
|
||||
viewed,
|
||||
time: 1,
|
||||
})
|
||||
|
||||
const error = (session: string, directory: string, viewed = false): Notification => ({
|
||||
type: "error",
|
||||
session,
|
||||
directory,
|
||||
viewed,
|
||||
time: 1,
|
||||
})
|
||||
|
||||
describe("buildNotificationIndex", () => {
|
||||
test("builds unseen counts and unseen error flags", () => {
|
||||
const list = [
|
||||
turn("s1", "d1", false),
|
||||
error("s1", "d1", false),
|
||||
turn("s1", "d1", true),
|
||||
turn("s2", "d1", false),
|
||||
error("s3", "d2", true),
|
||||
]
|
||||
|
||||
const index = buildNotificationIndex(list)
|
||||
|
||||
expect(index.session.all.get("s1")?.length).toBe(3)
|
||||
expect(index.session.unseen.get("s1")?.length).toBe(2)
|
||||
expect(index.session.unseenCount.get("s1")).toBe(2)
|
||||
expect(index.session.unseenHasError.get("s1")).toBe(true)
|
||||
|
||||
expect(index.session.unseenCount.get("s2")).toBe(1)
|
||||
expect(index.session.unseenHasError.get("s2") ?? false).toBe(false)
|
||||
expect(index.session.unseenCount.get("s3") ?? 0).toBe(0)
|
||||
expect(index.session.unseenHasError.get("s3") ?? false).toBe(false)
|
||||
|
||||
expect(index.project.unseenCount.get("d1")).toBe(3)
|
||||
expect(index.project.unseenHasError.get("d1")).toBe(true)
|
||||
expect(index.project.unseenCount.get("d2") ?? 0).toBe(0)
|
||||
expect(index.project.unseenHasError.get("d2") ?? false).toBe(false)
|
||||
})
|
||||
|
||||
test("updates selectors after viewed transitions", () => {
|
||||
const list = [turn("s1", "d1", false), error("s1", "d1", false), turn("s2", "d1", false)]
|
||||
const next = list.map((item) => (item.session === "s1" ? { ...item, viewed: true } : item))
|
||||
|
||||
const before = buildNotificationIndex(list)
|
||||
const after = buildNotificationIndex(next)
|
||||
|
||||
expect(before.session.unseenCount.get("s1")).toBe(2)
|
||||
expect(before.session.unseenHasError.get("s1")).toBe(true)
|
||||
expect(before.project.unseenCount.get("d1")).toBe(3)
|
||||
expect(before.project.unseenHasError.get("d1")).toBe(true)
|
||||
|
||||
expect(after.session.unseenCount.get("s1") ?? 0).toBe(0)
|
||||
expect(after.session.unseenHasError.get("s1") ?? false).toBe(false)
|
||||
expect(after.project.unseenCount.get("d1")).toBe(1)
|
||||
expect(after.project.unseenHasError.get("d1") ?? false).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond"
|
||||
import { autoRespondsPermission } from "./permission-auto-respond"
|
||||
|
||||
const session = (input: { id: string; parentID?: string }) =>
|
||||
({
|
||||
@@ -60,43 +60,4 @@ describe("autoRespondsPermission", () => {
|
||||
|
||||
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
|
||||
})
|
||||
|
||||
test("falls back to directory-level auto-accept", () => {
|
||||
const directory = "/tmp/project"
|
||||
const sessions = [session({ id: "root" })]
|
||||
const autoAccept = {
|
||||
[`${base64Encode(directory)}/*`]: true,
|
||||
}
|
||||
|
||||
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true)
|
||||
})
|
||||
|
||||
test("session-level override takes precedence over directory-level", () => {
|
||||
const directory = "/tmp/project"
|
||||
const sessions = [session({ id: "root" })]
|
||||
const autoAccept = {
|
||||
[`${base64Encode(directory)}/*`]: true,
|
||||
[`${base64Encode(directory)}/root`]: false,
|
||||
}
|
||||
|
||||
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isDirectoryAutoAccepting", () => {
|
||||
test("returns true when directory key is set", () => {
|
||||
const directory = "/tmp/project"
|
||||
const autoAccept = { [`${base64Encode(directory)}/*`]: true }
|
||||
expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false when directory key is not set", () => {
|
||||
expect(isDirectoryAutoAccepting({}, "/tmp/project")).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false when directory key is explicitly false", () => {
|
||||
const directory = "/tmp/project"
|
||||
const autoAccept = { [`${base64Encode(directory)}/*`]: false }
|
||||
expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,19 +5,9 @@ export function acceptKey(sessionID: string, directory?: string) {
|
||||
return `${base64Encode(directory)}/${sessionID}`
|
||||
}
|
||||
|
||||
export function directoryAcceptKey(directory: string) {
|
||||
return `${base64Encode(directory)}/*`
|
||||
}
|
||||
|
||||
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
|
||||
const key = acceptKey(sessionID, directory)
|
||||
const directoryKey = directory ? directoryAcceptKey(directory) : undefined
|
||||
return autoAccept[key] ?? autoAccept[sessionID] ?? (directoryKey ? autoAccept[directoryKey] : undefined)
|
||||
}
|
||||
|
||||
export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
|
||||
const key = directoryAcceptKey(directory)
|
||||
return autoAccept[key] ?? false
|
||||
return autoAccept[key] ?? autoAccept[sessionID]
|
||||
}
|
||||
|
||||
function sessionLineage(session: { id: string; parentID?: string }[], sessionID: string) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createMemo, onCleanup } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
||||
@@ -7,12 +7,7 @@ import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import {
|
||||
acceptKey,
|
||||
directoryAcceptKey,
|
||||
isDirectoryAutoAccepting,
|
||||
autoRespondsPermission,
|
||||
} from "./permission-auto-respond"
|
||||
import { acceptKey, autoRespondsPermission } from "./permission-auto-respond"
|
||||
|
||||
type PermissionRespondFn = (input: {
|
||||
sessionID: string
|
||||
@@ -81,25 +76,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
}),
|
||||
)
|
||||
|
||||
// When config has permission: "allow", auto-enable directory-level auto-accept
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
const directory = decode64(params.dir)
|
||||
if (!directory) return
|
||||
const [childStore] = globalSync.child(directory)
|
||||
const perm = childStore.config.permission
|
||||
if (typeof perm === "string" && perm === "allow") {
|
||||
const key = directoryAcceptKey(directory)
|
||||
if (store.autoAccept[key] === undefined) {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = true
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const MAX_RESPONDED = 1000
|
||||
const RESPONDED_TTL_MS = 60 * 60 * 1000
|
||||
const responded = new Map<string, number>()
|
||||
@@ -143,10 +119,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
|
||||
}
|
||||
|
||||
function isAutoAcceptingDirectory(directory: string) {
|
||||
return isDirectoryAutoAccepting(store.autoAccept, directory)
|
||||
}
|
||||
|
||||
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
|
||||
const session = directory ? globalSync.child(directory, { bootstrap: false })[0].session : []
|
||||
return autoRespondsPermission(store.autoAccept, session, permission, directory)
|
||||
@@ -170,36 +142,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
})
|
||||
onCleanup(unsubscribe)
|
||||
|
||||
function enableDirectory(directory: string) {
|
||||
const key = directoryAcceptKey(directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = true
|
||||
}),
|
||||
)
|
||||
|
||||
globalSDK.client.permission
|
||||
.list({ directory })
|
||||
.then((x) => {
|
||||
if (!isAutoAcceptingDirectory(directory)) return
|
||||
for (const perm of x.data ?? []) {
|
||||
if (!perm?.id) continue
|
||||
if (!shouldAutoRespond(perm, directory)) continue
|
||||
respondOnce(perm, directory)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
function disableDirectory(directory: string) {
|
||||
const key = directoryAcceptKey(directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = false
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function enable(sessionID: string, directory: string) {
|
||||
const key = acceptKey(sessionID, directory)
|
||||
const version = bumpEnableVersion(sessionID, directory)
|
||||
@@ -243,7 +185,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
return shouldAutoRespond(permission, directory)
|
||||
},
|
||||
isAutoAccepting,
|
||||
isAutoAcceptingDirectory,
|
||||
toggleAutoAccept(sessionID: string, directory: string) {
|
||||
if (isAutoAccepting(sessionID, directory)) {
|
||||
disable(sessionID, directory)
|
||||
@@ -252,13 +193,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
|
||||
enable(sessionID, directory)
|
||||
},
|
||||
toggleAutoAcceptDirectory(directory: string) {
|
||||
if (isAutoAcceptingDirectory(directory)) {
|
||||
disableDirectory(directory)
|
||||
return
|
||||
}
|
||||
enableDirectory(directory)
|
||||
},
|
||||
enableAutoAccept(sessionID: string, directory: string) {
|
||||
if (isAutoAccepting(sessionID, directory)) return
|
||||
enable(sessionID, directory)
|
||||
@@ -267,11 +201,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
disable(sessionID, directory)
|
||||
},
|
||||
permissionsEnabled,
|
||||
isPermissionAllowAll(directory: string) {
|
||||
const [childStore] = globalSync.child(directory)
|
||||
const perm = childStore.config.permission
|
||||
return typeof perm === "string" && perm === "allow"
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
|
||||
import type { Accessor } from "solid-js"
|
||||
import { ServerConnection } from "./server"
|
||||
|
||||
type PickerPaths = string | string[] | null
|
||||
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
||||
@@ -59,10 +58,10 @@ export type Platform = {
|
||||
fetch?: typeof fetch
|
||||
|
||||
/** Get the configured default server URL (platform-specific) */
|
||||
getDefaultServer?(): Promise<ServerConnection.Key | null>
|
||||
getDefaultServerUrl?(): Promise<string | null>
|
||||
|
||||
/** Set the default server URL to use on app startup (platform-specific) */
|
||||
setDefaultServer?(url: ServerConnection.Key | null): Promise<void> | void
|
||||
setDefaultServerUrl?(url: string | null): Promise<void> | void
|
||||
|
||||
/** Get the configured WSL integration (desktop only) */
|
||||
getWslEnabled?(): Promise<boolean>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { useCheckServerHealth } from "@/utils/server-health"
|
||||
import { checkServerHealth } from "@/utils/server-health"
|
||||
|
||||
type StoredProject = { worktree: string; expanded: boolean }
|
||||
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
|
||||
@@ -95,7 +96,7 @@ export namespace ServerConnection {
|
||||
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
|
||||
name: "Server",
|
||||
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
const platform = usePlatform()
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("server", ["server.v3"]),
|
||||
@@ -196,7 +197,8 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
|
||||
const isReady = createMemo(() => ready() && !!state.active)
|
||||
|
||||
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http).then((x) => x.healthy)
|
||||
const fetcher = platform.fetch ?? globalThis.fetch
|
||||
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http, fetcher).then((x) => x.healthy)
|
||||
|
||||
createEffect(() => {
|
||||
const current_ = current()
|
||||
|
||||
@@ -23,8 +23,6 @@ export interface Settings {
|
||||
autoSave: boolean
|
||||
releaseNotes: boolean
|
||||
showReasoningSummaries: boolean
|
||||
shellToolPartsExpanded: boolean
|
||||
editToolPartsExpanded: boolean
|
||||
}
|
||||
updates: {
|
||||
startup: boolean
|
||||
@@ -46,8 +44,6 @@ const defaultSettings: Settings = {
|
||||
autoSave: true,
|
||||
releaseNotes: true,
|
||||
showReasoningSummaries: false,
|
||||
shellToolPartsExpanded: true,
|
||||
editToolPartsExpanded: false,
|
||||
},
|
||||
updates: {
|
||||
startup: true,
|
||||
@@ -133,20 +129,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setShowReasoningSummaries(value: boolean) {
|
||||
setStore("general", "showReasoningSummaries", value)
|
||||
},
|
||||
shellToolPartsExpanded: withFallback(
|
||||
() => store.general?.shellToolPartsExpanded,
|
||||
defaultSettings.general.shellToolPartsExpanded,
|
||||
),
|
||||
setShellToolPartsExpanded(value: boolean) {
|
||||
setStore("general", "shellToolPartsExpanded", value)
|
||||
},
|
||||
editToolPartsExpanded: withFallback(
|
||||
() => store.general?.editToolPartsExpanded,
|
||||
defaultSettings.general.editToolPartsExpanded,
|
||||
),
|
||||
setEditToolPartsExpanded(value: boolean) {
|
||||
setStore("general", "editToolPartsExpanded", value)
|
||||
},
|
||||
},
|
||||
updates: {
|
||||
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
|
||||
|
||||
@@ -3,16 +3,9 @@ import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { retry } from "@opencode-ai/util/retry"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import {
|
||||
clearSessionPrefetch,
|
||||
getSessionPrefetch,
|
||||
getSessionPrefetchPromise,
|
||||
setSessionPrefetch,
|
||||
} from "./global-sync/session-prefetch"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import { useSDK } from "./sdk"
|
||||
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[]) {
|
||||
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
||||
@@ -115,8 +108,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
const inflightDiff = 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({
|
||||
limit: {} as Record<string, number>,
|
||||
complete: {} as Record<string, boolean>,
|
||||
@@ -130,63 +121,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
return undefined
|
||||
}
|
||||
|
||||
const seenFor = (directory: string) => {
|
||||
const existing = seen.get(directory)
|
||||
if (existing) {
|
||||
seen.delete(directory)
|
||||
seen.set(directory, existing)
|
||||
return existing
|
||||
}
|
||||
const created = new Set<string>()
|
||||
seen.set(directory, created)
|
||||
while (seen.size > maxDirs) {
|
||||
const first = seen.keys().next().value
|
||||
if (!first) break
|
||||
const stale = [...(seen.get(first) ?? [])]
|
||||
seen.delete(first)
|
||||
const [, setStore] = globalSync.child(first, { bootstrap: false })
|
||||
evict(first, setStore, stale)
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
const clearMeta = (directory: string, sessionIDs: string[]) => {
|
||||
if (sessionIDs.length === 0) return
|
||||
setMeta(
|
||||
produce((draft) => {
|
||||
for (const sessionID of sessionIDs) {
|
||||
const key = keyFor(directory, sessionID)
|
||||
delete draft.limit[key]
|
||||
delete draft.complete[key]
|
||||
delete draft.loading[key]
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
|
||||
if (sessionIDs.length === 0) return
|
||||
clearSessionPrefetch(directory, sessionIDs)
|
||||
for (const sessionID of sessionIDs) {
|
||||
globalSync.todo.set(sessionID, undefined)
|
||||
}
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
dropSessionCaches(draft, sessionIDs)
|
||||
}),
|
||||
)
|
||||
clearMeta(directory, sessionIDs)
|
||||
}
|
||||
|
||||
const touch = (directory: string, setStore: Setter, sessionID: string) => {
|
||||
const stale = pickSessionCacheEvictions({
|
||||
seen: seenFor(directory),
|
||||
keep: sessionID,
|
||||
limit: SESSION_CACHE_LIMIT,
|
||||
})
|
||||
evict(directory, setStore, stale)
|
||||
}
|
||||
|
||||
const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => {
|
||||
const messages = await retry(() =>
|
||||
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
|
||||
@@ -201,8 +135,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
}
|
||||
}
|
||||
|
||||
const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false
|
||||
|
||||
const loadMessages = async (input: {
|
||||
directory: string
|
||||
client: typeof sdk.client
|
||||
@@ -216,7 +148,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
setMeta("loading", key, true)
|
||||
await fetchMessages(input)
|
||||
.then((next) => {
|
||||
if (!tracked(input.directory, input.sessionID)) return
|
||||
batch(() => {
|
||||
input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
|
||||
for (const p of next.part) {
|
||||
@@ -224,16 +155,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
}
|
||||
setMeta("limit", key, input.limit)
|
||||
setMeta("complete", key, next.complete)
|
||||
setSessionPrefetch({
|
||||
directory: input.directory,
|
||||
sessionID: input.sessionID,
|
||||
limit: input.limit,
|
||||
complete: next.complete,
|
||||
})
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
if (!tracked(input.directory, input.sessionID)) return
|
||||
setMeta("loading", key, false)
|
||||
})
|
||||
}
|
||||
@@ -275,7 +199,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
parts: Part[]
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string }
|
||||
variant?: string
|
||||
}) {
|
||||
const message: Message = {
|
||||
id: input.messageID,
|
||||
@@ -284,7 +207,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
time: { created: Date.now() },
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
variant: input.variant,
|
||||
}
|
||||
const [, setStore] = target()
|
||||
setOptimisticAdd(setStore as (...args: unknown[]) => void, {
|
||||
@@ -293,103 +215,67 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
parts: input.parts,
|
||||
})
|
||||
},
|
||||
async sync(sessionID: string, opts?: { force?: boolean }) {
|
||||
async sync(sessionID: string) {
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
const key = keyFor(directory, sessionID)
|
||||
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
|
||||
|
||||
touch(directory, setStore, sessionID)
|
||||
const limit = meta.limit[key] ?? messagePageSize
|
||||
|
||||
const seeded = getSessionPrefetch(directory, sessionID)
|
||||
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
|
||||
batch(() => {
|
||||
setMeta("limit", key, seeded.limit)
|
||||
setMeta("complete", key, seeded.complete)
|
||||
setMeta("loading", key, false)
|
||||
})
|
||||
}
|
||||
const sessionReq = hasSession
|
||||
? Promise.resolve()
|
||||
: retry(() => client.session.get({ sessionID })).then((session) => {
|
||||
const data = session.data
|
||||
if (!data) return
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft, sessionID, (s) => s.id)
|
||||
if (match.found) {
|
||||
draft[match.index] = data
|
||||
return
|
||||
}
|
||||
draft.splice(match.index, 0, data)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return runInflight(inflight, key, async () => {
|
||||
const pending = getSessionPrefetchPromise(directory, sessionID)
|
||||
if (pending) {
|
||||
await pending
|
||||
const seeded = getSessionPrefetch(directory, sessionID)
|
||||
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
|
||||
batch(() => {
|
||||
setMeta("limit", key, seeded.limit)
|
||||
setMeta("complete", key, seeded.complete)
|
||||
setMeta("loading", key, false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
|
||||
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
|
||||
if (cached && hasSession && !opts?.force) return
|
||||
|
||||
const limit = meta.limit[key] ?? messagePageSize
|
||||
const sessionReq =
|
||||
hasSession && !opts?.force
|
||||
? Promise.resolve()
|
||||
: retry(() => client.session.get({ sessionID })).then((session) => {
|
||||
if (!tracked(directory, sessionID)) return
|
||||
const data = session.data
|
||||
if (!data) return
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft, sessionID, (s) => s.id)
|
||||
if (match.found) {
|
||||
draft[match.index] = data
|
||||
return
|
||||
}
|
||||
draft.splice(match.index, 0, data)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const messagesReq =
|
||||
cached && !opts?.force
|
||||
? Promise.resolve()
|
||||
: loadMessages({
|
||||
directory,
|
||||
client,
|
||||
setStore,
|
||||
sessionID,
|
||||
limit,
|
||||
})
|
||||
|
||||
await Promise.all([sessionReq, messagesReq])
|
||||
const messagesReq = loadMessages({
|
||||
directory,
|
||||
client,
|
||||
setStore,
|
||||
sessionID,
|
||||
limit,
|
||||
})
|
||||
|
||||
return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {}))
|
||||
},
|
||||
async diff(sessionID: string, opts?: { force?: boolean }) {
|
||||
async diff(sessionID: string) {
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
touch(directory, setStore, sessionID)
|
||||
if (store.session_diff[sessionID] !== undefined && !opts?.force) return
|
||||
if (store.session_diff[sessionID] !== undefined) return
|
||||
|
||||
const key = keyFor(directory, sessionID)
|
||||
return runInflight(inflightDiff, key, () =>
|
||||
retry(() => client.session.diff({ sessionID })).then((diff) => {
|
||||
if (!tracked(directory, sessionID)) return
|
||||
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
|
||||
}),
|
||||
)
|
||||
},
|
||||
async todo(sessionID: string, opts?: { force?: boolean }) {
|
||||
async todo(sessionID: string) {
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
touch(directory, setStore, sessionID)
|
||||
const existing = store.todo[sessionID]
|
||||
const cached = globalSync.data.session_todo[sessionID]
|
||||
if (existing !== undefined) {
|
||||
if (cached === undefined) {
|
||||
globalSync.todo.set(sessionID, existing)
|
||||
}
|
||||
if (!opts?.force) return
|
||||
return
|
||||
}
|
||||
|
||||
if (cached !== undefined) {
|
||||
@@ -399,7 +285,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const key = keyFor(directory, sessionID)
|
||||
return runInflight(inflightTodo, key, () =>
|
||||
retry(() => client.session.todo({ sessionID })).then((todo) => {
|
||||
if (!tracked(directory, sessionID)) return
|
||||
const list = todo.data ?? []
|
||||
setStore("todo", sessionID, reconcile(list, { key: "id" }))
|
||||
globalSync.todo.set(sessionID, list)
|
||||
@@ -423,7 +308,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [, setStore] = globalSync.child(directory)
|
||||
touch(directory, setStore, sessionID)
|
||||
const key = keyFor(directory, sessionID)
|
||||
const step = count ?? messagePageSize
|
||||
if (meta.loading[key]) return
|
||||
@@ -439,11 +323,6 @@ 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) => {
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
|
||||
@@ -2,7 +2,6 @@ import { beforeAll, describe, expect, mock, test } from "bun:test"
|
||||
|
||||
let getWorkspaceTerminalCacheKey: (dir: string) => string
|
||||
let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
|
||||
let migrateTerminalState: (value: unknown) => unknown
|
||||
|
||||
beforeAll(async () => {
|
||||
mock.module("@solidjs/router", () => ({
|
||||
@@ -18,7 +17,6 @@ beforeAll(async () => {
|
||||
const mod = await import("./terminal")
|
||||
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
|
||||
getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys
|
||||
migrateTerminalState = mod.migrateTerminalState
|
||||
})
|
||||
|
||||
describe("getWorkspaceTerminalCacheKey", () => {
|
||||
@@ -39,44 +37,3 @@ 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 { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "solid-js"
|
||||
import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useSDK } from "./sdk"
|
||||
import type { Platform } from "./platform"
|
||||
@@ -20,71 +20,6 @@ export type LocalPTY = {
|
||||
const WORKSPACE_KEY = "__workspace__"
|
||||
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) {
|
||||
return `${dir}:${WORKSPACE_KEY}`
|
||||
}
|
||||
@@ -103,16 +38,6 @@ type 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) {
|
||||
const key = getWorkspaceTerminalCacheKey(dir)
|
||||
for (const cache of caches) {
|
||||
@@ -136,11 +61,16 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat
|
||||
function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, legacySessionID?: string) {
|
||||
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(
|
||||
{
|
||||
...Persist.workspace(dir, "terminal", legacy),
|
||||
migrate: migrateTerminalState,
|
||||
},
|
||||
Persist.workspace(dir, "terminal", legacy),
|
||||
createStore<{
|
||||
active?: string
|
||||
all: LocalPTY[]
|
||||
@@ -188,6 +118,26 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
|
||||
})
|
||||
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 {
|
||||
ready,
|
||||
all: createMemo(() => store.all),
|
||||
@@ -238,18 +188,6 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
|
||||
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) {
|
||||
const index = store.all.findIndex((x) => x.id === id)
|
||||
const pty = store.all[index]
|
||||
@@ -384,27 +322,12 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
|
||||
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 {
|
||||
ready: () => workspace().ready(),
|
||||
all: () => workspace().all(),
|
||||
active: () => workspace().active(),
|
||||
new: () => workspace().new(),
|
||||
update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty),
|
||||
trim: (id: string) => workspace().trim(id),
|
||||
trimAll: () => workspace().trimAll(),
|
||||
clone: (id: string) => workspace().clone(id),
|
||||
open: (id: string) => workspace().open(id),
|
||||
close: (id: string) => workspace().close(id),
|
||||
|
||||
@@ -98,19 +98,6 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) {
|
||||
throw new Error(getRootNotFoundError())
|
||||
}
|
||||
|
||||
const getCurrentUrl = () => {
|
||||
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
||||
if (import.meta.env.DEV)
|
||||
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||
return location.origin
|
||||
}
|
||||
|
||||
const getDefaultUrl = () => {
|
||||
const lsDefault = readDefaultServerUrl()
|
||||
if (lsDefault) return lsDefault
|
||||
return getCurrentUrl()
|
||||
}
|
||||
|
||||
const platform: Platform = {
|
||||
platform: "web",
|
||||
version: pkg.version,
|
||||
@@ -119,20 +106,26 @@ const platform: Platform = {
|
||||
forward,
|
||||
restart,
|
||||
notify,
|
||||
getDefaultServer: async () => {
|
||||
const stored = readDefaultServerUrl()
|
||||
return stored ? ServerConnection.Key.make(stored) : null
|
||||
},
|
||||
setDefaultServer: writeDefaultServerUrl,
|
||||
getDefaultServerUrl: async () => readDefaultServerUrl(),
|
||||
setDefaultServerUrl: writeDefaultServerUrl,
|
||||
}
|
||||
|
||||
const defaultUrl = iife(() => {
|
||||
const lsDefault = readDefaultServerUrl()
|
||||
if (lsDefault) return lsDefault
|
||||
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
||||
if (import.meta.env.DEV)
|
||||
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||
return location.origin
|
||||
})
|
||||
|
||||
if (root instanceof HTMLElement) {
|
||||
const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } }
|
||||
const server: ServerConnection.Http = { type: "http", http: { url: defaultUrl } }
|
||||
render(
|
||||
() => (
|
||||
<PlatformProvider value={platform}>
|
||||
<AppBaseProviders>
|
||||
<AppInterface defaultServer={ServerConnection.Key.make(getDefaultUrl())} servers={[server]} />
|
||||
<AppInterface defaultServer={ServerConnection.key(server)} servers={[server]} />
|
||||
</AppBaseProviders>
|
||||
</PlatformProvider>
|
||||
),
|
||||
|
||||
@@ -18,27 +18,25 @@ const popularProviderSet = new Set(popularProviders)
|
||||
export function useProviders() {
|
||||
const globalSync = useGlobalSync()
|
||||
const params = useParams()
|
||||
const dir = createMemo(() => decode64(params.dir) ?? "")
|
||||
const providers = () => {
|
||||
if (dir()) {
|
||||
const [projectStore] = globalSync.child(dir())
|
||||
const currentDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||
const providers = createMemo(() => {
|
||||
if (currentDirectory()) {
|
||||
const [projectStore] = globalSync.child(currentDirectory())
|
||||
return projectStore.provider
|
||||
}
|
||||
return globalSync.data.provider
|
||||
}
|
||||
})
|
||||
const connectedIDs = createMemo(() => new Set(providers().connected))
|
||||
const connected = createMemo(() => providers().all.filter((p) => connectedIDs().has(p.id)))
|
||||
const paid = createMemo(() =>
|
||||
connected().filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input)),
|
||||
)
|
||||
const popular = createMemo(() => providers().all.filter((p) => popularProviderSet.has(p.id)))
|
||||
return {
|
||||
all: () => providers().all,
|
||||
default: () => providers().default,
|
||||
popular: () => providers().all.filter((p) => popularProviderSet.has(p.id)),
|
||||
connected: () => {
|
||||
const connected = new Set(providers().connected)
|
||||
return providers().all.filter((p) => connected.has(p.id))
|
||||
},
|
||||
paid: () => {
|
||||
const connected = new Set(providers().connected)
|
||||
return providers().all.filter(
|
||||
(p) => connected.has(p.id) && (p.id !== "opencode" || Object.values(p.models).some((m) => m.cost?.input)),
|
||||
)
|
||||
},
|
||||
all: createMemo(() => providers().all),
|
||||
default: createMemo(() => providers().default),
|
||||
popular,
|
||||
connected,
|
||||
paid,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,7 +456,6 @@ export const dict = {
|
||||
"session.todo.title": "المهام",
|
||||
"session.todo.collapse": "طي",
|
||||
"session.todo.expand": "توسيع",
|
||||
"session.new.title": "ابنِ أي شيء",
|
||||
"session.new.worktree.main": "الفرع الرئيسي",
|
||||
"session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})",
|
||||
"session.new.worktree.create": "إنشاء شجرة عمل جديدة",
|
||||
@@ -542,12 +541,6 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "تخصيص سمة OpenCode.",
|
||||
"settings.general.row.font.title": "الخط",
|
||||
"settings.general.row.font.description": "تخصيص الخط الأحادي المستخدم في كتل التعليمات البرمجية",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "توسيع أجزاء أداة shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"إظهار أجزاء أداة shell موسعة بشكل افتراضي في الشريط الزمني",
|
||||
"settings.general.row.editToolPartsExpanded.title": "توسيع أجزاء أداة edit",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"إظهار أجزاء أدوات edit و write و patch موسعة بشكل افتراضي في الشريط الزمني",
|
||||
"settings.general.row.wayland.title": "استخدام Wayland الأصلي",
|
||||
"settings.general.row.wayland.description": "تعطيل التراجع إلى X11 على Wayland. يتطلب إعادة التشغيل.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user