Compare commits

..
1 Commits
627 changed files with 19779 additions and 34089 deletions
-2
View File
@@ -25,9 +25,7 @@ kommander
-opencodeengineer bot that spams issues -opencodeengineer bot that spams issues
r44vc0rp r44vc0rp
rekram1-node rekram1-node
-ricardo-m-l
-robinmordasiewicz -robinmordasiewicz
simonklee
-spider-yamet clawdbot/llm psychosis, spam pinging the team -spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr thdxr
-toastythebot -toastythebot
+1 -2
View File
@@ -389,7 +389,6 @@ jobs:
needs: needs:
- build-cli - build-cli
- version - version
if: github.repository == 'anomalyco/opencode'
continue-on-error: false continue-on-error: false
env: env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@@ -422,6 +421,7 @@ jobs:
target: aarch64-unknown-linux-gnu target: aarch64-unknown-linux-gnu
platform_flag: --linux platform_flag: --linux
runs-on: ${{ matrix.settings.host }} runs-on: ${{ matrix.settings.host }}
# if: github.ref_name == 'beta'
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
@@ -547,7 +547,6 @@ jobs:
- sign-cli-windows - sign-cli-windows
- build-tauri - build-tauri
- build-electron - build-electron
if: always() && !failure() && !cancelled()
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: blacksmith-4vcpu-ubuntu-2404
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
+5 -49
View File
@@ -15,10 +15,6 @@ concurrency:
permissions: permissions:
contents: read contents: read
checks: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs: jobs:
unit: unit:
@@ -41,11 +37,6 @@ jobs:
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Setup Bun - name: Setup Bun
uses: ./.github/actions/setup-bun uses: ./.github/actions/setup-bun
@@ -54,39 +45,8 @@ jobs:
git config --global user.email "bot@opencode.ai" git config --global user.email "bot@opencode.ai"
git config --global user.name "opencode" git config --global user.name "opencode"
- name: Cache Turbo
uses: actions/cache@v4
with:
path: node_modules/.cache/turbo
key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-
turbo-${{ runner.os }}-
- name: Run unit tests - name: Run unit tests
run: bun turbo test:ci run: bun turbo test
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
- name: Publish unit reports
if: always()
uses: mikepenz/action-junit-report@v6
with:
report_paths: packages/*/.artifacts/unit/junit.xml
check_name: "unit results (${{ matrix.settings.name }})"
detailed_summary: true
include_time_in_summary: true
fail_on_failure: false
- name: Upload unit artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }}
include-hidden-files: true
if-no-files-found: ignore
retention-days: 7
path: packages/*/.artifacts/unit/junit.xml
e2e: e2e:
name: e2e (${{ matrix.settings.name }}) name: e2e (${{ matrix.settings.name }})
@@ -110,11 +70,6 @@ jobs:
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Setup Bun - name: Setup Bun
uses: ./.github/actions/setup-bun uses: ./.github/actions/setup-bun
@@ -145,17 +100,18 @@ jobs:
run: bun --cwd packages/app test:e2e:local run: bun --cwd packages/app test:e2e:local
env: env:
CI: true CI: true
PLAYWRIGHT_JUNIT_OUTPUT: e2e/junit-${{ matrix.settings.name }}.xml OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENCODE_E2E_MODEL: opencode/claude-haiku-4-5
OPENCODE_E2E_REQUIRE_PAID: "true"
timeout-minutes: 30 timeout-minutes: 30
- name: Upload Playwright artifacts - name: Upload Playwright artifacts
if: always() if: failure()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }} name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }}
if-no-files-found: ignore if-no-files-found: ignore
retention-days: 7 retention-days: 7
path: | path: |
packages/app/e2e/junit-*.xml
packages/app/e2e/test-results packages/app/e2e/test-results
packages/app/e2e/playwright-report packages/app/e2e/playwright-report
+7 -53
View File
@@ -653,30 +653,23 @@ const home = (api: TuiPluginApi, input: Cfg) => ({
const skin = look(ctx.theme.current) const skin = look(ctx.theme.current)
type Prompt = (props: { type Prompt = (props: {
workspaceID?: string workspaceID?: string
visible?: boolean
disabled?: boolean
onSubmit?: () => void
hint?: JSX.Element hint?: JSX.Element
right?: JSX.Element
showPlaceholder?: boolean
placeholders?: { placeholders?: {
normal?: string[] normal?: string[]
shell?: string[] shell?: string[]
} }
}) => JSX.Element }) => JSX.Element
type Slot = ( if (!("Prompt" in api.ui)) return null
props: { name: string; mode?: unknown; children?: JSX.Element } & Record<string, unknown>, const view = api.ui.Prompt
) => JSX.Element | null if (typeof view !== "function") return null
const ui = api.ui as TuiPluginApi["ui"] & { Prompt: Prompt; Slot: Slot } const Prompt = view as Prompt
const Prompt = ui.Prompt
const Slot = ui.Slot
const normal = [ const normal = [
`[SMOKE] route check for ${input.label}`, `[SMOKE] route check for ${input.label}`,
"[SMOKE] confirm home_prompt slot override", "[SMOKE] confirm home_prompt slot override",
"[SMOKE] verify prompt-right slot passthrough", "[SMOKE] verify api.ui.Prompt rendering",
] ]
const shell = ["printf '[SMOKE] home prompt\n'", "git status --short", "bun --version"] const shell = ["printf '[SMOKE] home prompt\n'", "git status --short", "bun --version"]
const hint = ( const Hint = (
<box flexShrink={0} flexDirection="row" gap={1}> <box flexShrink={0} flexDirection="row" gap={1}>
<text fg={skin.muted}> <text fg={skin.muted}>
<span style={{ fg: skin.accent }}></span> smoke home prompt <span style={{ fg: skin.accent }}></span> smoke home prompt
@@ -684,46 +677,7 @@ const home = (api: TuiPluginApi, input: Cfg) => ({
</box> </box>
) )
return ( return <Prompt workspaceID={value.workspace_id} hint={Hint} placeholders={{ normal, shell }} />
<Prompt
workspaceID={value.workspace_id}
hint={hint}
right={
<box flexDirection="row" gap={1}>
<Slot name="home_prompt_right" workspace_id={value.workspace_id} />
<Slot name="smoke_prompt_right" workspace_id={value.workspace_id} label={input.label} />
</box>
}
placeholders={{ normal, shell }}
/>
)
},
home_prompt_right(ctx, value) {
const skin = look(ctx.theme.current)
const id = value.workspace_id?.slice(0, 8) ?? "none"
return (
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}>{input.label}</span> home:{id}
</text>
)
},
session_prompt_right(ctx, value) {
const skin = look(ctx.theme.current)
return (
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}>{input.label}</span> session:{value.session_id.slice(0, 8)}
</text>
)
},
smoke_prompt_right(ctx, value) {
const skin = look(ctx.theme.current)
const id = typeof value.workspace_id === "string" ? value.workspace_id.slice(0, 8) : "none"
const label = typeof value.label === "string" ? value.label : input.label
return (
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}>{label}</span> custom:{id}
</text>
)
}, },
home_bottom(ctx) { home_bottom(ctx) {
const skin = look(ctx.theme.current) const skin = look(ctx.theme.current)
+523 -898
View File
File diff suppressed because it is too large Load Diff
-8
View File
@@ -109,12 +109,6 @@ const zenLiteCouponFirstMonth50 = new stripe.Coupon("ZenLiteCouponFirstMonth50",
appliesToProducts: [zenLiteProduct.id], appliesToProducts: [zenLiteProduct.id],
duration: "once", duration: "once",
}) })
const zenLiteCouponFirstMonth100 = new stripe.Coupon("ZenLiteCouponFirstMonth100", {
name: "First month 100% off",
percentOff: 100,
appliesToProducts: [zenLiteProduct.id],
duration: "once",
})
const zenLitePrice = new stripe.Price("ZenLitePrice", { const zenLitePrice = new stripe.Price("ZenLitePrice", {
product: zenLiteProduct.id, product: zenLiteProduct.id,
currency: "usd", currency: "usd",
@@ -130,7 +124,6 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
price: zenLitePrice.id, price: zenLitePrice.id,
priceInr: 92900, priceInr: 92900,
firstMonth50Coupon: zenLiteCouponFirstMonth50.id, firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
firstMonth100Coupon: zenLiteCouponFirstMonth100.id,
}, },
}) })
@@ -236,7 +229,6 @@ new sst.cloudflare.x.SolidStart("Console", {
SALESFORCE_INSTANCE_URL, SALESFORCE_INSTANCE_URL,
ZEN_BLACK_PRICE, ZEN_BLACK_PRICE,
ZEN_LITE_PRICE, ZEN_LITE_PRICE,
new sst.Secret("ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES"),
new sst.Secret("ZEN_LIMITS"), new sst.Secret("ZEN_LIMITS"),
new sst.Secret("ZEN_SESSION_SECRET"), new sst.Secret("ZEN_SESSION_SECRET"),
...ZEN_MODELS, ...ZEN_MODELS,
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-g29OM3dy+sZ3ioTs8zjQOK1N+KnNr9ptP9xtdPcdr64=", "x86_64-linux": "sha256-bjfe8/aD0hvUQQEfaNdmKV/Y3dzpf8oz1OUJdgf61WI=",
"aarch64-linux": "sha256-Iu91KwDcV5omkf4Ngny1aYpyCkPLjuoWOVUDOJUhW1k=", "aarch64-linux": "sha256-iU9v+ekSCB/qTUG+pOOpSMhPh+0hWnWU5jzDNllEkxU=",
"aarch64-darwin": "sha256-bk3G6m+Yo60Ea3Kyglc37QZf5Vm7MLMFcxemjc7HnL0=", "aarch64-darwin": "sha256-SgNydQLeAjbX0J49f2VKcgKg2Y30pK826R2qQJBMWE4=",
"x86_64-darwin": "sha256-y3hooQw13Z3Cu0KFfXYdpkTEeKTyuKd+a/jsXHQLdqA=" "x86_64-darwin": "sha256-/rzwNuI9x55qi0UcU7QvPUTupErmkt62T09g1omXkQk="
} }
} }
+7 -11
View File
@@ -12,7 +12,6 @@
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev", "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook", "dev:storybook": "bun --cwd packages/storybook storybook",
"typecheck": "bun turbo typecheck", "typecheck": "bun turbo typecheck",
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
"prepare": "husky", "prepare": "husky",
"random": "echo 'Random script'", "random": "echo 'Random script'",
"hello": "echo 'Hello World!'", "hello": "echo 'Hello World!'",
@@ -26,9 +25,8 @@
"packages/slack" "packages/slack"
], ],
"catalog": { "catalog": {
"@effect/platform-node": "4.0.0-beta.46", "@effect/platform-node": "4.0.0-beta.43",
"@types/bun": "1.3.11", "@types/bun": "1.3.11",
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"ulid": "3.0.1", "ulid": "3.0.1",
@@ -47,9 +45,8 @@
"dompurify": "3.3.1", "dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.46", "effect": "4.0.0-beta.43",
"ai": "6.0.158", "ai": "6.0.138",
"cross-spawn": "7.0.6",
"hono": "4.10.7", "hono": "4.10.7",
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
@@ -71,8 +68,7 @@
"@solidjs/router": "0.15.4", "@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"solid-js": "1.9.10", "solid-js": "1.9.10",
"vite-plugin-solid": "2.11.10", "vite-plugin-solid": "2.11.10"
"@lydell/node-pty": "1.2.0-beta.10"
} }
}, },
"devDependencies": { "devDependencies": {
@@ -92,7 +88,6 @@
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"heap-snapshot-toolkit": "1.1.3",
"typescript": "catalog:" "typescript": "catalog:"
}, },
"repository": { "repository": {
@@ -106,7 +101,6 @@
}, },
"trustedDependencies": [ "trustedDependencies": [
"esbuild", "esbuild",
"node-pty",
"protobufjs", "protobufjs",
"tree-sitter", "tree-sitter",
"tree-sitter-bash", "tree-sitter-bash",
@@ -120,6 +114,8 @@
}, },
"patchedDependencies": { "patchedDependencies": {
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch" "solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/provider-utils@4.0.21": "patches/@ai-sdk%2Fprovider-utils@4.0.21.patch",
"@ai-sdk/anthropic@3.0.64": "patches/@ai-sdk%2Fanthropic@3.0.64.patch"
} }
} }
+8 -7
View File
@@ -59,10 +59,8 @@ test("test description", async ({ page, sdk, gotoSession }) => {
### Using Fixtures ### Using Fixtures
- `page` - Playwright page - `page` - Playwright page
- `llm` - Mock LLM server for queuing responses (`text`, `tool`, `toolMatch`, `textMatch`, etc.) - `sdk` - OpenCode SDK client for API calls
- `project` - Golden-path project fixture (call `project.open()` first, then use `project.sdk`, `project.prompt(...)`, `project.gotoSession(...)`, `project.trackSession(...)`) - `gotoSession(sessionID?)` - Navigate to session
- `sdk` - OpenCode SDK client for API calls (worker-scoped, shared directory)
- `gotoSession(sessionID?)` - Navigate to session (worker-scoped, shared directory)
### Helper Functions ### Helper Functions
@@ -75,9 +73,12 @@ test("test description", async ({ page, sdk, gotoSession }) => {
- `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output - `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 - `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output
- `withSession(sdk, title, callback)` - Create temp session - `withSession(sdk, title, callback)` - Create temp session
- `withProject(...)` - Create temp project/workspace
- `sessionIDFromUrl(url)` - Read session ID from URL - `sessionIDFromUrl(url)` - Read session ID from URL
- `slugFromUrl(url)` - Read workspace slug from URL - `slugFromUrl(url)` - Read workspace slug from URL
- `waitSlug(page, skip?)` - Wait for resolved workspace slug - `waitSlug(page, skip?)` - Wait for resolved workspace slug
- `trackSession(sessionID, directory?)` - Register session for fixture cleanup
- `trackDirectory(directory)` - Register directory for fixture cleanup
- `clickListItem(container, filter)` - Click list item by key/text - `clickListItem(container, filter)` - Click list item by key/text
**Selectors** (`selectors.ts`): **Selectors** (`selectors.ts`):
@@ -127,9 +128,9 @@ test("test with cleanup", async ({ page, sdk, gotoSession }) => {
}) })
``` ```
- Prefer the `project` fixture for tests that need a dedicated project with LLM mocking — call `project.open()` then use `project.prompt(...)`, `project.trackSession(...)`, etc. - Prefer `withSession(...)` for temp sessions
- Use `withSession(sdk, title, callback)` for lightweight temp sessions on the shared worker directory - In `withProject(...)` tests that create sessions or extra workspaces, call `trackSession(sessionID, directory?)` and `trackDirectory(directory)`
- Call `project.trackSession(sessionID, directory?)` and `project.trackDirectory(directory)` for any resources created outside the fixture so teardown can clean them up - This lets fixture teardown abort, wait for idle, and clean up safely under CI concurrency
- Avoid calling `sdk.session.delete(...)` directly - Avoid calling `sdk.session.delete(...)` directly
### Timeouts ### Timeouts
+153 -67
View File
@@ -7,6 +7,7 @@ import { execSync } from "node:child_process"
import { terminalAttr, type E2EWindow } from "../src/testing/terminal" import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils" import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
import { import {
dropdownMenuTriggerSelector,
dropdownMenuContentSelector, dropdownMenuContentSelector,
projectSwitchSelector, projectSwitchSelector,
projectMenuTriggerSelector, projectMenuTriggerSelector,
@@ -205,7 +206,7 @@ export async function closeDialog(page: Page, dialog: Locator) {
await expect(dialog).toHaveCount(0) await expect(dialog).toHaveCount(0)
} }
async function isSidebarClosed(page: Page) { export async function isSidebarClosed(page: Page) {
const button = await waitSidebarButton(page, "isSidebarClosed") const button = await waitSidebarButton(page, "isSidebarClosed")
return (await button.getAttribute("aria-expanded")) !== "true" return (await button.getAttribute("aria-expanded")) !== "true"
} }
@@ -236,7 +237,7 @@ async function errorBoundaryText(page: Page) {
return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n") return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
} }
async function assertHealthy(page: Page, context: string) { export async function assertHealthy(page: Page, context: string) {
const text = await errorBoundaryText(page) const text = await errorBoundaryText(page)
if (!text) return if (!text) return
console.log(`[e2e:error-boundary][${context}]\n${text}`) console.log(`[e2e:error-boundary][${context}]\n${text}`)
@@ -311,6 +312,63 @@ export async function openSettings(page: Page) {
return dialog return dialog
} }
export async function seedProjects(page: Page, input: { directory: string; extra?: string[]; serverUrl?: string }) {
await page.addInitScript(
(args: { directory: string; serverUrl: string; extra: string[] }) => {
const key = "opencode.global.dat:server"
const defaultKey = "opencode.settings.dat:defaultServerUrl"
const raw = localStorage.getItem(key)
const parsed = (() => {
if (!raw) return undefined
try {
return JSON.parse(raw) as unknown
} catch {
return undefined
}
})()
const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
const list = Array.isArray(store.list) ? store.list : []
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
const nextProjects = { ...(projects as Record<string, unknown>) }
const nextList = list.includes(args.serverUrl) ? list : [args.serverUrl, ...list]
const add = (origin: string, directory: string) => {
const current = nextProjects[origin]
const items = Array.isArray(current) ? current : []
const existing = items.filter(
(p): p is { worktree: string; expanded?: boolean } =>
!!p &&
typeof p === "object" &&
"worktree" in p &&
typeof (p as { worktree?: unknown }).worktree === "string",
)
if (existing.some((p) => p.worktree === directory)) return
nextProjects[origin] = [{ worktree: directory, expanded: true }, ...existing]
}
const directories = [args.directory, ...args.extra]
for (const directory of directories) {
add("local", directory)
add(args.serverUrl, directory)
}
localStorage.setItem(
key,
JSON.stringify({
list: nextList,
projects: nextProjects,
lastProject,
}),
)
localStorage.setItem(defaultKey, args.serverUrl)
},
{ directory: input.directory, serverUrl: input.serverUrl ?? serverUrl, extra: input.extra ?? [] },
)
}
export async function createTestProject(input?: { serverUrl?: string }) { export async function createTestProject(input?: { serverUrl?: string }) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-")) const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
const id = `e2e-${path.basename(root)}` const id = `e2e-${path.basename(root)}`
@@ -320,7 +378,6 @@ export async function createTestProject(input?: { serverUrl?: string }) {
execSync("git init", { cwd: root, stdio: "ignore" }) execSync("git init", { cwd: root, stdio: "ignore" })
await fs.writeFile(path.join(root, ".git", "opencode"), id) await fs.writeFile(path.join(root, ".git", "opencode"), id)
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" }) execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
execSync("git config commit.gpgsign false", { cwd: root, stdio: "ignore" })
execSync("git add -A", { cwd: root, stdio: "ignore" }) execSync("git add -A", { cwd: root, stdio: "ignore" })
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', { execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
cwd: root, cwd: root,
@@ -401,15 +458,7 @@ export async function waitDir(page: Page, directory: string, input?: { serverUrl
return { directory: target, slug: base64Encode(target) } return { directory: target, slug: base64Encode(target) }
} }
export async function waitSession( export async function waitSession(page: Page, input: { directory: string; sessionID?: string; serverUrl?: string }) {
page: Page,
input: {
directory: string
sessionID?: string
serverUrl?: string
allowAnySession?: boolean
},
) {
const target = await resolveDirectory(input.directory, input.serverUrl) const target = await resolveDirectory(input.directory, input.serverUrl)
await expect await expect
.poll( .poll(
@@ -421,11 +470,11 @@ export async function waitSession(
if (!resolved || resolved.directory !== target) return false if (!resolved || resolved.directory !== target) return false
const current = sessionIDFromUrl(page.url()) const current = sessionIDFromUrl(page.url())
if (input.sessionID && current !== input.sessionID) return false if (input.sessionID && current !== input.sessionID) return false
if (!input.sessionID && !input.allowAnySession && current) return false if (!input.sessionID && current) return false
const state = await probeSession(page) const state = await probeSession(page)
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
if (!input.sessionID && !input.allowAnySession && state?.sessionID) return false if (!input.sessionID && state?.sessionID) return false
if (state?.dir) { if (state?.dir) {
const dir = await resolveDirectory(state.dir, input.serverUrl).catch(() => state.dir ?? "") const dir = await resolveDirectory(state.dir, input.serverUrl).catch(() => state.dir ?? "")
if (dir !== target) return false if (dir !== target) return false
@@ -532,15 +581,12 @@ export async function confirmDialog(page: Page, buttonName: string | RegExp) {
} }
export async function openSharePopover(page: Page) { export async function openSharePopover(page: Page) {
const scroller = page.locator(".scroll-view__viewport").first() const rightSection = page.locator(titlebarRightSelector)
await expect(scroller).toBeVisible() const shareButton = rightSection.getByRole("button", { name: "Share" }).first()
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 }) await expect(shareButton).toBeVisible()
const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
await expect(menuTrigger).toBeVisible({ timeout: 30_000 })
const popoverBody = page const popoverBody = page
.locator('[data-component="popover-content"]') .locator(popoverBodySelector)
.filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) }) .filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
.first() .first()
@@ -550,13 +596,16 @@ export async function openSharePopover(page: Page) {
.catch(() => false) .catch(() => false)
if (!opened) { if (!opened) {
const menu = page.locator(dropdownMenuContentSelector).first() await shareButton.click()
await menuTrigger.click() await expect(popoverBody).toBeVisible()
await clickMenuItem(menu, /share/i)
await expect(menu).toHaveCount(0)
await expect(popoverBody).toBeVisible({ timeout: 30_000 })
} }
return { rightSection: scroller, popoverBody } return { rightSection, popoverBody }
}
export async function clickPopoverButton(page: Page, buttonName: string | RegExp) {
const button = page.getByRole("button").filter({ hasText: buttonName }).first()
await expect(button).toBeVisible()
await button.click()
} }
export async function clickListItem( export async function clickListItem(
@@ -724,6 +773,40 @@ export async function seedSessionQuestion(
return { id: result.id } return { id: result.id }
} }
export async function seedSessionPermission(
sdk: ReturnType<typeof createSdk>,
input: {
sessionID: string
permission: string
patterns: string[]
description?: string
},
) {
const text = [
"Your only valid response is one bash tool call.",
`Use this JSON input: ${JSON.stringify({
command: input.patterns[0] ? `ls ${JSON.stringify(input.patterns[0])}` : "pwd",
workdir: "/",
description: input.description ?? `seed ${input.permission} permission request`,
})}`,
"Do not output plain text.",
].join("\n")
const result = await seed({
sdk,
sessionID: input.sessionID,
prompt: text,
timeout: 30_000,
probe: async () => {
const list = await sdk.permission.list().then((x) => x.data ?? [])
return list.find((item) => item.sessionID === input.sessionID)
},
})
if (!result) throw new Error("Timed out seeding permission request")
return { id: result.id }
}
export async function seedSessionTask( export async function seedSessionTask(
sdk: ReturnType<typeof createSdk>, sdk: ReturnType<typeof createSdk>,
input: { input: {
@@ -782,6 +865,36 @@ export async function seedSessionTask(
return result return result
} }
export async function seedSessionTodos(
sdk: ReturnType<typeof createSdk>,
input: {
sessionID: string
todos: Array<{ content: string; status: string; priority: string }>
},
) {
const text = [
"Your only valid response is one todowrite tool call.",
`Use this JSON input: ${JSON.stringify({ todos: input.todos })}`,
"Do not output plain text.",
].join("\n")
const target = JSON.stringify(input.todos)
const result = await seed({
sdk,
sessionID: input.sessionID,
prompt: text,
timeout: 30_000,
probe: async () => {
const todos = await sdk.session.todo({ sessionID: input.sessionID }).then((x) => x.data ?? [])
if (JSON.stringify(todos) !== target) return
return true
},
})
if (!result) throw new Error("Timed out seeding todos")
return true
}
export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) { export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
const [questions, permissions] = await Promise.all([ const [questions, permissions] = await Promise.all([
sdk.question.list().then((x) => x.data ?? []), sdk.question.list().then((x) => x.data ?? []),
@@ -871,57 +984,30 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
} }
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) { export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
const current = () => const current = await page
page .getByRole("button", { name: "New workspace" })
.getByRole("button", { name: "New workspace" }) .first()
.first() .isVisible()
.isVisible() .then((x) => x)
.then((x) => x) .catch(() => false)
.catch(() => false)
if ((await current()) === enabled) return if (current === enabled) return
if (enabled) {
await page.reload()
await openSidebar(page)
if ((await current()) === enabled) return
}
const flip = async (timeout?: number) => { const flip = async (timeout?: number) => {
const menu = await openProjectMenu(page, projectSlug) const menu = await openProjectMenu(page, projectSlug)
const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first() const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
await expect(toggle).toBeVisible() await expect(toggle).toBeVisible()
await expect(toggle).toBeEnabled({ timeout: 30_000 }) return toggle.click({ force: true, timeout })
const clicked = await toggle
.click({ force: true, timeout })
.then(() => true)
.catch(() => false)
if (clicked) return
await toggle.focus()
await page.keyboard.press("Enter")
} }
for (const timeout of [1500, undefined, undefined]) { const flipped = await flip(1500)
if ((await current()) === enabled) break .then(() => true)
await flip(timeout) .catch(() => false)
.then(() => undefined)
.catch(() => undefined)
const matched = await expect
.poll(current, { timeout: 5_000 })
.toBe(enabled)
.then(() => true)
.catch(() => false)
if (matched) break
}
if ((await current()) !== enabled) { if (!flipped) await flip()
await page.reload()
await openSidebar(page)
}
const expected = enabled ? "New workspace" : "New session" const expected = enabled ? "New workspace" : "New session"
await expect.poll(current, { timeout: 60_000 }).toBe(enabled) await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible({ timeout: 30_000 })
} }
export async function openWorkspaceMenu(page: Page, workspaceSlug: string) { export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
+4 -20
View File
@@ -44,18 +44,6 @@ async function waitForHealth(url: string, probe = "/global/health") {
throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`) throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`)
} }
function done(proc: ReturnType<typeof spawn>) {
return proc.exitCode !== null || proc.signalCode !== null
}
async function waitExit(proc: ReturnType<typeof spawn>, timeout = 10_000) {
if (done(proc)) return
await Promise.race([
new Promise<void>((resolve) => proc.once("exit", () => resolve())),
new Promise<void>((resolve) => setTimeout(resolve, timeout)),
])
}
const LOG_CAP = 100 const LOG_CAP = 100
function cap(input: string[]) { function cap(input: string[]) {
@@ -66,7 +54,7 @@ function tail(input: string[]) {
return input.slice(-40).join("") return input.slice(-40).join("")
} }
export async function startBackend(label: string, input?: { llmUrl?: string }): Promise<Handle> { export async function startBackend(label: string): Promise<Handle> {
const port = await freePort() const port = await freePort()
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), `opencode-e2e-${label}-`)) const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), `opencode-e2e-${label}-`))
const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
@@ -74,6 +62,7 @@ export async function startBackend(label: string, input?: { llmUrl?: string }):
const opencodeDir = path.join(repoDir, "packages", "opencode") const opencodeDir = path.join(repoDir, "packages", "opencode")
const env = { const env = {
...process.env, ...process.env,
OPENCODE_DISABLE_SHARE: process.env.OPENCODE_DISABLE_SHARE ?? "true",
OPENCODE_DISABLE_LSP_DOWNLOAD: "true", OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true", OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true", OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
@@ -84,7 +73,6 @@ export async function startBackend(label: string, input?: { llmUrl?: string }):
XDG_STATE_HOME: path.join(sandbox, "state"), XDG_STATE_HOME: path.join(sandbox, "state"),
OPENCODE_CLIENT: "app", OPENCODE_CLIENT: "app",
OPENCODE_STRICT_CONFIG_DEPS: "true", OPENCODE_STRICT_CONFIG_DEPS: "true",
OPENCODE_E2E_LLM_URL: input?.llmUrl,
} satisfies Record<string, string | undefined> } satisfies Record<string, string | undefined>
const out: string[] = [] const out: string[] = []
const err: string[] = [] const err: string[] = []
@@ -127,13 +115,9 @@ export async function startBackend(label: string, input?: { llmUrl?: string }):
return { return {
url, url,
async stop() { async stop() {
if (!done(proc)) { if (proc.exitCode === null) {
proc.kill("SIGTERM") proc.kill("SIGTERM")
await waitExit(proc) await new Promise((resolve) => proc.once("exit", () => resolve(undefined))).catch(() => undefined)
}
if (!done(proc)) {
proc.kill("SIGKILL")
await waitExit(proc)
} }
await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined) await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
}, },
+110 -428
View File
@@ -10,14 +10,12 @@ import {
cleanupTestProject, cleanupTestProject,
createTestProject, createTestProject,
setHealthPhase, setHealthPhase,
seedProjects,
sessionIDFromUrl, sessionIDFromUrl,
waitSession,
waitSessionIdle,
waitSessionSaved,
waitSlug, waitSlug,
waitSession,
} from "./actions" } from "./actions"
import { promptSelector } from "./selectors" import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
import { createSdk, dirSlug, getWorktree, serverUrl, sessionPath } from "./utils"
type LLMFixture = { type LLMFixture = {
url: string url: string
@@ -49,24 +47,6 @@ type LLMFixture = {
wait: (count: number) => Promise<void> wait: (count: number) => Promise<void>
inputs: () => Promise<Record<string, unknown>[]> inputs: () => Promise<Record<string, unknown>[]>
pending: () => Promise<number> pending: () => Promise<number>
misses: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
}
type LLMWorker = LLMFixture & {
reset: () => Promise<void>
}
type AssistantFixture = {
reply: LLMFixture["text"]
tool: LLMFixture["tool"]
toolHang: LLMFixture["toolHang"]
reason: LLMFixture["reason"]
fail: LLMFixture["fail"]
error: LLMFixture["error"]
hang: LLMFixture["hang"]
hold: LLMFixture["hold"]
calls: LLMFixture["calls"]
pending: LLMFixture["pending"]
} }
export const settingsKey = "settings.v3" export const settingsKey = "settings.v3"
@@ -81,40 +61,6 @@ const seedModel = (() => {
} }
})() })()
function clean(value: string | null) {
return (value ?? "").replace(/\u200B/g, "").trim()
}
async function visit(page: Page, url: string) {
let err: unknown
for (const _ of [0, 1, 2]) {
try {
await page.goto(url)
return
} catch (cause) {
err = cause
if (!String(cause).includes("ERR_CONNECTION_REFUSED")) throw cause
await new Promise((resolve) => setTimeout(resolve, 300))
}
}
throw err
}
async function promptSend(page: Page) {
return page
.evaluate(() => {
const win = window as E2EWindow
const sent = win.__opencode_e2e?.prompt?.sent
return {
started: sent?.started ?? 0,
count: sent?.count ?? 0,
sessionID: sent?.sessionID,
directory: sent?.directory,
}
})
.catch(() => ({ started: 0, count: 0, sessionID: undefined, directory: undefined }))
}
type ProjectHandle = { type ProjectHandle = {
directory: string directory: string
slug: string slug: string
@@ -131,23 +77,15 @@ type ProjectOptions = {
beforeGoto?: (project: { directory: string; sdk: ReturnType<typeof createSdk> }) => Promise<void> beforeGoto?: (project: { directory: string; sdk: ReturnType<typeof createSdk> }) => Promise<void>
} }
type ProjectFixture = ProjectHandle & {
open: (options?: ProjectOptions) => Promise<void>
prompt: (text: string) => Promise<string>
user: (text: string) => Promise<string>
shell: (cmd: string) => Promise<string>
}
type TestFixtures = { type TestFixtures = {
llm: LLMFixture llm: LLMFixture
assistant: AssistantFixture
project: ProjectFixture
sdk: ReturnType<typeof createSdk> sdk: ReturnType<typeof createSdk>
gotoSession: (sessionID?: string) => Promise<void> gotoSession: (sessionID?: string) => Promise<void>
withProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
withBackendProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
} }
type WorkerFixtures = { type WorkerFixtures = {
_llm: LLMWorker
backend: { backend: {
url: string url: string
sdk: (directory?: string) => ReturnType<typeof createSdk> sdk: (directory?: string) => ReturnType<typeof createSdk>
@@ -157,42 +95,9 @@ type WorkerFixtures = {
} }
export const test = base.extend<TestFixtures, WorkerFixtures>({ export const test = base.extend<TestFixtures, WorkerFixtures>({
_llm: [
async ({}, use) => {
const rt = ManagedRuntime.make(TestLLMServer.layer)
try {
const svc = await rt.runPromise(TestLLMServer.asEffect())
await use({
url: svc.url,
push: (...input) => rt.runPromise(svc.push(...input)),
pushMatch: (match, ...input) => rt.runPromise(svc.pushMatch(match, ...input)),
textMatch: (match, value, opts) => rt.runPromise(svc.textMatch(match, value, opts)),
toolMatch: (match, name, input) => rt.runPromise(svc.toolMatch(match, name, input)),
text: (value, opts) => rt.runPromise(svc.text(value, opts)),
tool: (name, input) => rt.runPromise(svc.tool(name, input)),
toolHang: (name, input) => rt.runPromise(svc.toolHang(name, input)),
reason: (value, opts) => rt.runPromise(svc.reason(value, opts)),
fail: (message) => rt.runPromise(svc.fail(message)),
error: (status, body) => rt.runPromise(svc.error(status, body)),
hang: () => rt.runPromise(svc.hang),
hold: (value, wait) => rt.runPromise(svc.hold(value, wait)),
reset: () => rt.runPromise(svc.reset),
hits: () => rt.runPromise(svc.hits),
calls: () => rt.runPromise(svc.calls),
wait: (count) => rt.runPromise(svc.wait(count)),
inputs: () => rt.runPromise(svc.inputs),
pending: () => rt.runPromise(svc.pending),
misses: () => rt.runPromise(svc.misses),
})
} finally {
await rt.dispose()
}
},
{ scope: "worker" },
],
backend: [ backend: [
async ({ _llm }, use, workerInfo) => { async ({}, use, workerInfo) => {
const handle = await startBackend(`w${workerInfo.workerIndex}`, { llmUrl: _llm.url }) const handle = await startBackend(`w${workerInfo.workerIndex}`)
try { try {
await use({ await use({
url: handle.url, url: handle.url,
@@ -204,48 +109,34 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
}, },
{ scope: "worker" }, { scope: "worker" },
], ],
llm: async ({ _llm }, use) => { llm: async ({}, use) => {
await _llm.reset() const rt = ManagedRuntime.make(TestLLMServer.layer)
await use({ try {
url: _llm.url, const svc = await rt.runPromise(TestLLMServer.asEffect())
push: _llm.push, await use({
pushMatch: _llm.pushMatch, url: svc.url,
textMatch: _llm.textMatch, push: (...input) => rt.runPromise(svc.push(...input)),
toolMatch: _llm.toolMatch, pushMatch: (match, ...input) => rt.runPromise(svc.pushMatch(match, ...input)),
text: _llm.text, textMatch: (match, value, opts) => rt.runPromise(svc.textMatch(match, value, opts)),
tool: _llm.tool, toolMatch: (match, name, input) => rt.runPromise(svc.toolMatch(match, name, input)),
toolHang: _llm.toolHang, text: (value, opts) => rt.runPromise(svc.text(value, opts)),
reason: _llm.reason, tool: (name, input) => rt.runPromise(svc.tool(name, input)),
fail: _llm.fail, toolHang: (name, input) => rt.runPromise(svc.toolHang(name, input)),
error: _llm.error, reason: (value, opts) => rt.runPromise(svc.reason(value, opts)),
hang: _llm.hang, fail: (message) => rt.runPromise(svc.fail(message)),
hold: _llm.hold, error: (status, body) => rt.runPromise(svc.error(status, body)),
hits: _llm.hits, hang: () => rt.runPromise(svc.hang),
calls: _llm.calls, hold: (value, wait) => rt.runPromise(svc.hold(value, wait)),
wait: _llm.wait, hits: () => rt.runPromise(svc.hits),
inputs: _llm.inputs, calls: () => rt.runPromise(svc.calls),
pending: _llm.pending, wait: (count) => rt.runPromise(svc.wait(count)),
misses: _llm.misses, inputs: () => rt.runPromise(svc.inputs),
}) pending: () => rt.runPromise(svc.pending),
const pending = await _llm.pending() })
if (pending > 0) { } finally {
throw new Error(`TestLLMServer still has ${pending} queued response(s) after the test finished`) await rt.dispose()
} }
}, },
assistant: async ({ llm }, use) => {
await use({
reply: llm.text,
tool: llm.tool,
toolHang: llm.toolHang,
reason: llm.reason,
fail: llm.fail,
error: llm.error,
hang: llm.hang,
hold: llm.hold,
calls: llm.calls,
pending: llm.pending,
})
},
page: async ({ page }, use) => { page: async ({ page }, use) => {
let boundary: string | undefined let boundary: string | undefined
setHealthPhase(page, "test") setHealthPhase(page, "test")
@@ -270,8 +161,9 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
if (boundary) throw new Error(boundary) if (boundary) throw new Error(boundary)
}, },
directory: [ directory: [
async ({ backend }, use) => { async ({}, use) => {
await use(await getWorktree(backend.url)) const directory = await getWorktree()
await use(directory)
}, },
{ scope: "worker" }, { scope: "worker" },
], ],
@@ -281,254 +173,78 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
}, },
{ scope: "worker" }, { scope: "worker" },
], ],
sdk: async ({ directory, backend }, use) => { sdk: async ({ directory }, use) => {
await use(backend.sdk(directory)) await use(createSdk(directory))
}, },
gotoSession: async ({ page, directory, backend }, use) => { gotoSession: async ({ page, directory }, use) => {
await seedStorage(page, { directory, serverUrl: backend.url }) await seedStorage(page, { directory })
const gotoSession = async (sessionID?: string) => { const gotoSession = async (sessionID?: string) => {
await visit(page, sessionPath(directory, sessionID)) await page.goto(sessionPath(directory, sessionID))
await waitSession(page, { await waitSession(page, { directory, sessionID })
directory,
sessionID,
serverUrl: backend.url,
allowAnySession: !sessionID,
})
} }
await use(gotoSession) await use(gotoSession)
}, },
project: async ({ page, llm, backend }, use) => { withProject: async ({ page }, use) => {
const item = makeProject(page, llm, backend) await use((callback, options) => runProject(page, callback, options))
try { },
await use(item.project) withBackendProject: async ({ page, backend }, use) => {
} finally { await use((callback, options) =>
await item.cleanup() runProject(page, callback, { ...options, serverUrl: backend.url, sdk: backend.sdk }),
} )
}, },
}) })
function makeProject( async function runProject<T>(
page: Page, page: Page,
llm: LLMFixture, callback: (project: ProjectHandle) => Promise<T>,
backend: { url: string; sdk: (directory?: string) => ReturnType<typeof createSdk> }, options?: ProjectOptions & {
serverUrl?: string
sdk?: (directory?: string) => ReturnType<typeof createSdk>
},
) { ) {
let state: const url = options?.serverUrl
| { const root = await createTestProject(url ? { serverUrl: url } : undefined)
directory: string const sdk = options?.sdk?.(root) ?? createSdk(root, url)
slug: string const sessions = new Map<string, string>()
sdk: ReturnType<typeof createSdk> const dirs = new Set<string>()
sessions: Map<string, string> await options?.setup?.(root)
dirs: Set<string> await seedStorage(page, {
} directory: root,
| undefined extra: options?.extra,
model: options?.model,
const need = () => { serverUrl: url,
if (state) return state })
throw new Error("project.open() must be called first")
}
const trackSession = (sessionID: string, directory?: string) => {
const cur = need()
cur.sessions.set(sessionID, directory ?? cur.directory)
}
const trackDirectory = (directory: string) => {
const cur = need()
if (directory !== cur.directory) cur.dirs.add(directory)
}
const gotoSession = async (sessionID?: string) => { const gotoSession = async (sessionID?: string) => {
const cur = need() await page.goto(sessionPath(root, sessionID))
await visit(page, sessionPath(cur.directory, sessionID)) await waitSession(page, { directory: root, sessionID, serverUrl: url })
await waitSession(page, {
directory: cur.directory,
sessionID,
serverUrl: backend.url,
allowAnySession: !sessionID,
})
const current = sessionIDFromUrl(page.url()) const current = sessionIDFromUrl(page.url())
if (current) trackSession(current) if (current) trackSession(current)
} }
const open = async (options?: ProjectOptions) => { const trackSession = (sessionID: string, directory?: string) => {
if (state) return sessions.set(sessionID, directory ?? root)
const directory = await createTestProject({ serverUrl: backend.url }) }
const sdk = backend.sdk(directory)
await options?.setup?.(directory) const trackDirectory = (directory: string) => {
await seedStorage(page, { if (directory !== root) dirs.add(directory)
directory, }
extra: options?.extra,
model: options?.model, try {
serverUrl: backend.url, await options?.beforeGoto?.({ directory: root, sdk })
})
state = {
directory,
slug: "",
sdk,
sessions: new Map(),
dirs: new Set(),
}
await options?.beforeGoto?.({ directory, sdk })
await gotoSession() await gotoSession()
need().slug = await waitSlug(page) const slug = await waitSlug(page)
} return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory, sdk })
} finally {
const send = async (text: string, input: { noReply: boolean; shell: boolean }) => {
if (input.noReply) {
const cur = need()
const state = await page.evaluate(() => {
const model = (window as E2EWindow).__opencode_e2e?.model?.current
if (!model) return null
return {
dir: model.dir,
sessionID: model.sessionID,
agent: model.agent,
model: model.model ? { providerID: model.model.providerID, modelID: model.model.modelID } : undefined,
variant: model.variant ?? undefined,
}
})
const dir = state?.dir ?? cur.directory
const sdk = backend.sdk(dir)
const sessionID = state?.sessionID
? state.sessionID
: await sdk.session.create({ directory: dir, title: "E2E Session" }).then((res) => {
if (!res.data?.id) throw new Error("Failed to create no-reply session")
return res.data.id
})
await sdk.session.prompt({
sessionID,
agent: state?.agent,
model: state?.model,
variant: state?.variant,
noReply: true,
parts: [{ type: "text", text }],
})
await visit(page, sessionPath(dir, sessionID))
const active = await waitSession(page, {
directory: dir,
sessionID,
serverUrl: backend.url,
})
trackSession(sessionID, active.directory)
await waitSessionSaved(active.directory, sessionID, 90_000, backend.url)
return sessionID
}
const prev = await promptSend(page)
if (!input.noReply && !input.shell && (await llm.pending()) === 0) {
await llm.text("ok")
}
const prompt = page.locator(promptSelector).first()
const submit = async () => {
await expect(prompt).toBeVisible()
await prompt.click()
if (input.shell) {
await page.keyboard.type("!")
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
}
await page.keyboard.type(text)
await expect.poll(async () => clean(await prompt.textContent())).toBe(text)
await page.keyboard.press("Enter")
const started = await expect
.poll(async () => (await promptSend(page)).started, { timeout: 5_000 })
.toBeGreaterThan(prev.started)
.then(() => true)
.catch(() => false)
if (started) return
const send = page.getByRole("button", { name: "Send" }).first()
const enabled = await send
.isEnabled()
.then((x) => x)
.catch(() => false)
if (enabled) {
await send.click()
} else {
await prompt.click()
await page.keyboard.press("Enter")
}
await expect.poll(async () => (await promptSend(page)).started, { timeout: 5_000 }).toBeGreaterThan(prev.started)
}
await submit()
let next: { sessionID: string; directory: string } | undefined
await expect
.poll(
async () => {
const sent = await promptSend(page)
if (sent.count <= prev.count) return ""
if (!sent.sessionID || !sent.directory) return ""
next = { sessionID: sent.sessionID, directory: sent.directory }
return sent.sessionID
},
{ timeout: 90_000 },
)
.not.toBe("")
if (!next) throw new Error("Failed to observe prompt submission in e2e prompt probe")
const active = await waitSession(page, {
directory: next.directory,
sessionID: next.sessionID,
serverUrl: backend.url,
})
trackSession(next.sessionID, active.directory)
if (!input.shell) {
await waitSessionSaved(active.directory, next.sessionID, 90_000, backend.url)
}
await waitSessionIdle(backend.sdk(active.directory), next.sessionID, 90_000).catch(() => undefined)
return next.sessionID
}
const prompt = async (text: string) => {
return send(text, { noReply: false, shell: false })
}
const user = async (text: string) => {
return send(text, { noReply: true, shell: false })
}
const shell = async (cmd: string) => {
return send(cmd, { noReply: false, shell: true })
}
const cleanup = async () => {
const cur = state
if (!cur) return
setHealthPhase(page, "cleanup") setHealthPhase(page, "cleanup")
await Promise.allSettled( await Promise.allSettled(
Array.from(cur.sessions, ([sessionID, directory]) => Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory, serverUrl: url })),
cleanupSession({ sessionID, directory, serverUrl: backend.url }),
),
) )
await Promise.allSettled(Array.from(cur.dirs, (directory) => cleanupTestProject(directory))) await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
await cleanupTestProject(cur.directory) await cleanupTestProject(root)
state = undefined
setHealthPhase(page, "test") setHealthPhase(page, "test")
} }
return {
project: {
open,
prompt,
user,
shell,
gotoSession,
trackSession,
trackDirectory,
get directory() {
return need().directory
},
get slug() {
return need().slug
},
get sdk() {
return need().sdk
},
},
cleanup,
}
} }
async function seedStorage( async function seedStorage(
@@ -540,65 +256,31 @@ async function seedStorage(
serverUrl?: string serverUrl?: string
}, },
) { ) {
const origin = input.serverUrl ?? serverUrl await seedProjects(page, input)
await page.addInitScript( await page.addInitScript((model: { providerID: string; modelID: string }) => {
(args: { const win = window as E2EWindow
directory: string win.__opencode_e2e = {
serverUrl: string ...win.__opencode_e2e,
extra: string[] model: {
model: { providerID: string; modelID: string } enabled: true,
}) => { },
const key = "opencode.global.dat:server" prompt: {
const raw = localStorage.getItem(key) enabled: true,
const parsed = (() => { },
if (!raw) return undefined terminal: {
try { enabled: true,
return JSON.parse(raw) as unknown terminals: {},
} catch { },
return undefined }
} localStorage.setItem(
})() "opencode.global.dat:model",
JSON.stringify({
const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {} recent: [model],
const list = Array.isArray(store.list) ? store.list : [] user: [],
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {} variant: {},
const projects = store.projects && typeof store.projects === "object" ? store.projects : {} }),
const next = { ...(projects as Record<string, unknown>) } )
const nextList = list.includes(args.serverUrl) ? list : [args.serverUrl, ...list] }, input.model ?? seedModel)
const add = (origin: string, directory: string) => {
const current = next[origin]
const items = Array.isArray(current) ? current : []
const existing = items.filter(
(p): p is { worktree: string; expanded?: boolean } =>
!!p &&
typeof p === "object" &&
"worktree" in p &&
typeof (p as { worktree?: unknown }).worktree === "string",
)
if (existing.some((p) => p.worktree === directory)) return
next[origin] = [{ worktree: directory, expanded: true }, ...existing]
}
for (const directory of [args.directory, ...args.extra]) {
add("local", directory)
add(args.serverUrl, directory)
}
localStorage.setItem(key, JSON.stringify({ list: nextList, projects: next, lastProject }))
localStorage.setItem("opencode.settings.dat:defaultServerUrl", args.serverUrl)
const win = window as E2EWindow
win.__opencode_e2e = {
...win.__opencode_e2e,
model: { enabled: true },
prompt: { enabled: true },
terminal: { enabled: true, terminals: {} },
}
localStorage.setItem("opencode.global.dat:model", JSON.stringify({ recent: [args.model], user: [], variant: {} }))
},
{ directory: input.directory, serverUrl: origin, extra: input.extra ?? [], model: input.model ?? seedModel },
)
} }
export { expect } export { expect }
+1 -1
View File
@@ -2,7 +2,7 @@ import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors" import { promptSelector } from "../selectors"
import { clickListItem } from "../actions" import { clickListItem } from "../actions"
test.fixme("smoke model selection updates prompt footer", async ({ page, gotoSession }) => { test("smoke model selection updates prompt footer", async ({ page, gotoSession }) => {
await gotoSession() await gotoSession()
await page.locator(promptSelector).click() await page.locator(promptSelector).click()
+29 -35
View File
@@ -1,49 +1,43 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { clickMenuItem, openProjectMenu, openSidebar } from "../actions" import { clickMenuItem, openProjectMenu, openSidebar } from "../actions"
test("dialog edit project updates name and startup script", async ({ page, project }) => { test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
await project.open() await withProject(async ({ slug }) => {
await openSidebar(page) await openSidebar(page)
const open = async () => { const open = async () => {
const menu = await openProjectMenu(page, project.slug) const menu = await openProjectMenu(page, slug)
await clickMenuItem(menu, /^Edit$/i, { force: true }) await clickMenuItem(menu, /^Edit$/i, { force: true })
const dialog = page.getByRole("dialog") const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible() await expect(dialog).toBeVisible()
await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project") await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
return dialog return dialog
} }
const name = `e2e project ${Date.now()}` const name = `e2e project ${Date.now()}`
const startup = `echo e2e_${Date.now()}` const startup = `echo e2e_${Date.now()}`
const dialog = await open() const dialog = await open()
const nameInput = dialog.getByLabel("Name") const nameInput = dialog.getByLabel("Name")
await nameInput.fill(name) await nameInput.fill(name)
const startupInput = dialog.getByLabel("Workspace startup script") const startupInput = dialog.getByLabel("Workspace startup script")
await startupInput.fill(startup) await startupInput.fill(startup)
await dialog.getByRole("button", { name: "Save" }).click() await dialog.getByRole("button", { name: "Save" }).click()
await expect(dialog).toHaveCount(0) await expect(dialog).toHaveCount(0)
await expect const header = page.locator(".group\\/project").first()
.poll( await expect(header).toContainText(name)
async () => {
await page.reload() const reopened = await open()
await openSidebar(page) await expect(reopened.getByLabel("Name")).toHaveValue(name)
const reopened = await open() await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup)
const value = await reopened.getByLabel("Name").inputValue() await reopened.getByRole("button", { name: "Cancel" }).click()
const next = await reopened.getByLabel("Workspace startup script").inputValue() await expect(reopened).toHaveCount(0)
await reopened.getByRole("button", { name: "Cancel" }).click() })
await expect(reopened).toHaveCount(0)
return `${value}\n${next}`
},
{ timeout: 30_000 },
)
.toBe(`${name}\n${startup}`)
}) })
@@ -3,46 +3,51 @@ import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, open
import { projectSwitchSelector } from "../selectors" import { projectSwitchSelector } from "../selectors"
import { dirSlug } from "../utils" import { dirSlug } from "../utils"
test("closing active project navigates to another open project", async ({ page, project }) => { test("closing active project navigates to another open project", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject() const other = await createTestProject()
const otherSlug = dirSlug(other) const otherSlug = dirSlug(other)
try { try {
await project.open({ extra: [other] }) await withProject(
await openSidebar(page) async ({ slug }) => {
await openSidebar(page)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible() await expect(otherButton).toBeVisible()
await otherButton.click() await otherButton.click()
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
const menu = await openProjectMenu(page, otherSlug) const menu = await openProjectMenu(page, otherSlug)
await clickMenuItem(menu, /^Close$/i, { force: true })
await expect await clickMenuItem(menu, /^Close$/i, { force: true })
.poll(
() => {
const pathname = new URL(page.url()).pathname
if (new RegExp(`^/${project.slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
if (pathname === "/") return "home"
return ""
},
{ timeout: 15_000 },
)
.toMatch(/^(project|home)$/)
await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`)) await expect
await expect .poll(
.poll( () => {
async () => { const pathname = new URL(page.url()).pathname
return await page.locator(projectSwitchSelector(otherSlug)).count() if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
}, if (pathname === "/") return "home"
{ timeout: 15_000 }, return ""
) },
.toBe(0) { timeout: 15_000 },
)
.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)
},
{ extra: [other] },
)
} finally { } finally {
await cleanupTestProject(other) await cleanupTestProject(other)
} }
@@ -5,89 +5,111 @@ import {
createTestProject, createTestProject,
cleanupTestProject, cleanupTestProject,
openSidebar, openSidebar,
sessionIDFromUrl,
setWorkspacesEnabled, setWorkspacesEnabled,
waitSession, waitSession,
waitSessionSaved,
waitSlug, waitSlug,
} from "../actions" } from "../actions"
import { projectSwitchSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { dirSlug, resolveDirectory } from "../utils" import { dirSlug, resolveDirectory } from "../utils"
test("can switch between projects from sidebar", async ({ page, project }) => { test("can switch between projects from sidebar", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject() const other = await createTestProject()
const otherSlug = dirSlug(other) const otherSlug = dirSlug(other)
try { try {
await project.open({ extra: [other] }) await withProject(
await defocus(page) async ({ directory }) => {
await defocus(page)
const currentSlug = dirSlug(project.directory) const currentSlug = dirSlug(directory)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible() await expect(otherButton).toBeVisible()
await otherButton.click() await otherButton.click()
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
const currentButton = page.locator(projectSwitchSelector(currentSlug)).first() const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
await expect(currentButton).toBeVisible() await expect(currentButton).toBeVisible()
await currentButton.click() await currentButton.click()
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`)) await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
},
{ extra: [other] },
)
} finally { } finally {
await cleanupTestProject(other) await cleanupTestProject(other)
} }
}) })
test("switching back to a project opens the latest workspace session", async ({ page, project }) => { test("switching back to a project opens the latest workspace session", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject() const other = await createTestProject()
const otherSlug = dirSlug(other) const otherSlug = dirSlug(other)
try { try {
await project.open({ extra: [other] }) await withProject(
await defocus(page) async ({ directory, slug, trackSession, trackDirectory }) => {
await setWorkspacesEnabled(page, project.slug, true) await defocus(page)
await openSidebar(page) await setWorkspacesEnabled(page, slug, true)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() await openSidebar(page)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await page.getByRole("button", { name: "New workspace" }).first().click() await page.getByRole("button", { name: "New workspace" }).first().click()
const raw = await waitSlug(page, [project.slug]) const raw = await waitSlug(page, [slug])
const dir = base64Decode(raw) const dir = base64Decode(raw)
if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`) if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`)
const space = await resolveDirectory(dir) const space = await resolveDirectory(dir)
const next = dirSlug(space) const next = dirSlug(space)
project.trackDirectory(space) trackDirectory(space)
await openSidebar(page) await openSidebar(page)
const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first() const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first()
await expect(item).toBeVisible() await expect(item).toBeVisible()
await item.hover() await item.hover()
const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first() const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first()
await expect(btn).toBeVisible() await expect(btn).toBeVisible()
await btn.click({ force: true }) await btn.click({ force: true })
await waitSession(page, { directory: space }) await waitSession(page, { directory: space })
const created = await project.user("test") // Create a session by sending a prompt
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.fill("test")
await page.keyboard.press("Enter")
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`)) // Wait for the URL to update with the new session ID
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
await openSidebar(page) const created = sessionIDFromUrl(page.url())
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
trackSession(created, space)
await waitSessionSaved(space, created)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
await expect(otherButton).toBeVisible()
await otherButton.click({ force: true })
await waitSession(page, { directory: other })
const rootButton = page.locator(projectSwitchSelector(project.slug)).first() await openSidebar(page)
await expect(rootButton).toBeVisible()
await rootButton.click({ force: true })
await waitSession(page, { directory: space, sessionID: created }) const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`)) await expect(otherButton).toBeVisible()
await otherButton.click({ force: true })
await waitSession(page, { directory: other })
const rootButton = page.locator(projectSwitchSelector(slug)).first()
await expect(rootButton).toBeVisible()
await rootButton.click({ force: true })
await waitSession(page, { directory: space, sessionID: created })
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
},
{ extra: [other] },
)
} finally { } finally {
await cleanupTestProject(other) await cleanupTestProject(other)
} }
@@ -7,9 +7,11 @@ import {
setWorkspacesEnabled, setWorkspacesEnabled,
waitDir, waitDir,
waitSession, waitSession,
waitSessionSaved,
waitSlug, waitSlug,
} from "../actions" } from "../actions"
import { workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk } from "../utils"
function item(space: { slug: string; raw: string }) { function item(space: { slug: string; raw: string }) {
return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}` return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
@@ -48,31 +50,45 @@ async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: s
} }
async function createSessionFromWorkspace( async function createSessionFromWorkspace(
project: Parameters<typeof test>[0]["project"],
page: Page, page: Page,
space: { slug: string; raw: string; directory: string }, space: { slug: string; raw: string; directory: string },
text: string, text: string,
) { ) {
await openWorkspaceNewSession(page, space) await openWorkspaceNewSession(page, space)
return project.user(text)
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.fill(text)
await page.keyboard.press("Enter")
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
const sessionID = sessionIDFromUrl(page.url())
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
await waitSessionSaved(space.directory, sessionID)
await createSdk(space.directory)
.session.abort({ sessionID })
.catch(() => undefined)
return sessionID
} }
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, project }) => { test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
await project.open() await withProject(async ({ slug: root, trackDirectory, trackSession }) => {
await openSidebar(page) await openSidebar(page)
await setWorkspacesEnabled(page, project.slug, true) await setWorkspacesEnabled(page, root, true)
const first = await createWorkspace(page, project.slug, []) const first = await createWorkspace(page, root, [])
project.trackDirectory(first.directory) trackDirectory(first.directory)
await waitWorkspaceReady(page, first) await waitWorkspaceReady(page, first)
const second = await createWorkspace(page, project.slug, [first.slug]) const second = await createWorkspace(page, root, [first.slug])
project.trackDirectory(second.directory) trackDirectory(second.directory)
await waitWorkspaceReady(page, second) await waitWorkspaceReady(page, second)
await createSessionFromWorkspace(project, page, first, `workspace one ${Date.now()}`) trackSession(await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`), first.directory)
await createSessionFromWorkspace(project, page, second, `workspace two ${Date.now()}`) trackSession(await createSessionFromWorkspace(page, second, `workspace two ${Date.now()}`), second.directory)
await createSessionFromWorkspace(project, page, first, `workspace one again ${Date.now()}`) trackSession(await createSessionFromWorkspace(page, first, `workspace one again ${Date.now()}`), first.directory)
})
}) })
+275 -268
View File
@@ -19,10 +19,10 @@ import {
waitDir, waitDir,
waitSlug, waitSlug,
} from "../actions" } from "../actions"
import { inlineInputSelector, workspaceItemSelector } from "../selectors" import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
import { dirSlug } from "../utils" import { createSdk, dirSlug } from "../utils"
async function setupWorkspaceTest(page: Page, project: { slug: string; trackDirectory: (directory: string) => void }) { async function setupWorkspaceTest(page: Page, project: { slug: string }) {
const rootSlug = project.slug const rootSlug = project.slug
await openSidebar(page) await openSidebar(page)
@@ -31,7 +31,6 @@ async function setupWorkspaceTest(page: Page, project: { slug: string; trackDire
await page.getByRole("button", { name: "New workspace" }).first().click() await page.getByRole("button", { name: "New workspace" }).first().click()
const next = await resolveSlug(await waitSlug(page, [rootSlug])) const next = await resolveSlug(await waitSlug(page, [rootSlug]))
await waitDir(page, next.directory) await waitDir(page, next.directory)
project.trackDirectory(next.directory)
await openSidebar(page) await openSidebar(page)
@@ -53,192 +52,44 @@ async function setupWorkspaceTest(page: Page, project: { slug: string; trackDire
return { rootSlug, slug: next.slug, directory: next.directory } return { rootSlug, slug: next.slug, directory: next.directory }
} }
test("can enable and disable workspaces from project menu", async ({ page, project }) => { test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
await project.open()
await openSidebar(page) await withProject(async ({ slug }) => {
await openSidebar(page)
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible() await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0) await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
await setWorkspacesEnabled(page, project.slug, true) await setWorkspacesEnabled(page, slug, true)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await expect(page.locator(workspaceItemSelector(project.slug)).first()).toBeVisible() await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible()
await setWorkspacesEnabled(page, project.slug, false) await setWorkspacesEnabled(page, slug, false)
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible() await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
await expect(page.locator(workspaceItemSelector(project.slug))).toHaveCount(0) await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
})
}) })
test("can create a workspace", async ({ page, project }) => { test("can create a workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await project.open()
await openSidebar(page)
await setWorkspacesEnabled(page, project.slug, true)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await page.getByRole("button", { name: "New workspace" }).first().click()
const next = await resolveSlug(await waitSlug(page, [project.slug]))
await waitDir(page, next.directory)
project.trackDirectory(next.directory)
await openSidebar(page)
await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(next.slug)).first()
try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
})
test("non-git projects keep workspace mode disabled", async ({ page, project }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-")) await withProject(async ({ slug }) => {
const nonGitSlug = dirSlug(nonGit) await openSidebar(page)
await setWorkspacesEnabled(page, slug, true)
await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n") await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
try { await page.getByRole("button", { name: "New workspace" }).first().click()
await project.open({ extra: [nonGit] }) const next = await resolveSlug(await waitSlug(page, [slug]))
await page.goto(`/${nonGitSlug}/session`) await waitDir(page, next.directory)
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory)
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
await openSidebar(page) await openSidebar(page)
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
await expect(page.getByRole("button", { name: "Create Git repository" })).toBeVisible()
} finally {
await cleanupTestProject(nonGit)
}
})
test("can rename a workspace", async ({ page, project }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await project.open()
const { slug } = await setupWorkspaceTest(page, project)
const rename = `e2e workspace ${Date.now()}`
const menu = await openWorkspaceMenu(page, slug)
await clickMenuItem(menu, /^Rename$/i, { force: true })
await expect(menu).toHaveCount(0)
const item = page.locator(workspaceItemSelector(slug)).first()
await expect(item).toBeVisible()
const input = item.locator(inlineInputSelector).first()
const shown = await input
.isVisible()
.then((x) => x)
.catch(() => false)
if (!shown) {
const retry = await openWorkspaceMenu(page, slug)
await clickMenuItem(retry, /^Rename$/i, { force: true })
await expect(retry).toHaveCount(0)
}
await expect(input).toBeVisible()
await input.fill(rename)
await input.press("Enter")
await expect(item).toContainText(rename)
})
test("can reset a workspace", async ({ page, project }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await project.open()
const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
const readme = path.join(createdDir, "README.md")
const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
const original = await fs.readFile(readme, "utf8")
const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n`
await fs.writeFile(readme, dirty, "utf8")
await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8")
await expect
.poll(async () => {
return await fs
.stat(extra)
.then(() => true)
.catch(() => false)
})
.toBe(true)
await expect
.poll(async () => {
const files = await project.sdk.file
.status({ directory: createdDir })
.then((r) => r.data ?? [])
.catch(() => [])
return files.length
})
.toBeGreaterThan(0)
const menu = await openWorkspaceMenu(page, slug)
await clickMenuItem(menu, /^Reset$/i, { force: true })
await confirmDialog(page, /^Reset workspace$/i)
await expect
.poll(
async () => {
const files = await project.sdk.file
.status({ directory: createdDir })
.then((r) => r.data ?? [])
.catch(() => [])
return files.length
},
{ timeout: 120_000 },
)
.toBe(0)
await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 120_000 }).toBe(original)
await expect
.poll(async () => {
return await fs
.stat(extra)
.then(() => true)
.catch(() => false)
})
.toBe(false)
})
test("can reorder workspaces by drag and drop", async ({ page, project }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await project.open()
const rootSlug = project.slug
const listSlugs = async () => {
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
const slugs = await nodes.evaluateAll((els) => {
return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
})
return slugs
}
const waitReady = async (slug: string) => {
await expect await expect
.poll( .poll(
async () => { async () => {
const item = page.locator(workspaceItemSelector(slug)).first() const item = page.locator(workspaceItemSelector(next.slug)).first()
try { try {
await item.hover({ timeout: 500 }) await item.hover({ timeout: 500 })
return true return true
@@ -249,120 +100,276 @@ test("can reorder workspaces by drag and drop", async ({ page, project }) => {
{ timeout: 60_000 }, { timeout: 60_000 },
) )
.toBe(true) .toBe(true)
await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
await cleanupTestProject(next.directory)
})
})
test("non-git projects keep workspace mode disabled", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-"))
const nonGitSlug = dirSlug(nonGit)
await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
try {
await withProject(async () => {
await page.goto(`/${nonGitSlug}/session`)
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory)
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
await openSidebar(page)
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
const trigger = page.locator('[data-action="project-menu"]').first()
const hasMenu = await trigger
.isVisible()
.then((x) => x)
.catch(() => false)
if (!hasMenu) return
await trigger.click({ force: true })
const menu = page.locator(dropdownMenuContentSelector).first()
await expect(menu).toBeVisible()
const toggle = menu.locator('[data-action="project-workspaces-toggle"]').first()
await expect(toggle).toBeVisible()
await expect(toggle).toBeDisabled()
await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0)
})
} finally {
await cleanupTestProject(nonGit)
} }
})
const drag = async (from: string, to: string) => { test("can rename a workspace", async ({ page, withProject }) => {
const src = page.locator(workspaceItemSelector(from)).first() await page.setViewportSize({ width: 1400, height: 800 })
const dst = page.locator(workspaceItemSelector(to)).first()
const a = await src.boundingBox() await withProject(async (project) => {
const b = await dst.boundingBox() const { slug } = await setupWorkspaceTest(page, project)
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2) const rename = `e2e workspace ${Date.now()}`
await page.mouse.down() const menu = await openWorkspaceMenu(page, slug)
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 }) await clickMenuItem(menu, /^Rename$/i, { force: true })
await page.mouse.up()
}
await openSidebar(page) await expect(menu).toHaveCount(0)
await setWorkspacesEnabled(page, rootSlug, true) const item = page.locator(workspaceItemSelector(slug)).first()
await expect(item).toBeVisible()
const input = item.locator(inlineInputSelector).first()
await expect(input).toBeVisible()
await input.fill(rename)
await input.press("Enter")
await expect(item).toContainText(rename)
})
})
const workspaces = [] as { directory: string; slug: string }[] test("can reset a workspace", async ({ page, sdk, withProject }) => {
for (const _ of [0, 1]) { await page.setViewportSize({ width: 1400, height: 800 })
const prev = slugFromUrl(page.url())
await page.getByRole("button", { name: "New workspace" }).first().click() await withProject(async (project) => {
const next = await resolveSlug(await waitSlug(page, [rootSlug, prev])) const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
await waitDir(page, next.directory)
project.trackDirectory(next.directory) const readme = path.join(createdDir, "README.md")
workspaces.push(next) const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
const original = await fs.readFile(readme, "utf8")
const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n`
await fs.writeFile(readme, dirty, "utf8")
await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8")
await expect
.poll(async () => {
return await fs
.stat(extra)
.then(() => true)
.catch(() => false)
})
.toBe(true)
await expect
.poll(async () => {
const files = await sdk.file
.status({ directory: createdDir })
.then((r) => r.data ?? [])
.catch(() => [])
return files.length
})
.toBeGreaterThan(0)
const menu = await openWorkspaceMenu(page, slug)
await clickMenuItem(menu, /^Reset$/i, { force: true })
await confirmDialog(page, /^Reset workspace$/i)
await expect
.poll(
async () => {
const files = await sdk.file
.status({ directory: createdDir })
.then((r) => r.data ?? [])
.catch(() => [])
return files.length
},
{ timeout: 60_000 },
)
.toBe(0)
await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 60_000 }).toBe(original)
await expect
.poll(async () => {
return await fs
.stat(extra)
.then(() => true)
.catch(() => false)
})
.toBe(false)
})
})
test("can delete a workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async (project) => {
const sdk = createSdk(project.directory)
const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project)
await expect
.poll(
async () => {
const worktrees = await sdk.worktree
.list()
.then((r) => r.data ?? [])
.catch(() => [] as string[])
return worktrees.includes(directory)
},
{ timeout: 30_000 },
)
.toBe(true)
const menu = await openWorkspaceMenu(page, slug)
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
.poll(
async () => {
const worktrees = await sdk.worktree
.list()
.then((r) => r.data ?? [])
.catch(() => [] as string[])
return worktrees.includes(directory)
},
{ timeout: 60_000 },
)
.toBe(false)
await project.gotoSession()
await openSidebar(page) await openSidebar(page)
} await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
if (workspaces.length !== 2) throw new Error("Expected two created workspaces") })
const a = workspaces[0].slug
const b = workspaces[1].slug
await waitReady(a)
await waitReady(b)
const list = async () => {
const slugs = await listSlugs()
return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
}
await expect
.poll(async () => {
const slugs = await list()
return slugs.length === 2
})
.toBe(true)
const before = await list()
const from = before[1]
const to = before[0]
if (!from || !to) throw new Error("Failed to resolve initial workspace order")
await drag(from, to)
await expect.poll(async () => await list()).toEqual([from, to])
}) })
test("can delete a workspace", async ({ page, project }) => { test("can reorder workspaces by drag and drop", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
await project.open() await withProject(async ({ slug: rootSlug }) => {
const workspaces = [] as { directory: string; slug: string }[]
const rootSlug = project.slug const listSlugs = async () => {
await openSidebar(page) const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
await setWorkspacesEnabled(page, rootSlug, true) const slugs = await nodes.evaluateAll((els) => {
return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
})
return slugs
}
const created = await project.sdk.worktree.create({ directory: project.directory }).then((res) => res.data) const waitReady = async (slug: string) => {
if (!created?.directory) throw new Error("Failed to create workspace for delete test") await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(slug)).first()
try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
}
const directory = created.directory const drag = async (from: string, to: string) => {
const slug = dirSlug(directory) const src = page.locator(workspaceItemSelector(from)).first()
project.trackDirectory(directory) const dst = page.locator(workspaceItemSelector(to)).first()
await page.reload() const a = await src.boundingBox()
await openSidebar(page) const b = await dst.boundingBox()
await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible({ timeout: 60_000 }) if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
await expect await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
.poll( await page.mouse.down()
async () => { await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
const worktrees = await project.sdk.worktree await page.mouse.up()
.list() }
.then((r) => r.data ?? [])
.catch(() => [] as string[])
return worktrees.includes(directory)
},
{ timeout: 30_000 },
)
.toBe(true)
const menu = await openWorkspaceMenu(page, slug) try {
await clickMenuItem(menu, /^Delete$/i, { force: true }) await openSidebar(page)
await confirmDialog(page, /^Delete workspace$/i)
await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory) await setWorkspacesEnabled(page, rootSlug, true)
await expect for (const _ of [0, 1]) {
.poll( const prev = slugFromUrl(page.url())
async () => { await page.getByRole("button", { name: "New workspace" }).first().click()
const worktrees = await project.sdk.worktree const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
.list() await waitDir(page, next.directory)
.then((r) => r.data ?? []) workspaces.push(next)
.catch(() => [] as string[])
return worktrees.includes(directory)
},
{ timeout: 60_000 },
)
.toBe(false)
await openSidebar(page) await openSidebar(page)
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 }) }
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
const a = workspaces[0].slug
const b = workspaces[1].slug
await waitReady(a)
await waitReady(b)
const list = async () => {
const slugs = await listSlugs()
return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
}
await expect
.poll(async () => {
const slugs = await list()
return slugs.length === 2
})
.toBe(true)
const before = await list()
const from = before[1]
const to = before[0]
if (!from || !to) throw new Error("Failed to resolve initial workspace order")
await drag(from, to)
await expect.poll(async () => await list()).toEqual([from, to])
} finally {
await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory)))
}
})
}) })
+39 -8
View File
@@ -1,15 +1,46 @@
import { createSdk } from "../utils"
export const openaiModel = { providerID: "openai", modelID: "gpt-5.3-chat-latest" }
type Hit = { body: Record<string, unknown> } type Hit = { body: Record<string, unknown> }
export function bodyText(hit: Hit) { export function bodyText(hit: Hit) {
return JSON.stringify(hit.body) return JSON.stringify(hit.body)
} }
/** export function titleMatch(hit: Hit) {
* Match requests whose body contains the exact serialized tool input. return bodyText(hit).includes("Generate a title for this conversation")
* The seed prompts embed JSON.stringify(input) in the prompt text, which }
* gets escaped again inside the JSON body — so we double-escape to match.
*/ export function promptMatch(token: string) {
export function inputMatch(input: unknown) { return (hit: Hit) => bodyText(hit).includes(token)
const escaped = JSON.stringify(JSON.stringify(input)).slice(1, -1) }
return (hit: Hit) => bodyText(hit).includes(escaped)
export async function withMockOpenAI<T>(input: { serverUrl: string; llmUrl: string; fn: () => Promise<T> }) {
const sdk = createSdk(undefined, input.serverUrl)
const prev = await sdk.global.config.get().then((res) => res.data ?? {})
try {
await sdk.global.config.update({
config: {
...prev,
model: `${openaiModel.providerID}/${openaiModel.modelID}`,
enabled_providers: ["openai"],
provider: {
...prev.provider,
openai: {
...prev.provider?.openai,
options: {
...prev.provider?.openai?.options,
apiKey: "test-key",
baseURL: input.llmUrl,
},
},
},
},
})
return await input.fn()
} finally {
await sdk.global.config.update({ config: prev })
}
} }
+35 -8
View File
@@ -1,25 +1,52 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors" import { promptSelector } from "../selectors"
import { assistantText, withSession } from "../actions" import { assistantText, sessionIDFromUrl, withSession } from "../actions"
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim() const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
// Regression test for Issue #12453: the synchronous POST /message endpoint holds // Regression test for Issue #12453: the synchronous POST /message endpoint holds
// the connection open while the agent works, causing "Failed to fetch" over // the connection open while the agent works, causing "Failed to fetch" over
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately. // VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
test("prompt succeeds when sync message endpoint is unreachable", async ({ page, project, assistant }) => { test("prompt succeeds when sync message endpoint is unreachable", async ({
page,
llm,
backend,
withBackendProject,
}) => {
test.setTimeout(120_000) test.setTimeout(120_000)
// Simulate Tailscale/VPN killing the long-lived sync connection // Simulate Tailscale/VPN killing the long-lived sync connection
await page.route("**/session/*/message", (route) => route.abort("connectionfailed")) await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
const token = `E2E_ASYNC_${Date.now()}` await withMockOpenAI({
await project.open() serverUrl: backend.url,
await assistant.reply(token) llmUrl: llm.url,
const sessionID = await project.prompt(`Reply with exactly: ${token}`) fn: async () => {
const token = `E2E_ASYNC_${Date.now()}`
await llm.textMatch(titleMatch, "E2E Title")
await llm.textMatch(promptMatch(token), token)
await expect.poll(() => assistant.calls()).toBeGreaterThanOrEqual(1) await withBackendProject(
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 90_000 }).toContain(token) async (project) => {
await page.locator(promptSelector).click()
await page.keyboard.type(`Reply with exactly: ${token}`)
await page.keyboard.press("Enter")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = sessionIDFromUrl(page.url())!
project.trackSession(sessionID)
await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1)
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 90_000 }).toContain(token)
},
{
model: openaiModel,
},
)
},
})
}) })
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => { test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
@@ -1,88 +0,0 @@
import type { Locator, Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { promptAgentSelector, promptModelSelector, promptSelector } from "../selectors"
type Probe = {
agent?: string
model?: { providerID: string; modelID: string; name?: string }
models?: Array<{ providerID: string; modelID: string; name: string }>
agents?: Array<{ name: string }>
}
async function probe(page: Page): Promise<Probe | null> {
return page.evaluate(() => {
const win = window as Window & {
__opencode_e2e?: {
model?: {
current?: Probe
}
}
}
return win.__opencode_e2e?.model?.current ?? null
})
}
async function state(page: Page) {
const value = await probe(page)
if (!value) throw new Error("Failed to resolve model selection probe")
return value
}
async function ready(page: Page) {
const prompt = page.locator(promptSelector)
await prompt.click()
await expect(prompt).toBeFocused()
await prompt.pressSequentially("focus")
return prompt
}
async function body(prompt: Locator) {
return prompt.evaluate((el) => (el as HTMLElement).innerText)
}
test("agent select returns focus to the prompt", async ({ page, gotoSession }) => {
await gotoSession()
const prompt = await ready(page)
const info = await state(page)
const next = info.agents?.map((item) => item.name).find((name) => name !== info.agent)
test.skip(!next, "only one agent available")
if (!next) return
await page.locator(`${promptAgentSelector} [data-slot="select-select-trigger"]`).first().click()
const item = page.locator('[data-slot="select-select-item"]').filter({ hasText: next }).first()
await expect(item).toBeVisible()
await item.click({ force: true })
await expect(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()).toHaveText(
next,
)
await expect(prompt).toBeFocused()
await prompt.pressSequentially(" agent")
await expect.poll(() => body(prompt)).toContain("focus agent")
})
test("model select returns focus to the prompt", async ({ page, gotoSession }) => {
await gotoSession()
const prompt = await ready(page)
const info = await state(page)
const key = info.model ? `${info.model.providerID}:${info.model.modelID}` : null
const next = info.models?.find((item) => `${item.providerID}:${item.modelID}` !== key)
test.skip(!next, "only one model available")
if (!next) return
await page.locator(`${promptModelSelector} [data-action="prompt-model"]`).first().click()
const item = page.locator(`[data-slot="list-item"][data-key="${next.providerID}:${next.modelID}"]`).first()
await expect(item).toBeVisible()
await item.click({ force: true })
await expect(page.locator(`${promptModelSelector} [data-action="prompt-model"] span`).first()).toHaveText(next.name)
await expect(prompt).toBeFocused()
await prompt.pressSequentially(" model")
await expect.poll(() => body(prompt)).toContain("focus model")
})
+152 -81
View File
@@ -1,12 +1,11 @@
import type { ToolPart } from "@opencode-ai/sdk/v2/client" import type { ToolPart } from "@opencode-ai/sdk/v2/client"
import type { Page } from "@playwright/test" import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { assistantText } from "../actions" import { assistantText, sessionIDFromUrl } from "../actions"
import { promptSelector } from "../selectors" import { promptSelector } from "../selectors"
import { createSdk } from "../utils" import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim() const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
type Sdk = ReturnType<typeof createSdk>
const isBash = (part: unknown): part is ToolPart => { const isBash = (part: unknown): part is ToolPart => {
if (!part || typeof part !== "object") return false if (!part || typeof part !== "object") return false
@@ -15,15 +14,47 @@ const isBash = (part: unknown): part is ToolPart => {
return "state" in part 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) { async function wait(page: Page, value: string) {
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value) await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
} }
async function reply(sdk: Sdk, sessionID: string, token: string) { async function reply(
await expect.poll(() => assistantText(sdk, sessionID), { timeout: 90_000 }).toContain(token) sdk: { session: { messages: Parameters<typeof assistantText>[0]["session"] } },
sessionID: string,
token: string,
) {
await expect
.poll(() => assistantText(sdk as Parameters<typeof assistantText>[0], sessionID), { timeout: 90_000 })
.toContain(token)
} }
async function shell(sdk: Sdk, sessionID: string, cmd: string, token: string) { async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string, cmd: string, token: string) {
await expect await expect
.poll( .poll(
async () => { async () => {
@@ -42,105 +73,145 @@ async function shell(sdk: Sdk, sessionID: string, cmd: string, token: string) {
.toContain(token) .toContain(token)
} }
test("prompt history restores unsent draft with arrow navigation", async ({ page, project, assistant }) => { test("prompt history restores unsent draft with arrow navigation", async ({
page,
llm,
backend,
withBackendProject,
}) => {
test.setTimeout(120_000) test.setTimeout(120_000)
const firstToken = `E2E_HISTORY_ONE_${Date.now()}` await withMockOpenAI({
const secondToken = `E2E_HISTORY_TWO_${Date.now()}` serverUrl: backend.url,
const first = `Reply with exactly: ${firstToken}` llmUrl: llm.url,
const second = `Reply with exactly: ${secondToken}` fn: async () => {
const draft = `draft ${Date.now()}` 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 project.open() await llm.textMatch(titleMatch, "E2E Title")
await assistant.reply(firstToken) await llm.textMatch(promptMatch(firstToken), firstToken)
const sessionID = await project.prompt(first) await llm.textMatch(promptMatch(secondToken), secondToken)
await wait(page, "")
await reply(project.sdk, sessionID, firstToken)
await assistant.reply(secondToken) await withBackendProject(
await project.prompt(second) async (project) => {
await wait(page, "") const prompt = page.locator(promptSelector)
await reply(project.sdk, sessionID, secondToken)
const prompt = page.locator(promptSelector) await prompt.click()
await prompt.click() await page.keyboard.type(first)
await page.keyboard.type(draft) await page.keyboard.press("Enter")
await wait(page, draft) await wait(page, "")
await prompt.fill("") await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
await wait(page, "") const sessionID = sessionIDFromUrl(page.url())!
project.trackSession(sessionID)
await reply(project.sdk, sessionID, firstToken)
await page.keyboard.press("ArrowUp") await prompt.click()
await wait(page, second) await page.keyboard.type(second)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(project.sdk, sessionID, secondToken)
await page.keyboard.press("ArrowUp") await prompt.click()
await wait(page, first) await page.keyboard.type(draft)
await wait(page, draft)
await page.keyboard.press("ArrowDown") await prompt.fill("")
await wait(page, second) await wait(page, "")
await page.keyboard.press("ArrowDown") await page.keyboard.press("ArrowUp")
await wait(page, "") 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, "")
},
{
model: openaiModel,
},
)
},
})
}) })
test.fixme("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => { test("shell history stays separate from normal prompt history", async ({ page, llm, backend, withBackendProject }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
const firstToken = `E2E_SHELL_ONE_${Date.now()}` await withMockOpenAI({
const secondToken = `E2E_SHELL_TWO_${Date.now()}` serverUrl: backend.url,
const normalToken = `E2E_NORMAL_${Date.now()}` llmUrl: llm.url,
const first = `echo ${firstToken}` fn: async () => {
const second = `echo ${secondToken}` const firstToken = `E2E_SHELL_ONE_${Date.now()}`
const normal = `Reply with exactly: ${normalToken}` 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 gotoSession() await llm.textMatch(titleMatch, "E2E Title")
await llm.textMatch(promptMatch(normalToken), normalToken)
const prompt = page.locator(promptSelector) await withBackendProject(
async (project) => {
const prompt = page.locator(promptSelector)
await prompt.click() await prompt.click()
await page.keyboard.type("!") await page.keyboard.type("!")
await page.keyboard.type(first) await page.keyboard.type(first)
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await wait(page, "") await wait(page, "")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 }) await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = sessionIDFromUrl(page.url())! const sessionID = sessionIDFromUrl(page.url())!
await shell(sdk, sessionID, first, firstToken) project.trackSession(sessionID)
await shell(project.sdk, sessionID, first, firstToken)
await prompt.click() await prompt.click()
await page.keyboard.type("!") await page.keyboard.type("!")
await page.keyboard.type(second) await page.keyboard.type(second)
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await wait(page, "") await wait(page, "")
await shell(sdk, sessionID, second, secondToken) await shell(project.sdk, sessionID, second, secondToken)
await page.keyboard.press("Escape") await prompt.click()
await wait(page, "") await page.keyboard.type("!")
await page.keyboard.press("ArrowUp")
await wait(page, second)
await prompt.click() await page.keyboard.press("ArrowUp")
await page.keyboard.type("!") await wait(page, first)
await page.keyboard.press("ArrowUp")
await wait(page, second)
await page.keyboard.press("ArrowUp") await page.keyboard.press("ArrowDown")
await wait(page, first) await wait(page, second)
await page.keyboard.press("ArrowDown") await page.keyboard.press("ArrowDown")
await wait(page, second) await wait(page, "")
await page.keyboard.press("ArrowDown") await page.keyboard.press("Escape")
await wait(page, "") await wait(page, "")
await page.keyboard.press("Escape") await prompt.click()
await wait(page, "") await page.keyboard.type(normal)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(project.sdk, sessionID, normalToken)
await prompt.click() await prompt.click()
await page.keyboard.type(normal) await page.keyboard.press("ArrowUp")
await page.keyboard.press("Enter") await wait(page, normal)
await wait(page, "") },
await reply(sdk, sessionID, normalToken) {
model: openaiModel,
await prompt.click() },
await page.keyboard.press("ArrowUp") )
await wait(page, normal) },
})
}) })
+25 -39
View File
@@ -1,7 +1,7 @@
import type { ToolPart } from "@opencode-ai/sdk/v2/client" import type { ToolPart } from "@opencode-ai/sdk/v2/client"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { closeDialog, openSettings, withSession } from "../actions" import { sessionIDFromUrl } from "../actions"
import { promptModelSelector, promptSelector, promptVariantSelector } from "../selectors" import { promptSelector } from "../selectors"
const isBash = (part: unknown): part is ToolPart => { const isBash = (part: unknown): part is ToolPart => {
if (!part || typeof part !== "object") return false if (!part || typeof part !== "object") return false
@@ -10,34 +10,33 @@ const isBash = (part: unknown): part is ToolPart => {
return "state" in part return "state" in part
} }
test("shell mode runs a command in the project directory", async ({ page, project }) => { test("shell mode runs a command in the project directory", async ({ page, withBackendProject }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
await project.open() await withBackendProject(async ({ directory, gotoSession, trackSession, sdk }) => {
const cmd = process.platform === "win32" ? "dir" : "command ls" const prompt = page.locator(promptSelector)
const cmd = process.platform === "win32" ? "dir" : "command ls"
await withSession(project.sdk, `e2e shell ${Date.now()}`, async (session) => { await gotoSession()
project.trackSession(session.id) await prompt.click()
await project.gotoSession(session.id) await page.keyboard.type("!")
const dialog = await openSettings(page) await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
const toggle = dialog.locator('[data-action="settings-auto-accept-permissions"]').first()
const input = toggle.locator('[data-slot="switch-input"]').first() await page.keyboard.type(cmd)
await expect(toggle).toBeVisible() await page.keyboard.press("Enter")
if ((await input.getAttribute("aria-checked")) !== "true") {
await toggle.locator('[data-slot="switch-control"]').click() await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
await expect(input).toHaveAttribute("aria-checked", "true")
} const id = sessionIDFromUrl(page.url())
await closeDialog(page, dialog) if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
await project.shell(cmd) trackSession(id, directory)
await expect await expect
.poll( .poll(
async () => { async () => {
const list = await project.sdk.session const list = await sdk.session.messages({ sessionID: id, limit: 50 }).then((x) => x.data ?? [])
.messages({ sessionID: session.id, limit: 50 })
.then((x) => x.data ?? [])
const msg = list.findLast( const msg = list.findLast(
(item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === project.directory, (item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === directory,
) )
if (!msg) return if (!msg) return
@@ -50,25 +49,12 @@ test("shell mode runs a command in the project directory", async ({ page, projec
typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
if (!output.includes("README.md")) return if (!output.includes("README.md")) return
return { cwd: project.directory, output } return { cwd: directory, output }
}, },
{ timeout: 90_000 }, { timeout: 90_000 },
) )
.toEqual(expect.objectContaining({ cwd: project.directory, output: expect.stringContaining("README.md") })) .toEqual(expect.objectContaining({ cwd: directory, output: expect.stringContaining("README.md") }))
await expect(prompt).toHaveText("")
}) })
}) })
test("shell mode unmounts model and variant controls", async ({ page, project }) => {
await project.open()
const prompt = page.locator(promptSelector).first()
await expect(page.locator(promptModelSelector)).toHaveCount(1)
await expect(page.locator(promptVariantSelector)).toHaveCount(1)
await prompt.click()
await page.keyboard.type("!")
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
await expect(page.locator(promptModelSelector)).toHaveCount(0)
await expect(page.locator(promptVariantSelector)).toHaveCount(0)
})
@@ -22,45 +22,45 @@ async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
.toBeGreaterThan(0) .toBeGreaterThan(0)
} }
test("/share and /unshare update session share state", async ({ page, project }) => { test("/share and /unshare update session share state", async ({ page, withBackendProject }) => {
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).") test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
await project.open() await withBackendProject(async (project) => {
await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => { await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => {
project.trackSession(session.id) const prompt = page.locator(promptSelector)
const prompt = page.locator(promptSelector)
await seed(project.sdk, session.id) await seed(project.sdk, session.id)
await project.gotoSession(session.id) await project.gotoSession(session.id)
await prompt.click() await prompt.click()
await page.keyboard.type("/share") await page.keyboard.type("/share")
await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible() await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined return data?.share?.url || undefined
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
) )
.not.toBeUndefined() .not.toBeUndefined()
await prompt.click() await prompt.click()
await page.keyboard.type("/unshare") await page.keyboard.type("/unshare")
await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible() await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined return data?.share?.url || undefined
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
) )
.toBeUndefined() .toBeUndefined()
})
}) })
}) })
+38 -8
View File
@@ -1,7 +1,9 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { assistantText } from "../actions" import { promptSelector } from "../selectors"
import { assistantText, sessionIDFromUrl } from "../actions"
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
test("can send a prompt and receive a reply", async ({ page, project, assistant }) => { test("can send a prompt and receive a reply", async ({ page, llm, backend, withBackendProject }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
const pageErrors: string[] = [] const pageErrors: string[] = []
@@ -11,13 +13,41 @@ test("can send a prompt and receive a reply", async ({ page, project, assistant
page.on("pageerror", onPageError) page.on("pageerror", onPageError)
try { try {
const token = `E2E_OK_${Date.now()}` await withMockOpenAI({
await project.open() serverUrl: backend.url,
await assistant.reply(token) llmUrl: llm.url,
const sessionID = await project.prompt(`Reply with exactly: ${token}`) fn: async () => {
const token = `E2E_OK_${Date.now()}`
await expect.poll(() => assistant.calls()).toBeGreaterThanOrEqual(1) await llm.textMatch(titleMatch, "E2E Title")
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 30_000 }).toContain(token) await llm.textMatch(promptMatch(token), token)
await withBackendProject(
async (project) => {
const prompt = page.locator(promptSelector)
await prompt.click()
await page.keyboard.type(`Reply with exactly: ${token}`)
await page.keyboard.press("Enter")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = (() => {
const id = sessionIDFromUrl(page.url())
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
return id
})()
project.trackSession(sessionID)
await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1)
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 30_000 }).toContain(token)
},
{
model: openaiModel,
},
)
},
})
} finally { } finally {
page.off("pageerror", onPageError) page.off("pageerror", onPageError)
} }
+13 -2
View File
@@ -1,10 +1,16 @@
export const promptSelector = '[data-component="prompt-input"]' export const promptSelector = '[data-component="prompt-input"]'
const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]' export const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]'
export const terminalSelector = `${terminalPanelSelector} [data-component="terminal"]` export const terminalSelector = `${terminalPanelSelector} [data-component="terminal"]`
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]' export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]' export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]' export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
export const permissionRejectSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(1)`
export const permissionAllowAlwaysSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(2)`
export const permissionAllowOnceSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(3)`
export const sessionTodoDockSelector = '[data-component="session-todo-dock"]'
export const sessionTodoToggleSelector = '[data-action="session-todo-toggle"]'
export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]' export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]'
export const sessionTodoListSelector = '[data-slot="session-todo-list"]'
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]' export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
export const promptAgentSelector = '[data-component="prompt-agent-control"]' export const promptAgentSelector = '[data-component="prompt-agent-control"]'
@@ -24,7 +30,7 @@ export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-error
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]' export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]' export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]' export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
export const projectSwitchSelector = (slug: string) => export const projectSwitchSelector = (slug: string) =>
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]` `${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
@@ -34,6 +40,9 @@ export const projectMenuTriggerSelector = (slug: string) =>
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]` export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
export const projectClearNotificationsSelector = (slug: string) =>
`[data-action="project-clear-notifications"][data-project="${slug}"]`
export const projectWorkspacesToggleSelector = (slug: string) => export const projectWorkspacesToggleSelector = (slug: string) =>
`[data-action="project-workspaces-toggle"][data-project="${slug}"]` `[data-action="project-workspaces-toggle"][data-project="${slug}"]`
@@ -41,6 +50,8 @@ export const titlebarRightSelector = "#opencode-titlebar-right"
export const popoverBodySelector = '[data-slot="popover-body"]' export const popoverBodySelector = '[data-slot="popover-body"]'
export const dropdownMenuTriggerSelector = '[data-slot="dropdown-menu-trigger"]'
export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]' export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]'
export const inlineInputSelector = '[data-component="inline-input"]' export const inlineInputSelector = '[data-component="inline-input"]'
@@ -1,8 +1,7 @@
import { seedSessionTask, withSession } from "../actions" import { seedSessionTask, withSession } from "../actions"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { inputMatch } from "../prompt/mock"
test("task tool child-session link does not trigger stale show errors", async ({ page, llm, project }) => { test("task tool child-session link does not trigger stale show errors", async ({ page, withBackendProject }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
const errs: string[] = [] const errs: string[] = []
@@ -11,54 +10,32 @@ test("task tool child-session link does not trigger stale show errors", async ({
} }
page.on("pageerror", onError) page.on("pageerror", onError)
try { await withBackendProject(async ({ gotoSession, trackSession, sdk }) => {
await project.open() await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
await withSession(project.sdk, `e2e child nav ${Date.now()}`, async (session) => { trackSession(session.id)
const taskInput = { const child = await seedSessionTask(sdk, {
sessionID: session.id,
description: "Open child session", description: "Open child session",
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.", prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
subagent_type: "general",
}
await llm.toolMatch(inputMatch(taskInput), "task", taskInput)
const child = await seedSessionTask(project.sdk, {
sessionID: session.id,
description: taskInput.description,
prompt: taskInput.prompt,
}) })
project.trackSession(child.sessionID) trackSession(child.sessionID)
await project.gotoSession(session.id) try {
await gotoSession(session.id)
const header = page.locator("[data-session-title]") const link = page
await expect(header.getByRole("button", { name: "More options" })).toBeVisible({ timeout: 30_000 }) .locator("a.subagent-link")
.filter({ hasText: /open child session/i })
.first()
await expect(link).toBeVisible({ timeout: 30_000 })
await link.click()
const card = page await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
.locator('[data-component="task-tool-card"]') await page.waitForTimeout(1000)
.filter({ hasText: /open child session/i }) expect(errs).toEqual([])
.first() } finally {
await expect(card).toBeVisible({ timeout: 30_000 }) page.off("pageerror", onError)
await card.click() }
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
await expect(header.locator('[data-slot="session-title-parent"]')).toHaveText(session.title)
await expect(header.locator('[data-slot="session-title-child"]')).toHaveText(taskInput.description)
await expect(header.locator('[data-slot="session-title-separator"]')).toHaveText("/")
await expect
.poll(
() =>
header.locator('[data-slot="session-title-separator"]').evaluate((el) => ({
left: getComputedStyle(el).paddingLeft,
right: getComputedStyle(el).paddingRight,
})),
{ timeout: 30_000 },
)
.toEqual({ left: "8px", right: "8px" })
await expect(header.getByRole("button", { name: "More options" })).toHaveCount(0)
await expect(page.getByText("Subagent sessions cannot be prompted.")).toBeVisible({ timeout: 30_000 })
await expect(page.getByRole("button", { name: "Back to main session." })).toBeVisible({ timeout: 30_000 })
await expect.poll(() => errs, { timeout: 5_000 }).toEqual([])
}) })
} finally { })
page.off("pageerror", onError)
}
}) })
@@ -5,7 +5,7 @@ import {
type ComposerProbeState, type ComposerProbeState,
type ComposerWindow, type ComposerWindow,
} from "../../src/testing/session-composer" } from "../../src/testing/session-composer"
import { cleanupSession, clearSessionDockSeed, closeDialog, openSettings, seedSessionQuestion } from "../actions" import { cleanupSession, clearSessionDockSeed, seedSessionQuestion } from "../actions"
import { import {
permissionDockSelector, permissionDockSelector,
promptSelector, promptSelector,
@@ -14,7 +14,6 @@ import {
sessionTodoToggleButtonSelector, sessionTodoToggleButtonSelector,
} from "../selectors" } from "../selectors"
import { modKey } from "../utils" import { modKey } from "../utils"
import { inputMatch } from "../prompt/mock"
type Sdk = Parameters<typeof clearSessionDockSeed>[0] type Sdk = Parameters<typeof clearSessionDockSeed>[0]
type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" } type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
@@ -23,13 +22,12 @@ async function withDockSession<T>(
sdk: Sdk, sdk: Sdk,
title: string, title: string,
fn: (session: { id: string; title: string }) => Promise<T>, fn: (session: { id: string; title: string }) => Promise<T>,
opts?: { permission?: PermissionRule[]; trackSession?: (sessionID: string) => void }, opts?: { permission?: PermissionRule[] },
) { ) {
const session = await sdk.session const session = await sdk.session
.create(opts?.permission ? { title, permission: opts.permission } : { title }) .create(opts?.permission ? { title, permission: opts.permission } : { title })
.then((r) => r.data) .then((r) => r.data)
if (!session?.id) throw new Error("Session create did not return an id") if (!session?.id) throw new Error("Session create did not return an id")
opts?.trackSession?.(session.id)
try { try {
return await fn(session) return await fn(session)
} finally { } finally {
@@ -37,17 +35,6 @@ async function withDockSession<T>(
} }
} }
const defaultQuestions = [
{
header: "Need input",
question: "Pick one option",
options: [
{ label: "Continue", description: "Continue now" },
{ label: "Stop", description: "Stop here" },
],
},
]
test.setTimeout(120_000) test.setTimeout(120_000)
async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>) { async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>) {
@@ -65,14 +52,12 @@ async function clearPermissionDock(page: any, label: RegExp) {
} }
async function setAutoAccept(page: any, enabled: boolean) { async function setAutoAccept(page: any, enabled: boolean) {
const dialog = await openSettings(page) const button = page.locator('[data-action="prompt-permissions"]').first()
const toggle = dialog.locator('[data-action="settings-auto-accept-permissions"]').first() await expect(button).toBeVisible()
const input = toggle.locator('[data-slot="switch-input"]').first() const pressed = (await button.getAttribute("aria-pressed")) === "true"
await expect(toggle).toBeVisible() if (pressed === enabled) return
const checked = (await input.getAttribute("aria-checked")) === "true" await button.click()
if (checked !== enabled) await toggle.locator('[data-slot="switch-control"]').click() await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false")
await expect(input).toHaveAttribute("aria-checked", enabled ? "true" : "false")
await closeDialog(page, dialog)
} }
async function expectQuestionBlocked(page: any) { async function expectQuestionBlocked(page: any) {
@@ -244,7 +229,9 @@ async function withMockPermission<T>(
const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined
if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child) if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child)
await route.fulfill({ await route.fulfill({
response: res, status: res.status(),
headers: res.headers(),
contentType: "application/json",
body: JSON.stringify(json), body: JSON.stringify(json),
}) })
} }
@@ -269,47 +256,53 @@ async function withMockPermission<T>(
} }
} }
test("default dock shows prompt input", async ({ page, project }) => { test("default dock shows prompt input", async ({ page, withBackendProject }) => {
await project.open() await withBackendProject(async (project) => {
await withDockSession( await withDockSession(project.sdk, "e2e composer dock default", async (session) => {
project.sdk,
"e2e composer dock default",
async (session) => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
await expect(page.locator(sessionComposerDockSelector)).toBeVisible() await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
await expect(page.locator(promptSelector)).toBeVisible() await expect(page.locator(promptSelector)).toBeVisible()
await expect(page.locator('[data-action="prompt-permissions"]')).toHaveCount(0)
await expect(page.locator(questionDockSelector)).toHaveCount(0) await expect(page.locator(questionDockSelector)).toHaveCount(0)
await expect(page.locator(permissionDockSelector)).toHaveCount(0) await expect(page.locator(permissionDockSelector)).toHaveCount(0)
await page.locator(promptSelector).click() await page.locator(promptSelector).click()
await expect(page.locator(promptSelector)).toBeFocused() await expect(page.locator(promptSelector)).toBeFocused()
}, })
{ trackSession: project.trackSession }, })
)
}) })
test("auto-accept toggle works before first submit", async ({ page, project }) => { test("auto-accept toggle works before first submit", async ({ page, withBackendProject }) => {
await project.open() await withBackendProject(async ({ gotoSession }) => {
await gotoSession()
await setAutoAccept(page, true) const button = page.locator('[data-action="prompt-permissions"]').first()
await setAutoAccept(page, false) await expect(button).toBeVisible()
await expect(button).toHaveAttribute("aria-pressed", "false")
await setAutoAccept(page, true)
await setAutoAccept(page, false)
})
}) })
test("blocked question flow unblocks after submit", async ({ page, llm, project }) => { test("blocked question flow unblocks after submit", async ({ page, withBackendProject }) => {
await project.open() await withBackendProject(async (project) => {
await withDockSession( await withDockSession(project.sdk, "e2e composer dock question", async (session) => {
project.sdk,
"e2e composer dock question",
async (session) => {
await withDockSeed(project.sdk, session.id, async () => { await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
await seedSessionQuestion(project.sdk, { await seedSessionQuestion(project.sdk, {
sessionID: session.id, sessionID: session.id,
questions: defaultQuestions, questions: [
{
header: "Need input",
question: "Pick one option",
options: [
{ label: "Continue", description: "Continue now" },
{ label: "Stop", description: "Stop here" },
],
},
],
}) })
const dock = page.locator(questionDockSelector) const dock = page.locator(questionDockSelector)
@@ -320,24 +313,28 @@ test("blocked question flow unblocks after submit", async ({ page, llm, project
await expectQuestionOpen(page) await expectQuestionOpen(page)
}) })
}, })
{ trackSession: project.trackSession }, })
)
}) })
test("blocked question flow supports keyboard shortcuts", async ({ page, llm, project }) => { test("blocked question flow supports keyboard shortcuts", async ({ page, withBackendProject }) => {
await project.open() await withBackendProject(async (project) => {
await withDockSession( await withDockSession(project.sdk, "e2e composer dock question keyboard", async (session) => {
project.sdk,
"e2e composer dock question keyboard",
async (session) => {
await withDockSeed(project.sdk, session.id, async () => { await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
await seedSessionQuestion(project.sdk, { await seedSessionQuestion(project.sdk, {
sessionID: session.id, sessionID: session.id,
questions: defaultQuestions, questions: [
{
header: "Need input",
question: "Pick one option",
options: [
{ label: "Continue", description: "Continue now" },
{ label: "Stop", description: "Stop here" },
],
},
],
}) })
const dock = page.locator(questionDockSelector) const dock = page.locator(questionDockSelector)
@@ -354,24 +351,28 @@ test("blocked question flow supports keyboard shortcuts", async ({ page, llm, pr
await page.keyboard.press(`${modKey}+Enter`) await page.keyboard.press(`${modKey}+Enter`)
await expectQuestionOpen(page) await expectQuestionOpen(page)
}) })
}, })
{ trackSession: project.trackSession }, })
)
}) })
test("blocked question flow supports escape dismiss", async ({ page, llm, project }) => { test("blocked question flow supports escape dismiss", async ({ page, withBackendProject }) => {
await project.open() await withBackendProject(async (project) => {
await withDockSession( await withDockSession(project.sdk, "e2e composer dock question escape", async (session) => {
project.sdk,
"e2e composer dock question escape",
async (session) => {
await withDockSeed(project.sdk, session.id, async () => { await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
await seedSessionQuestion(project.sdk, { await seedSessionQuestion(project.sdk, {
sessionID: session.id, sessionID: session.id,
questions: defaultQuestions, questions: [
{
header: "Need input",
question: "Pick one option",
options: [
{ label: "Continue", description: "Continue now" },
{ label: "Stop", description: "Stop here" },
],
},
],
}) })
const dock = page.locator(questionDockSelector) const dock = page.locator(questionDockSelector)
@@ -383,17 +384,13 @@ test("blocked question flow supports escape dismiss", async ({ page, llm, projec
await page.keyboard.press("Escape") await page.keyboard.press("Escape")
await expectQuestionOpen(page) await expectQuestionOpen(page)
}) })
}, })
{ trackSession: project.trackSession }, })
)
}) })
test("blocked permission flow supports allow once", async ({ page, project }) => { test("blocked permission flow supports allow once", async ({ page, withBackendProject }) => {
await project.open() await withBackendProject(async (project) => {
await withDockSession( await withDockSession(project.sdk, "e2e composer dock permission once", async (session) => {
project.sdk,
"e2e composer dock permission once",
async (session) => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
await setAutoAccept(page, false) await setAutoAccept(page, false)
await withMockPermission( await withMockPermission(
@@ -416,17 +413,13 @@ test("blocked permission flow supports allow once", async ({ page, project }) =>
await expectPermissionOpen(page) await expectPermissionOpen(page)
}, },
) )
}, })
{ trackSession: project.trackSession }, })
)
}) })
test("blocked permission flow supports reject", async ({ page, project }) => { test("blocked permission flow supports reject", async ({ page, withBackendProject }) => {
await project.open() await withBackendProject(async (project) => {
await withDockSession( await withDockSession(project.sdk, "e2e composer dock permission reject", async (session) => {
project.sdk,
"e2e composer dock permission reject",
async (session) => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
await setAutoAccept(page, false) await setAutoAccept(page, false)
await withMockPermission( await withMockPermission(
@@ -448,17 +441,13 @@ test("blocked permission flow supports reject", async ({ page, project }) => {
await expectPermissionOpen(page) await expectPermissionOpen(page)
}, },
) )
}, })
{ trackSession: project.trackSession }, })
)
}) })
test("blocked permission flow supports allow always", async ({ page, project }) => { test("blocked permission flow supports allow always", async ({ page, withBackendProject }) => {
await project.open() await withBackendProject(async (project) => {
await withDockSession( await withDockSession(project.sdk, "e2e composer dock permission always", async (session) => {
project.sdk,
"e2e composer dock permission always",
async (session) => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
await setAutoAccept(page, false) await setAutoAccept(page, false)
await withMockPermission( await withMockPermission(
@@ -481,27 +470,16 @@ test("blocked permission flow supports allow always", async ({ page, project })
await expectPermissionOpen(page) await expectPermissionOpen(page)
}, },
) )
}, })
{ trackSession: project.trackSession }, })
)
}) })
test("child session question request blocks parent dock and unblocks after submit", async ({ page, llm, project }) => { test("child session question request blocks parent dock and unblocks after submit", async ({
const questions = [ page,
{ withBackendProject,
header: "Child input", }) => {
question: "Pick one child option", await withBackendProject(async (project) => {
options: [ await withDockSession(project.sdk, "e2e composer dock child question parent", async (session) => {
{ label: "Continue", description: "Continue child" },
{ label: "Stop", description: "Stop child" },
],
},
]
await project.open()
await withDockSession(
project.sdk,
"e2e composer dock child question parent",
async (session) => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
const child = await project.sdk.session const child = await project.sdk.session
@@ -511,14 +489,21 @@ test("child session question request blocks parent dock and unblocks after submi
}) })
.then((r) => r.data) .then((r) => r.data)
if (!child?.id) throw new Error("Child session create did not return an id") if (!child?.id) throw new Error("Child session create did not return an id")
project.trackSession(child.id)
try { try {
await withDockSeed(project.sdk, child.id, async () => { await withDockSeed(project.sdk, child.id, async () => {
await llm.toolMatch(inputMatch({ questions }), "question", { questions })
await seedSessionQuestion(project.sdk, { await seedSessionQuestion(project.sdk, {
sessionID: child.id, sessionID: child.id,
questions, questions: [
{
header: "Child input",
question: "Pick one child option",
options: [
{ label: "Continue", description: "Continue child" },
{ label: "Stop", description: "Stop child" },
],
},
],
}) })
const dock = page.locator(questionDockSelector) const dock = page.locator(questionDockSelector)
@@ -532,17 +517,16 @@ test("child session question request blocks parent dock and unblocks after submi
} finally { } finally {
await cleanupSession({ sdk: project.sdk, sessionID: child.id }) await cleanupSession({ sdk: project.sdk, sessionID: child.id })
} }
}, })
{ trackSession: project.trackSession }, })
)
}) })
test("child session permission request blocks parent dock and supports allow once", async ({ page, project }) => { test("child session permission request blocks parent dock and supports allow once", async ({
await project.open() page,
await withDockSession( withBackendProject,
project.sdk, }) => {
"e2e composer dock child permission parent", await withBackendProject(async (project) => {
async (session) => { await withDockSession(project.sdk, "e2e composer dock child permission parent", async (session) => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
await setAutoAccept(page, false) await setAutoAccept(page, false)
@@ -553,7 +537,6 @@ test("child session permission request blocks parent dock and supports allow onc
}) })
.then((r) => r.data) .then((r) => r.data)
if (!child?.id) throw new Error("Child session create did not return an id") if (!child?.id) throw new Error("Child session create did not return an id")
project.trackSession(child.id)
try { try {
await withMockPermission( await withMockPermission(
@@ -580,17 +563,13 @@ test("child session permission request blocks parent dock and supports allow onc
} finally { } finally {
await cleanupSession({ sdk: project.sdk, sessionID: child.id }) await cleanupSession({ sdk: project.sdk, sessionID: child.id })
} }
}, })
{ trackSession: project.trackSession }, })
)
}) })
test("todo dock transitions and collapse behavior", async ({ page, project }) => { test("todo dock transitions and collapse behavior", async ({ page, withBackendProject }) => {
await project.open() await withBackendProject(async (project) => {
await withDockSession( await withDockSession(project.sdk, "e2e composer dock todo", async (session) => {
project.sdk,
"e2e composer dock todo",
async (session) => {
const dock = await todoDock(page, session.id) const dock = await todoDock(page, session.id)
await project.gotoSession(session.id) await project.gotoSession(session.id)
await expect(page.locator(sessionComposerDockSelector)).toBeVisible() await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
@@ -616,31 +595,25 @@ test("todo dock transitions and collapse behavior", async ({ page, project }) =>
} finally { } finally {
await dock.clear() await dock.clear()
} }
}, })
{ trackSession: project.trackSession }, })
)
}) })
test("keyboard focus stays off prompt while blocked", async ({ page, llm, project }) => { test("keyboard focus stays off prompt while blocked", async ({ page, withBackendProject }) => {
const questions = [ await withBackendProject(async (project) => {
{ await withDockSession(project.sdk, "e2e composer dock keyboard", async (session) => {
header: "Need input",
question: "Pick one option",
options: [{ label: "Continue", description: "Continue now" }],
},
]
await project.open()
await withDockSession(
project.sdk,
"e2e composer dock keyboard",
async (session) => {
await withDockSeed(project.sdk, session.id, async () => { await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
await llm.toolMatch(inputMatch({ questions }), "question", { questions })
await seedSessionQuestion(project.sdk, { await seedSessionQuestion(project.sdk, {
sessionID: session.id, sessionID: session.id,
questions, questions: [
{
header: "Need input",
question: "Pick one option",
options: [{ label: "Continue", description: "Continue now" }],
},
],
}) })
await expectQuestionBlocked(page) await expectQuestionBlocked(page)
@@ -649,7 +622,6 @@ test("keyboard focus stays off prompt while blocked", async ({ page, llm, projec
await page.keyboard.type("abc") await page.keyboard.type("abc")
await expect(page.locator(promptSelector)).toHaveCount(0) await expect(page.locator(promptSelector)).toHaveCount(0)
}) })
}, })
{ trackSession: project.trackSession }, })
)
}) })
@@ -1,9 +1,18 @@
import type { Locator, Page } from "@playwright/test" import type { Locator, Page } from "@playwright/test"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { openSidebar, resolveSlug, setWorkspacesEnabled, waitSession, waitSlug } from "../actions" import {
openSidebar,
resolveSlug,
sessionIDFromUrl,
setWorkspacesEnabled,
waitSession,
waitSessionIdle,
waitSlug,
} from "../actions"
import { import {
promptAgentSelector, promptAgentSelector,
promptModelSelector, promptModelSelector,
promptSelector,
promptVariantSelector, promptVariantSelector,
workspaceItemSelector, workspaceItemSelector,
workspaceNewSessionSelector, workspaceNewSessionSelector,
@@ -221,8 +230,32 @@ async function goto(page: Page, directory: string, sessionID?: string) {
await waitSession(page, { directory, sessionID }) await waitSession(page, { directory, sessionID })
} }
async function submit(project: Parameters<typeof test>[0]["project"], value: string) { async function submit(page: Page, value: string) {
return project.prompt(value) const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.click()
await prompt.fill(value)
await prompt.press("Enter")
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
const id = sessionIDFromUrl(page.url())
if (!id) throw new Error(`Failed to resolve session id from ${page.url()}`)
return id
}
async function waitUser(directory: string, sessionID: string) {
const sdk = createSdk(directory)
await expect
.poll(
async () => {
const items = await sdk.session.messages({ sessionID, limit: 20 }).then((x) => x.data ?? [])
return items.some((item) => item.info.role === "user")
},
{ timeout: 30_000 },
)
.toBe(true)
await sdk.session.abort({ sessionID }).catch(() => undefined)
await waitSessionIdle(sdk, sessionID, 30_000).catch(() => undefined)
} }
async function createWorkspace(page: Page, root: string, seen: string[]) { async function createWorkspace(page: Page, root: string, seen: string[]) {
@@ -265,98 +298,108 @@ async function newWorkspaceSession(page: Page, slug: string) {
return waitSession(page, { directory: next.directory }).then((item) => item.directory) return waitSession(page, { directory: next.directory }).then((item) => item.directory)
} }
test("session model restore per session without leaking into new sessions", async ({ page, project }) => { test("session model restore per session without leaking into new sessions", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1440, height: 900 }) await page.setViewportSize({ width: 1440, height: 900 })
await project.open() await withProject(async ({ directory, gotoSession, trackSession }) => {
await project.gotoSession() await gotoSession()
const firstState = await chooseOtherModel(page) const firstState = await chooseOtherModel(page)
const firstKey = await currentModel(page) const firstKey = await currentModel(page)
const first = await submit(project, `session variant ${Date.now()}`) const first = await submit(page, `session variant ${Date.now()}`)
trackSession(first)
await waitUser(directory, first)
await page.reload() await page.reload()
await waitSession(page, { directory: project.directory, sessionID: first }) await waitSession(page, { directory, sessionID: first })
await waitFooter(page, firstState) await waitFooter(page, firstState)
await project.gotoSession() await gotoSession()
const fresh = await read(page) const fresh = await read(page)
expect(fresh.model).not.toBe(firstState.model) expect(fresh.model).not.toBe(firstState.model)
const secondState = await chooseOtherModel(page, [firstKey]) const secondState = await chooseOtherModel(page, [firstKey])
const second = await submit(project, `session model ${Date.now()}`) const second = await submit(page, `session model ${Date.now()}`)
trackSession(second)
await waitUser(directory, second)
await goto(page, project.directory, first) await goto(page, directory, first)
await waitFooter(page, firstState) await waitFooter(page, firstState)
await goto(page, project.directory, second) await goto(page, directory, second)
await waitFooter(page, secondState) await waitFooter(page, secondState)
await project.gotoSession() await gotoSession()
await page.reload() await waitFooter(page, fresh)
await waitSession(page, { directory: project.directory }) })
await waitFooter(page, fresh)
}) })
test("session model restore across workspaces", async ({ page, project }) => { test("session model restore across workspaces", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1440, height: 900 }) await page.setViewportSize({ width: 1440, height: 900 })
await project.open() await withProject(async ({ directory: root, slug, gotoSession, trackDirectory, trackSession }) => {
const root = project.directory await gotoSession()
await project.gotoSession()
const firstState = await chooseOtherModel(page) const firstState = await chooseOtherModel(page)
const firstKey = await currentModel(page) const firstKey = await currentModel(page)
const first = await submit(project, `root session ${Date.now()}`) const first = await submit(page, `root session ${Date.now()}`)
trackSession(first, root)
await waitUser(root, first)
await openSidebar(page) await openSidebar(page)
await setWorkspacesEnabled(page, project.slug, true) await setWorkspacesEnabled(page, slug, true)
const one = await createWorkspace(page, project.slug, []) const one = await createWorkspace(page, slug, [])
const oneDir = await newWorkspaceSession(page, one.slug) const oneDir = await newWorkspaceSession(page, one.slug)
project.trackDirectory(oneDir) trackDirectory(oneDir)
const secondState = await chooseOtherModel(page, [firstKey]) const secondState = await chooseOtherModel(page, [firstKey])
const secondKey = await currentModel(page) const secondKey = await currentModel(page)
const second = await submit(project, `workspace one ${Date.now()}`) const second = await submit(page, `workspace one ${Date.now()}`)
trackSession(second, oneDir)
await waitUser(oneDir, second)
const two = await createWorkspace(page, project.slug, [one.slug]) const two = await createWorkspace(page, slug, [one.slug])
const twoDir = await newWorkspaceSession(page, two.slug) const twoDir = await newWorkspaceSession(page, two.slug)
project.trackDirectory(twoDir) trackDirectory(twoDir)
const thirdState = await chooseOtherModel(page, [firstKey, secondKey]) const thirdState = await chooseOtherModel(page, [firstKey, secondKey])
const third = await submit(project, `workspace two ${Date.now()}`) const third = await submit(page, `workspace two ${Date.now()}`)
trackSession(third, twoDir)
await waitUser(twoDir, third)
await goto(page, root, first) await goto(page, root, first)
await waitFooter(page, firstState) await waitFooter(page, firstState)
await goto(page, oneDir, second) await goto(page, oneDir, second)
await waitFooter(page, secondState) await waitFooter(page, secondState)
await goto(page, twoDir, third) await goto(page, twoDir, third)
await waitFooter(page, thirdState) await waitFooter(page, thirdState)
await goto(page, root, first) await goto(page, root, first)
await waitFooter(page, firstState) await waitFooter(page, firstState)
})
}) })
test("variant preserved when switching agent modes", async ({ page, project }) => { test("variant preserved when switching agent modes", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1440, height: 900 }) await page.setViewportSize({ width: 1440, height: 900 })
await project.open() await withProject(async ({ directory, gotoSession }) => {
await project.gotoSession() await gotoSession()
await ensureVariant(page, project.directory) await ensureVariant(page, directory)
const updated = await chooseDifferentVariant(page) const updated = await chooseDifferentVariant(page)
const available = await agents(page) const available = await agents(page)
const other = available.find((name) => name !== updated.agent) const other = available.find((name) => name !== updated.agent)
test.skip(!other, "only one agent available") test.skip(!other, "only one agent available")
if (!other) return if (!other) return
await choose(page, promptAgentSelector, other) await choose(page, promptAgentSelector, other)
await waitFooter(page, { agent: other, variant: updated.variant }) await waitFooter(page, { agent: other, variant: updated.variant })
await choose(page, promptAgentSelector, updated.agent) await choose(page, promptAgentSelector, updated.agent)
await waitFooter(page, { agent: updated.agent, variant: updated.variant }) await waitFooter(page, { agent: updated.agent, variant: updated.variant })
})
}) })
+142 -154
View File
@@ -1,6 +1,6 @@
import { waitSessionIdle, withSession } from "../actions" import { waitSessionIdle, withSession } from "../actions"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { bodyText } from "../prompt/mock" import { createSdk } from "../utils"
const count = 14 const count = 14
@@ -40,19 +40,8 @@ function edit(file: string, prev: string, next: string) {
) )
} }
async function patchWithMock( async function patch(sdk: ReturnType<typeof createSdk>, sessionID: string, patchText: string) {
llm: Parameters<typeof test>[0]["llm"], await sdk.session.promptAsync({
sdk: Parameters<typeof withSession>[0],
sessionID: string,
patchText: string,
) {
const callsBefore = await llm.calls()
await llm.toolMatch(
(hit) => bodyText(hit).includes("Your only valid response is one apply_patch tool call."),
"apply_patch",
{ patchText },
)
await sdk.session.prompt({
sessionID, sessionID,
agent: "build", agent: "build",
system: [ system: [
@@ -65,16 +54,7 @@ async function patchWithMock(
parts: [{ type: "text", text: "Apply the provided patch exactly once." }], parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
}) })
await expect.poll(() => llm.calls().then((c) => c > callsBefore), { timeout: 30_000 }).toBe(true) await waitSessionIdle(sdk, sessionID, 120_000)
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 120_000 },
)
.toBeGreaterThan(0)
} }
async function show(page: Parameters<typeof test>[0]["page"]) { async function show(page: Parameters<typeof test>[0]["page"]) {
@@ -253,7 +233,8 @@ async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
} }
} }
test("review applies inline comment clicks without horizontal overflow", async ({ page, llm, project }) => { test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
test.skip(true, "Flaky in CI for now.")
test.setTimeout(180_000) test.setTimeout(180_000)
const tag = `review-comment-${Date.now()}` const tag = `review-comment-${Date.now()}`
@@ -262,45 +243,48 @@ test("review applies inline comment clicks without horizontal overflow", async (
await page.setViewportSize({ width: 1280, height: 900 }) await page.setViewportSize({ width: 1280, height: 900 })
await project.open() await withProject(async (project) => {
await withSession(project.sdk, `e2e review comment ${tag}`, async (session) => { const sdk = createSdk(project.directory)
project.trackSession(session.id)
await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
await expect await withSession(sdk, `e2e review comment ${tag}`, async (session) => {
.poll( await patch(sdk, session.id, seed([{ file, mark: tag }]))
async () => {
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
)
.toBe(1)
await project.gotoSession(session.id) await expect
await show(page) .poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
)
.toBe(1)
const tab = page.getByRole("tab", { name: /Review/i }).first() await project.gotoSession(session.id)
await expect(tab).toBeVisible() await show(page)
await tab.click()
await expand(page) const tab = page.getByRole("tab", { name: /Review/i }).first()
await waitMark(page, file, tag) await expect(tab).toBeVisible()
await comment(page, file, note) await tab.click()
await expect await expand(page)
.poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) await waitMark(page, file, tag)
.toBeLessThanOrEqual(1) await comment(page, file, note)
await expect
.poll(async () => (await overflow(page, file))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) await expect
.toBeLessThanOrEqual(1) .poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
await expect .toBeLessThanOrEqual(1)
.poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) await expect
.toBeLessThanOrEqual(1) .poll(async () => (await overflow(page, file))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
})
}) })
}) })
test("review file comments submit on click without clipping actions", async ({ page, llm, project }) => { test("review file comments submit on click without clipping actions", async ({ page, withProject }) => {
test.skip(true, "Flaky in CI for now.")
test.setTimeout(180_000) test.setTimeout(180_000)
const tag = `review-file-comment-${Date.now()}` const tag = `review-file-comment-${Date.now()}`
@@ -309,46 +293,49 @@ test("review file comments submit on click without clipping actions", async ({ p
await page.setViewportSize({ width: 1280, height: 900 }) await page.setViewportSize({ width: 1280, height: 900 })
await project.open() await withProject(async (project) => {
await withSession(project.sdk, `e2e review file comment ${tag}`, async (session) => { const sdk = createSdk(project.directory)
project.trackSession(session.id)
await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
await expect await withSession(sdk, `e2e review file comment ${tag}`, async (session) => {
.poll( await patch(sdk, session.id, seed([{ file, mark: tag }]))
async () => {
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
)
.toBe(1)
await project.gotoSession(session.id) await expect
await show(page) .poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
)
.toBe(1)
const tab = page.getByRole("tab", { name: /Review/i }).first() await project.gotoSession(session.id)
await expect(tab).toBeVisible() await show(page)
await tab.click()
await expand(page) const tab = page.getByRole("tab", { name: /Review/i }).first()
await waitMark(page, file, tag) await expect(tab).toBeVisible()
await openReviewFile(page, file) await tab.click()
await fileComment(page, note)
await expect await expand(page)
.poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) await waitMark(page, file, tag)
.toBeLessThanOrEqual(1) await openReviewFile(page, file)
await expect await fileComment(page, note)
.poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1) await expect
await expect .poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) .toBeLessThanOrEqual(1)
.toBeLessThanOrEqual(1) await expect
.poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
})
}) })
}) })
test.fixme("review keeps scroll position after a live diff update", async ({ page, llm, project }) => { 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) test.setTimeout(180_000)
const tag = `review-${Date.now()}` const tag = `review-${Date.now()}`
@@ -358,83 +345,84 @@ test.fixme("review keeps scroll position after a live diff update", async ({ pag
await page.setViewportSize({ width: 1600, height: 1000 }) await page.setViewportSize({ width: 1600, height: 1000 })
await project.open() await withProject(async (project) => {
await withSession(project.sdk, `e2e review ${tag}`, async (session) => { const sdk = createSdk(project.directory)
project.trackSession(session.id)
await patchWithMock(llm, project.sdk, session.id, seed(list))
await expect await withSession(sdk, `e2e review ${tag}`, async (session) => {
.poll( await patch(sdk, session.id, seed(list))
async () => {
const info = await project.sdk.session.get({ sessionID: session.id }).then((res) => res.data)
return info?.summary?.files ?? 0
},
{ timeout: 60_000 },
)
.toBe(list.length)
await expect await expect
.poll( .poll(
async () => { async () => {
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) const info = await sdk.session.get({ sessionID: session.id }).then((res) => res.data)
return diff.length return info?.summary?.files ?? 0
}, },
{ timeout: 60_000 }, { timeout: 60_000 },
) )
.toBe(list.length) .toBe(list.length)
await project.gotoSession(session.id) await expect
await show(page) .poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
)
.toBe(list.length)
const tab = page.getByRole("tab", { name: /Review/i }).first() await project.gotoSession(session.id)
await expect(tab).toBeVisible() await show(page)
await tab.click()
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first() const tab = page.getByRole("tab", { name: /Review/i }).first()
await expect(view).toBeVisible() await expect(tab).toBeVisible()
const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ }) await tab.click()
await expect(heads).toHaveCount(list.length, { timeout: 60_000 })
await expand(page) const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
await waitMark(page, hit.file, hit.mark) await expect(view).toBeVisible()
const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
const row = page await expect(heads).toHaveCount(list.length, {
.getByRole("heading", { timeout: 60_000,
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) await expand(page)
const prev = await spot(page, hit.file) await waitMark(page, hit.file, hit.mark)
if (!prev) throw new Error(`missing review row for ${hit.file}`)
await patchWithMock(llm, project.sdk, session.id, edit(hit.file, hit.mark, next)) 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 await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200)
.poll( const prev = await spot(page, hit.file)
async () => { if (!prev) throw new Error(`missing review row for ${hit.file}`)
const diff = await project.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 patch(sdk, session.id, edit(hit.file, hit.mark, next))
await expect await expect
.poll( .poll(
async () => { async () => {
const next = await spot(page, hit.file) const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
if (!next) return Number.POSITIVE_INFINITY const item = diff.find((item) => item.file === hit.file)
return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y)) return typeof item?.after === "string" ? item.after : ""
}, },
{ timeout: 60_000 }, { timeout: 60_000 },
) )
.toBeLessThanOrEqual(32) .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)
})
}) })
}) })
+143 -143
View File
@@ -49,185 +49,185 @@ async function seedConversation(input: {
return { prompt, userMessageID } return { prompt, userMessageID }
} }
test("slash undo sets revert and restores prior prompt", async ({ page, project }) => { test("slash undo sets revert and restores prior prompt", async ({ page, withBackendProject }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
const token = `undo_${Date.now()}` const token = `undo_${Date.now()}`
await project.open() await withBackendProject(async (project) => {
const sdk = project.sdk const sdk = project.sdk
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => { await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
project.trackSession(session.id) await project.gotoSession(session.id)
await project.gotoSession(session.id)
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token }) const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
await seeded.prompt.click() await seeded.prompt.click()
await page.keyboard.type("/undo") await page.keyboard.type("/undo")
const undo = page.locator('[data-slash-id="session.undo"]').first() const undo = page.locator('[data-slash-id="session.undo"]').first()
await expect(undo).toBeVisible() await expect(undo).toBeVisible()
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await expect await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000, timeout: 30_000,
}) })
.toBe(seeded.userMessageID) .toBe(seeded.userMessageID)
await expect(seeded.prompt).toContainText(token) await expect(seeded.prompt).toContainText(token)
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0) await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0)
})
}) })
}) })
test("slash redo clears revert and restores latest state", async ({ page, project }) => { test("slash redo clears revert and restores latest state", async ({ page, withBackendProject }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
const token = `redo_${Date.now()}` const token = `redo_${Date.now()}`
await project.open() await withBackendProject(async (project) => {
const sdk = project.sdk const sdk = project.sdk
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => { await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
project.trackSession(session.id) await project.gotoSession(session.id)
await project.gotoSession(session.id)
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token }) const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
await seeded.prompt.click() await seeded.prompt.click()
await page.keyboard.type("/undo") await page.keyboard.type("/undo")
const undo = page.locator('[data-slash-id="session.undo"]').first() const undo = page.locator('[data-slash-id="session.undo"]').first()
await expect(undo).toBeVisible() await expect(undo).toBeVisible()
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await expect await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000, timeout: 30_000,
}) })
.toBe(seeded.userMessageID) .toBe(seeded.userMessageID)
await seeded.prompt.click() await seeded.prompt.click()
await page.keyboard.press(`${modKey}+A`) await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace") await page.keyboard.press("Backspace")
await page.keyboard.type("/redo") await page.keyboard.type("/redo")
const redo = page.locator('[data-slash-id="session.redo"]').first() const redo = page.locator('[data-slash-id="session.redo"]').first()
await expect(redo).toBeVisible() await expect(redo).toBeVisible()
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await expect await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000, timeout: 30_000,
}) })
.toBeUndefined() .toBeUndefined()
await expect(seeded.prompt).not.toContainText(token) await expect(seeded.prompt).not.toContainText(token)
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1) await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1)
})
}) })
}) })
test("slash undo/redo traverses multi-step revert stack", async ({ page, project }) => { test("slash undo/redo traverses multi-step revert stack", async ({ page, withBackendProject }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
const firstToken = `undo_redo_first_${Date.now()}` const firstToken = `undo_redo_first_${Date.now()}`
const secondToken = `undo_redo_second_${Date.now()}` const secondToken = `undo_redo_second_${Date.now()}`
await project.open() await withBackendProject(async (project) => {
const sdk = project.sdk const sdk = project.sdk
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => { await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
project.trackSession(session.id) await project.gotoSession(session.id)
await project.gotoSession(session.id)
const first = await seedConversation({ const first = await seedConversation({
page, page,
sdk, sdk,
sessionID: session.id, sessionID: session.id,
token: firstToken, token: firstToken,
})
const second = await seedConversation({
page,
sdk,
sessionID: session.id,
token: secondToken,
})
expect(first.userMessageID).not.toBe(second.userMessageID)
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 second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/undo")
const undo = page.locator('[data-slash-id="session.undo"]').first()
await expect(undo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(second.userMessageID)
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(0)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/undo")
await expect(undo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(first.userMessageID)
await expect(firstMessage).toHaveCount(0)
await expect(secondMessage).toHaveCount(0)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/redo")
const redo = page.locator('[data-slash-id="session.redo"]').first()
await expect(redo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(second.userMessageID)
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(0)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/redo")
await expect(redo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBeUndefined()
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(1)
}) })
const second = await seedConversation({
page,
sdk,
sessionID: session.id,
token: secondToken,
})
expect(first.userMessageID).not.toBe(second.userMessageID)
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 second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/undo")
const undo = page.locator('[data-slash-id="session.undo"]').first()
await expect(undo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(second.userMessageID)
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(0)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/undo")
await expect(undo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(first.userMessageID)
await expect(firstMessage).toHaveCount(0)
await expect(secondMessage).toHaveCount(0)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/redo")
const redo = page.locator('[data-slash-id="session.redo"]').first()
await expect(redo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(second.userMessageID)
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(0)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/redo")
await expect(redo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBeUndefined()
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(1)
}) })
}) })
+107 -107
View File
@@ -31,152 +31,152 @@ async function seedMessage(sdk: Sdk, sessionID: string) {
.toBeGreaterThan(0) .toBeGreaterThan(0)
} }
test("session can be renamed via header menu", async ({ page, project }) => { test("session can be renamed via header menu", async ({ page, withBackendProject }) => {
const stamp = Date.now() const stamp = Date.now()
const originalTitle = `e2e rename test ${stamp}` const originalTitle = `e2e rename test ${stamp}`
const renamedTitle = `e2e renamed ${stamp}` const renamedTitle = `e2e renamed ${stamp}`
await project.open() await withBackendProject(async (project) => {
await withSession(project.sdk, originalTitle, async (session) => { await withSession(project.sdk, originalTitle, async (session) => {
project.trackSession(session.id) await seedMessage(project.sdk, session.id)
await seedMessage(project.sdk, session.id) await project.gotoSession(session.id)
await project.gotoSession(session.id) await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
const menu = await openSessionMoreMenu(page, session.id) const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /rename/i) await clickMenuItem(menu, /rename/i)
const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first() const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
await expect(input).toBeVisible() await expect(input).toBeVisible()
await expect(input).toBeFocused() await expect(input).toBeFocused()
await input.fill(renamedTitle) await input.fill(renamedTitle)
await expect(input).toHaveValue(renamedTitle) await expect(input).toHaveValue(renamedTitle)
await input.press("Enter") await input.press("Enter")
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.title return data?.title
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
) )
.toBe(renamedTitle) .toBe(renamedTitle)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle) await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
})
}) })
}) })
test("session can be archived via header menu", async ({ page, project }) => { test("session can be archived via header menu", async ({ page, withBackendProject }) => {
const stamp = Date.now() const stamp = Date.now()
const title = `e2e archive test ${stamp}` const title = `e2e archive test ${stamp}`
await project.open() await withBackendProject(async (project) => {
await withSession(project.sdk, title, async (session) => { await withSession(project.sdk, title, async (session) => {
project.trackSession(session.id) await seedMessage(project.sdk, session.id)
await seedMessage(project.sdk, session.id) await project.gotoSession(session.id)
await project.gotoSession(session.id) const menu = await openSessionMoreMenu(page, session.id)
const menu = await openSessionMoreMenu(page, session.id) await clickMenuItem(menu, /archive/i)
await clickMenuItem(menu, /archive/i)
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.time?.archived return data?.time?.archived
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
) )
.not.toBeUndefined() .not.toBeUndefined()
await openSidebar(page) await openSidebar(page)
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0) await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
})
}) })
}) })
test("session can be deleted via header menu", async ({ page, project }) => { test("session can be deleted via header menu", async ({ page, withBackendProject }) => {
const stamp = Date.now() const stamp = Date.now()
const title = `e2e delete test ${stamp}` const title = `e2e delete test ${stamp}`
await project.open() await withBackendProject(async (project) => {
await withSession(project.sdk, title, async (session) => { await withSession(project.sdk, title, async (session) => {
project.trackSession(session.id) await seedMessage(project.sdk, session.id)
await seedMessage(project.sdk, session.id) await project.gotoSession(session.id)
await project.gotoSession(session.id) const menu = await openSessionMoreMenu(page, session.id)
const menu = await openSessionMoreMenu(page, session.id) await clickMenuItem(menu, /delete/i)
await clickMenuItem(menu, /delete/i) await confirmDialog(page, /delete/i)
await confirmDialog(page, /delete/i)
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await project.sdk.session const data = await project.sdk.session
.get({ sessionID: session.id }) .get({ sessionID: session.id })
.then((r) => r.data) .then((r) => r.data)
.catch(() => undefined) .catch(() => undefined)
return data?.id return data?.id
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
) )
.toBeUndefined() .toBeUndefined()
await openSidebar(page) await openSidebar(page)
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0) await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
})
}) })
}) })
test("session can be shared and unshared via header button", async ({ page, project }) => { test("session can be shared and unshared via header button", async ({ page, withBackendProject }) => {
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).") test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
const stamp = Date.now() const stamp = Date.now()
const title = `e2e share test ${stamp}` const title = `e2e share test ${stamp}`
await project.open() await withBackendProject(async (project) => {
await withSession(project.sdk, title, async (session) => { await withSession(project.sdk, title, async (session) => {
project.trackSession(session.id) await seedMessage(project.sdk, session.id)
await project.gotoSession(session.id) await project.gotoSession(session.id)
await project.prompt(`share seed ${stamp}`)
const shared = await openSharePopover(page) const shared = await openSharePopover(page)
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first() const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
await expect(publish).toBeVisible({ timeout: 30_000 }) await expect(publish).toBeVisible({ timeout: 30_000 })
await publish.click() await publish.click()
await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({ await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({
timeout: 30_000, timeout: 30_000,
}) })
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined return data?.share?.url || undefined
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
) )
.not.toBeUndefined() .not.toBeUndefined()
const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first() const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()
await expect(unpublish).toBeVisible({ timeout: 30_000 }) await expect(unpublish).toBeVisible({ timeout: 30_000 })
await unpublish.click() await unpublish.click()
await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({ await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
timeout: 30_000, timeout: 30_000,
}) })
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined return data?.share?.url || undefined
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
) )
.toBeUndefined() .toBeUndefined()
const unshared = await openSharePopover(page) const unshared = await openSharePopover(page)
await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({ await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
timeout: 30_000, timeout: 30_000,
})
}) })
}) })
}) })
+3 -13
View File
@@ -88,20 +88,10 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
return document.documentElement.getAttribute("data-theme") return document.documentElement.getAttribute("data-theme")
}) })
const currentTheme = (await select.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? "" const currentTheme = (await select.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
const trigger = select.locator('[data-slot="select-select-trigger"]')
const items = page.locator('[data-slot="select-select-item"]')
await trigger.click() await select.locator('[data-slot="select-select-trigger"]').click()
const open = await expect
.poll(async () => (await items.count()) > 0, { timeout: 5_000 }) const items = page.locator('[data-slot="select-select-item"]')
.toBe(true)
.then(() => true)
.catch(() => false)
if (!open) {
await trigger.click()
await expect.poll(async () => (await items.count()) > 0, { timeout: 10_000 }).toBe(true)
}
await expect(items.first()).toBeVisible()
const count = await items.count() const count = await items.count()
expect(count).toBeGreaterThan(1) expect(count).toBeGreaterThan(1)
@@ -48,61 +48,70 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
} }
}) })
test("open sidebar project popover stays closed after clicking avatar", async ({ page, project }) => { test("open sidebar project popover stays closed after clicking avatar", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject() const other = await createTestProject()
const slug = dirSlug(other) const slug = dirSlug(other)
try { try {
await project.open({ extra: [other] }) await withProject(
await openSidebar(page) async () => {
await openSidebar(page)
const projectButton = page.locator(projectSwitchSelector(slug)).first() const project = page.locator(projectSwitchSelector(slug)).first()
const card = page.locator('[data-component="hover-card-content"]') const card = page.locator('[data-component="hover-card-content"]')
await expect(projectButton).toBeVisible() await expect(project).toBeVisible()
await projectButton.hover() await project.hover()
await expect(card.getByText(/recent sessions/i)).toBeVisible() await expect(card.getByText(/recent sessions/i)).toBeVisible()
await projectButton.click() await page.mouse.down()
await expect(card).toHaveCount(0) await expect(card).toHaveCount(0)
await page.mouse.up()
await waitSession(page, { directory: other }) await waitSession(page, { directory: other })
await expect(card).toHaveCount(0) await expect(card).toHaveCount(0)
},
{ extra: [other] },
)
} finally { } finally {
await cleanupTestProject(other) await cleanupTestProject(other)
} }
}) })
test("open sidebar project switch activates on first tabbed enter", async ({ page, project }) => { test("open sidebar project switch activates on first tabbed enter", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject() const other = await createTestProject()
const slug = dirSlug(other) const slug = dirSlug(other)
try { try {
await project.open({ extra: [other] }) await withProject(
await openSidebar(page) async () => {
await defocus(page) await openSidebar(page)
await defocus(page)
const projectButton = page.locator(projectSwitchSelector(slug)).first() const project = page.locator(projectSwitchSelector(slug)).first()
await expect(projectButton).toBeVisible() await expect(project).toBeVisible()
let hit = false let hit = false
for (let i = 0; i < 20; i++) { for (let i = 0; i < 20; i++) {
hit = await projectButton.evaluate((el) => { hit = await project.evaluate((el) => {
return el.matches(":focus") || !!el.parentElement?.matches(":focus") return el.matches(":focus") || !!el.parentElement?.matches(":focus")
}) })
if (hit) break if (hit) break
await page.keyboard.press("Tab") await page.keyboard.press("Tab")
} }
expect(hit).toBe(true) expect(hit).toBe(true)
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await waitSession(page, { directory: other }) await waitSession(page, { directory: other })
},
{ extra: [other] },
)
} finally { } finally {
await cleanupTestProject(other) await cleanupTestProject(other)
} }
@@ -12,34 +12,35 @@ async function open(page: Page) {
return term return term
} }
test("terminal reconnects without replacing the pty", async ({ page, project }) => { test("terminal reconnects without replacing the pty", async ({ page, withProject }) => {
await project.open() await withProject(async ({ gotoSession }) => {
const name = `OPENCODE_E2E_RECONNECT_${Date.now()}` const name = `OPENCODE_E2E_RECONNECT_${Date.now()}`
const token = `E2E_RECONNECT_${Date.now()}` const token = `E2E_RECONNECT_${Date.now()}`
await project.gotoSession() await gotoSession()
const term = await open(page) const term = await open(page)
const id = await term.getAttribute("data-pty-id") const id = await term.getAttribute("data-pty-id")
if (!id) throw new Error("Active terminal missing data-pty-id") if (!id) throw new Error("Active terminal missing data-pty-id")
const prev = await terminalConnects(page, { term }) const prev = await terminalConnects(page, { term })
await runTerminal(page, { await runTerminal(page, {
term, term,
cmd: `export ${name}=${token}; echo ${token}`, cmd: `export ${name}=${token}; echo ${token}`,
token, token,
}) })
await disconnectTerminal(page, { term }) await disconnectTerminal(page, { term })
await expect.poll(() => terminalConnects(page, { term }), { timeout: 15_000 }).toBeGreaterThan(prev) await expect.poll(() => terminalConnects(page, { term }), { timeout: 15_000 }).toBeGreaterThan(prev)
await expect.poll(() => term.getAttribute("data-pty-id"), { timeout: 5_000 }).toBe(id) await expect.poll(() => term.getAttribute("data-pty-id"), { timeout: 5_000 }).toBe(id)
await runTerminal(page, { await runTerminal(page, {
term, term,
cmd: `echo $${name}`, cmd: `echo $${name}`,
token, token,
timeout: 15_000, timeout: 15_000,
})
}) })
}) })
+108 -105
View File
@@ -36,130 +36,133 @@ async function store(page: Page, key: string) {
}, key) }, key)
} }
test("inactive terminal tab buffers persist across tab switches", async ({ page, project }) => { test("inactive terminal tab buffers persist across tab switches", async ({ page, withProject }) => {
await project.open() await withProject(async ({ directory, gotoSession }) => {
const key = workspacePersistKey(project.directory, "terminal") const key = workspacePersistKey(directory, "terminal")
const one = `E2E_TERM_ONE_${Date.now()}` const one = `E2E_TERM_ONE_${Date.now()}`
const two = `E2E_TERM_TWO_${Date.now()}` const two = `E2E_TERM_TWO_${Date.now()}`
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]') const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
const first = tabs.filter({ hasText: /Terminal 1/ }).first() const first = tabs.filter({ hasText: /Terminal 1/ }).first()
const second = tabs.filter({ hasText: /Terminal 2/ }).first() const second = tabs.filter({ hasText: /Terminal 2/ }).first()
await project.gotoSession() await gotoSession()
await open(page) await open(page)
await runTerminal(page, { cmd: `echo ${one}`, token: one }) await runTerminal(page, { cmd: `echo ${one}`, token: one })
await page.getByRole("button", { name: /new terminal/i }).click() await page.getByRole("button", { name: /new terminal/i }).click()
await expect(tabs).toHaveCount(2) await expect(tabs).toHaveCount(2)
await runTerminal(page, { cmd: `echo ${two}`, token: two }) await runTerminal(page, { cmd: `echo ${two}`, token: two })
await first.click() await first.click()
await expect(first).toHaveAttribute("aria-selected", "true") await expect(first).toHaveAttribute("aria-selected", "true")
await expect await expect
.poll( .poll(
async () => { async () => {
const state = await store(page, key) const state = await store(page, key)
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? "" const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? "" const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
return { return {
first: first.includes(one), first: first.includes(one),
second: second.includes(two), second: second.includes(two),
} }
}, },
{ timeout: 5_000 }, { timeout: 5_000 },
) )
.toEqual({ first: false, second: true }) .toEqual({ first: false, second: true })
await second.click() await second.click()
await expect(second).toHaveAttribute("aria-selected", "true") await expect(second).toHaveAttribute("aria-selected", "true")
await expect await expect
.poll( .poll(
async () => { async () => {
const state = await store(page, key) const state = await store(page, key)
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? "" const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? "" const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
return { return {
first: first.includes(one), first: first.includes(one),
second: second.includes(two), second: second.includes(two),
} }
}, },
{ timeout: 5_000 }, { timeout: 5_000 },
) )
.toEqual({ first: true, second: false }) .toEqual({ first: true, second: false })
})
}) })
test("closing the active terminal tab falls back to the previous tab", async ({ page, project }) => { test("closing the active terminal tab falls back to the previous tab", async ({ page, withProject }) => {
await project.open() await withProject(async ({ directory, gotoSession }) => {
const key = workspacePersistKey(project.directory, "terminal") const key = workspacePersistKey(directory, "terminal")
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]') const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
await project.gotoSession() await gotoSession()
await open(page) await open(page)
await page.getByRole("button", { name: /new terminal/i }).click() await page.getByRole("button", { name: /new terminal/i }).click()
await expect(tabs).toHaveCount(2) await expect(tabs).toHaveCount(2)
const second = tabs.filter({ hasText: /Terminal 2/ }).first() const second = tabs.filter({ hasText: /Terminal 2/ }).first()
await second.click() await second.click()
await expect(second).toHaveAttribute("aria-selected", "true") await expect(second).toHaveAttribute("aria-selected", "true")
await second.hover() await second.hover()
await page await page
.getByRole("button", { name: /close terminal/i }) .getByRole("button", { name: /close terminal/i })
.nth(1) .nth(1)
.click({ force: true }) .click({ force: true })
const first = tabs.filter({ hasText: /Terminal 1/ }).first() const first = tabs.filter({ hasText: /Terminal 1/ }).first()
await expect(tabs).toHaveCount(1) await expect(tabs).toHaveCount(1)
await expect(first).toHaveAttribute("aria-selected", "true") await expect(first).toHaveAttribute("aria-selected", "true")
await expect await expect
.poll( .poll(
async () => { async () => {
const state = await store(page, key) const state = await store(page, key)
return { return {
count: state?.all.length ?? 0, count: state?.all.length ?? 0,
first: state?.all.some((item) => item.titleNumber === 1) ?? false, first: state?.all.some((item) => item.titleNumber === 1) ?? false,
} }
}, },
{ timeout: 15_000 }, { timeout: 15_000 },
) )
.toEqual({ count: 1, first: true }) .toEqual({ count: 1, first: true })
})
}) })
test("terminal tab can be renamed from the context menu", async ({ page, project }) => { test("terminal tab can be renamed from the context menu", async ({ page, withProject }) => {
await project.open() await withProject(async ({ directory, gotoSession }) => {
const key = workspacePersistKey(project.directory, "terminal") const key = workspacePersistKey(directory, "terminal")
const rename = `E2E term ${Date.now()}` const rename = `E2E term ${Date.now()}`
const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first() const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first()
await project.gotoSession() await gotoSession()
await open(page) await open(page)
await expect(tab).toContainText(/Terminal 1/) await expect(tab).toContainText(/Terminal 1/)
await tab.click({ button: "right" }) await tab.click({ button: "right" })
const menu = page.locator(dropdownMenuContentSelector).first() const menu = page.locator(dropdownMenuContentSelector).first()
await expect(menu).toBeVisible() await expect(menu).toBeVisible()
await menu.getByRole("menuitem", { name: /^Rename$/i }).click() await menu.getByRole("menuitem", { name: /^Rename$/i }).click()
await expect(menu).toHaveCount(0) await expect(menu).toHaveCount(0)
const input = page.locator('#terminal-panel input[type="text"]').first() const input = page.locator('#terminal-panel input[type="text"]').first()
await expect(input).toBeVisible() await expect(input).toBeVisible()
await input.fill(rename) await input.fill(rename)
await input.press("Enter") await input.press("Enter")
await expect(input).toHaveCount(0) await expect(input).toHaveCount(0)
await expect(tab).toContainText(rename) await expect(tab).toContainText(rename)
await expect await expect
.poll( .poll(
async () => { async () => {
const state = await store(page, key) const state = await store(page, key)
return state?.all[0]?.title return state?.all[0]?.title
}, },
{ timeout: 5_000 }, { timeout: 5_000 },
) )
.toBe(rename) .toBe(rename)
})
}) })
+2 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.4.3", "version": "1.3.13",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -15,7 +15,6 @@
"build": "vite build", "build": "vite build",
"serve": "vite preview", "serve": "vite preview",
"test": "bun run test:unit", "test": "bun run test:unit",
"test:ci": "mkdir -p .artifacts/unit && bun test --preload ./happydom.ts ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"test:unit": "bun test --preload ./happydom.ts ./src", "test:unit": "bun test --preload ./happydom.ts ./src",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src", "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test", "test:e2e": "playwright test",
@@ -47,10 +46,9 @@
"@solid-primitives/active-element": "2.1.3", "@solid-primitives/active-element": "2.1.3",
"@solid-primitives/audio": "1.4.2", "@solid-primitives/audio": "1.4.2",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/event-listener": "2.4.5",
"@solid-primitives/i18n": "2.2.1", "@solid-primitives/i18n": "2.2.1",
"@solid-primitives/media": "2.3.3", "@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.5", "@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3", "@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "catalog:", "@solid-primitives/storage": "catalog:",
"@solid-primitives/timer": "1.4.4", "@solid-primitives/timer": "1.4.4",
+1 -6
View File
@@ -7,11 +7,6 @@ const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
const command = `bun run dev -- --host 0.0.0.0 --port ${port}` const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
const reuse = !process.env.CI const reuse = !process.env.CI
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
const reporter = [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]] as const
if (process.env.PLAYWRIGHT_JUNIT_OUTPUT) {
reporter.push(["junit", { outputFile: process.env.PLAYWRIGHT_JUNIT_OUTPUT }])
}
export default defineConfig({ export default defineConfig({
testDir: "./e2e", testDir: "./e2e",
@@ -24,7 +19,7 @@ export default defineConfig({
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
workers, workers,
reporter, reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
webServer: { webServer: {
command, command,
url: baseURL, url: baseURL,
+3 -3
View File
@@ -87,7 +87,7 @@ const runnerEnv = {
let seed: ReturnType<typeof Bun.spawn> | undefined let seed: ReturnType<typeof Bun.spawn> | undefined
let runner: ReturnType<typeof Bun.spawn> | undefined let runner: ReturnType<typeof Bun.spawn> | undefined
let server: { stop: (close?: boolean) => Promise<void> | void } | undefined let server: { stop: () => Promise<void> | void } | undefined
let inst: { Instance: { disposeAll: () => Promise<void> | void } } | undefined let inst: { Instance: { disposeAll: () => Promise<void> | void } } | undefined
let cleaned = false let cleaned = false
@@ -100,7 +100,7 @@ const cleanup = async () => {
const jobs = [ const jobs = [
inst?.Instance.disposeAll(), inst?.Instance.disposeAll(),
typeof server?.stop === "function" ? server.stop() : undefined, server?.stop(),
keepSandbox ? undefined : fs.rm(sandbox, { recursive: true, force: true }), keepSandbox ? undefined : fs.rm(sandbox, { recursive: true, force: true }),
].filter(Boolean) ].filter(Boolean)
await Promise.allSettled(jobs) await Promise.allSettled(jobs)
@@ -158,7 +158,7 @@ try {
const servermod = await import("../../opencode/src/server/server") const servermod = await import("../../opencode/src/server/server")
inst = await import("../../opencode/src/project/instance") inst = await import("../../opencode/src/project/instance")
server = await servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" }) server = servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" })
console.log(`opencode server listening on http://127.0.0.1:${serverPort}`) console.log(`opencode server listening on http://127.0.0.1:${serverPort}`)
await waitForHealth(`http://127.0.0.1:${serverPort}/global/health`) await waitForHealth(`http://127.0.0.1:${serverPort}/global/health`)
+1
View File
@@ -182,6 +182,7 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
if (checkMode() === "background" || type === "http") return false if (checkMode() === "background" || type === "http") return false
} }
}).pipe( }).pipe(
effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }), Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }),
Effect.ensuring(Effect.sync(() => setCheckMode("background"))), Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
Effect.runPromise, Effect.runPromise,
+2 -2
View File
@@ -1,7 +1,6 @@
import { useIsRouting, useLocation } from "@solidjs/router" import { useIsRouting, useLocation } from "@solidjs/router"
import { batch, createEffect, onCleanup, onMount } from "solid-js" import { batch, createEffect, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -350,12 +349,13 @@ export function DebugBar() {
syncHeap() syncHeap()
start() start()
makeEventListener(document, "visibilitychange", vis) document.addEventListener("visibilitychange", vis)
onCleanup(() => { onCleanup(() => {
if (one !== 0) cancelAnimationFrame(one) if (one !== 0) cancelAnimationFrame(one)
if (two !== 0) cancelAnimationFrame(two) if (two !== 0) cancelAnimationFrame(two)
stop() stop()
document.removeEventListener("visibilitychange", vis)
for (const ob of obs) ob.disconnect() for (const ob of obs) ob.disconnect()
}) })
}) })
@@ -86,7 +86,6 @@ const ModelList: Component<{
} }
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref"> type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
export function ModelSelectorPopover(props: { export function ModelSelectorPopover(props: {
provider?: string provider?: string
@@ -94,31 +93,25 @@ export function ModelSelectorPopover(props: {
children?: JSX.Element children?: JSX.Element
triggerAs?: ValidComponent triggerAs?: ValidComponent
triggerProps?: ModelSelectorTriggerProps triggerProps?: ModelSelectorTriggerProps
onClose?: (cause: "escape" | "select") => void
}) { }) {
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
open: boolean open: boolean
dismiss: Dismiss | null dismiss: "escape" | "outside" | null
}>({ }>({
open: false, open: false,
dismiss: null, dismiss: null,
}) })
const dialog = useDialog() const dialog = useDialog()
const close = (dismiss: Dismiss) => {
setStore("dismiss", dismiss)
setStore("open", false)
}
const handleManage = () => { const handleManage = () => {
close("manage") setStore("open", false)
void import("./dialog-manage-models").then((x) => { void import("./dialog-manage-models").then((x) => {
dialog.show(() => <x.DialogManageModels />) dialog.show(() => <x.DialogManageModels />)
}) })
} }
const handleConnectProvider = () => { const handleConnectProvider = () => {
close("provider") setStore("open", false)
void import("./dialog-select-provider").then((x) => { void import("./dialog-select-provider").then((x) => {
dialog.show(() => <x.DialogSelectProvider />) dialog.show(() => <x.DialogSelectProvider />)
}) })
@@ -143,19 +136,21 @@ export function ModelSelectorPopover(props: {
<Kobalte.Content <Kobalte.Content
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden" class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
onEscapeKeyDown={(event) => { onEscapeKeyDown={(event) => {
close("escape") setStore("dismiss", "escape")
setStore("open", false)
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
}} }}
onPointerDownOutside={() => close("outside")} onPointerDownOutside={() => {
onFocusOutside={() => close("outside")} setStore("dismiss", "outside")
setStore("open", false)
}}
onFocusOutside={() => {
setStore("dismiss", "outside")
setStore("open", false)
}}
onCloseAutoFocus={(event) => { onCloseAutoFocus={(event) => {
const dismiss = store.dismiss if (store.dismiss === "outside") event.preventDefault()
if (dismiss === "outside") event.preventDefault()
if (dismiss === "escape" || dismiss === "select") {
event.preventDefault()
props.onClose?.(dismiss)
}
setStore("dismiss", null) setStore("dismiss", null)
}} }}
> >
@@ -163,7 +158,7 @@ export function ModelSelectorPopover(props: {
<ModelList <ModelList
provider={props.provider} provider={props.provider}
model={props.model} model={props.model}
onSelect={() => close("select")} onSelect={() => setStore("open", false)}
class="p-1" class="p-1"
action={ action={
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
+125 -114
View File
@@ -243,6 +243,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}, },
) )
const working = createMemo(() => status()?.type !== "idle") const working = createMemo(() => status()?.type !== "idle")
const tip = () => {
if (working()) {
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
</div>
)
}
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
</div>
)
}
const imageAttachments = createMemo(() => const imageAttachments = createMemo(() =>
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
) )
@@ -280,31 +297,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (store.mode === "shell") return 0 if (store.mode === "shell") return 0
return prompt.context.items().filter((item) => !!item.comment?.trim()).length return prompt.context.items().filter((item) => !!item.comment?.trim()).length
}) })
const blank = createMemo(() => {
const text = prompt
.current()
.map((part) => ("content" in part ? part.content : ""))
.join("")
return text.trim().length === 0 && imageAttachments().length === 0 && commentCount() === 0
})
const stopping = createMemo(() => working() && blank())
const tip = () => {
if (stopping()) {
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
</div>
)
}
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
</div>
)
}
const contextItems = createMemo(() => { const contextItems = createMemo(() => {
const items = prompt.context.items() const items = prompt.context.items()
@@ -510,15 +502,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return getCursorPosition(editorRef) return getCursorPosition(editorRef)
} }
const restoreFocus = () => {
requestAnimationFrame(() => {
const cursor = prompt.cursor() ?? promptLength(prompt.current())
editorRef.focus()
setCursorPosition(editorRef, cursor)
queueScroll()
})
}
const renderEditorWithCursor = (parts: Prompt) => { const renderEditorWithCursor = (parts: Prompt) => {
const cursor = currentCursor() const cursor = currentCursor()
renderEditor(parts) renderEditor(parts)
@@ -1079,6 +1062,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory) if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
return permission.isAutoAccepting(id, sdk.directory) return permission.isAutoAccepting(id, sdk.directory)
}) })
const acceptLabel = createMemo(() =>
language.t(accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable"),
)
const toggleAccept = () => {
if (!params.id) {
permission.toggleAutoAcceptDirectory(sdk.directory)
return
}
permission.toggleAutoAccept(params.id, sdk.directory)
}
const { abort, handleSubmit } = createPromptSubmit({ const { abort, handleSubmit } = createPromptSubmit({
info, info,
@@ -1322,7 +1316,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onMouseDown={(e) => { onMouseDown={(e) => {
const target = e.target const target = e.target
if (!(target instanceof HTMLElement)) return if (!(target instanceof HTMLElement)) return
if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) { if (
target.closest(
'[data-action="prompt-attach"], [data-action="prompt-submit"], [data-action="prompt-permissions"]',
)
) {
return return
} }
editorRef?.focus() editorRef?.focus()
@@ -1400,17 +1398,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
/> />
<div class="flex items-center gap-1 pointer-events-auto"> <div class="flex items-center gap-1 pointer-events-auto">
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}> <Tooltip placement="top" inactive={!prompt.dirty() && !working()} value={tip()}>
<IconButton <IconButton
data-action="prompt-submit" data-action="prompt-submit"
type="submit" type="submit"
disabled={store.mode !== "normal" || (!working() && blank())} disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
tabIndex={store.mode === "normal" ? undefined : -1} tabIndex={store.mode === "normal" ? undefined : -1}
icon={stopping() ? "stop" : "arrow-up"} icon={working() ? "stop" : "arrow-up"}
variant="primary" variant="primary"
class="size-8" class="size-8"
style={buttons()} style={buttons()}
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")} aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/> />
</Tooltip> </Tooltip>
</div> </div>
@@ -1473,10 +1471,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
size="normal" size="normal"
options={agentNames()} options={agentNames()}
current={local.agent.current()?.name ?? ""} current={local.agent.current()?.name ?? ""}
onSelect={(value) => { onSelect={local.agent.set}
local.agent.set(value)
restoreFocus()
}}
class="capitalize max-w-[160px] text-text-base" class="capitalize max-w-[160px] text-text-base"
valueClass="truncate text-13-regular text-text-base" valueClass="truncate text-13-regular text-text-base"
triggerStyle={control()} triggerStyle={control()}
@@ -1485,62 +1480,28 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
/> />
</TooltipKeybind> </TooltipKeybind>
</div> </div>
<Show when={store.mode !== "shell"}> <div data-component="prompt-model-control">
<div data-component="prompt-model-control"> <Show
<Show when={providers.paid().length > 0}
when={providers.paid().length > 0} fallback={
fallback={
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<Button
data-action="prompt-model"
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
style={control()}
onClick={() => {
void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
})
}}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</Button>
</TooltipKeybind>
}
>
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
gutter={4} gutter={4}
title={language.t("command.model.choose")} title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")} keybind={command.keybind("model.choose")}
> >
<ModelSelectorPopover <Button
model={local.model} data-action="prompt-model"
triggerAs={Button} as="div"
triggerProps={{ variant="ghost"
variant: "ghost", size="normal"
size: "normal", class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
style: control(), style={control()}
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group", onClick={() => {
"data-action": "prompt-model", void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
})
}} }}
onClose={restoreFocus}
> >
<Show when={local.model.current()?.provider?.id}> <Show when={local.model.current()?.provider?.id}>
<ProviderIcon <ProviderIcon
@@ -1553,35 +1514,85 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
{local.model.current()?.name ?? language.t("dialog.model.select.title")} {local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span> </span>
<Icon name="chevron-down" size="small" class="shrink-0" /> <Icon name="chevron-down" size="small" class="shrink-0" />
</ModelSelectorPopover> </Button>
</TooltipKeybind> </TooltipKeybind>
</Show> }
</div> >
<div data-component="prompt-variant-control">
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
gutter={4} gutter={4}
title={language.t("command.model.variant.cycle")} title={language.t("command.model.choose")}
keybind={command.keybind("model.variant.cycle")} keybind={command.keybind("model.choose")}
> >
<Select <ModelSelectorPopover
size="normal" model={local.model}
options={variants()} triggerAs={Button}
current={local.model.variant.current() ?? "default"} triggerProps={{
label={(x) => (x === "default" ? language.t("common.default") : x)} variant: "ghost",
onSelect={(value) => { size: "normal",
local.model.variant.set(value === "default" ? undefined : value) style: control(),
restoreFocus() class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
"data-action": "prompt-model",
}} }}
class="capitalize max-w-[160px] text-text-base" >
valueClass="truncate text-13-regular text-text-base" <Show when={local.model.current()?.provider?.id}>
triggerStyle={control()} <ProviderIcon
triggerProps={{ "data-action": "prompt-model-variant" }} id={local.model.current()?.provider?.id ?? ""}
variant="ghost" class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
/> style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</ModelSelectorPopover>
</TooltipKeybind> </TooltipKeybind>
</div> </Show>
</Show> </div>
<div data-component="prompt-variant-control">
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.variant.cycle")}
>
<Select
size="normal"
options={variants()}
current={local.model.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
class="capitalize max-w-[160px] text-text-base"
valueClass="truncate text-13-regular text-text-base"
triggerStyle={control()}
triggerProps={{ "data-action": "prompt-model-variant" }}
variant="ghost"
/>
</TooltipKeybind>
</div>
<TooltipKeybind
placement="top"
gutter={8}
title={acceptLabel()}
keybind={command.keybind("permissions.autoaccept")}
>
<Button
data-action="prompt-permissions"
variant="ghost"
onClick={toggleAccept}
classList={{
"h-7 w-7 p-0 shrink-0 flex items-center justify-center": true,
"text-text-base": !accepting(),
"hover:bg-surface-success-base": accepting(),
}}
style={control()}
aria-label={acceptLabel()}
aria-pressed={accepting()}
>
<Icon name="shield" size="small" classList={{ "text-icon-success-base": accepting() }} />
</Button>
</TooltipKeybind>
</div> </div>
</div> </div>
</div> </div>
@@ -1,5 +1,4 @@
import { onMount } from "solid-js" import { onCleanup, onMount } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt" import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -182,9 +181,15 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
} }
onMount(() => { onMount(() => {
makeEventListener(document, "dragover", handleGlobalDragOver) document.addEventListener("dragover", handleGlobalDragOver)
makeEventListener(document, "dragleave", handleGlobalDragLeave) document.addEventListener("dragleave", handleGlobalDragLeave)
makeEventListener(document, "drop", handleGlobalDrop) document.addEventListener("drop", handleGlobalDrop)
})
onCleanup(() => {
document.removeEventListener("dragover", handleGlobalDragOver)
document.removeEventListener("dragleave", handleGlobalDragLeave)
document.removeEventListener("drop", handleGlobalDrop)
}) })
return { return {
@@ -1,6 +1,5 @@
import { Component, For, Show } from "solid-js" import { Component, For, Show } from "solid-js"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import type { ImageAttachmentPart } from "@/context/prompt" import type { ImageAttachmentPart } from "@/context/prompt"
type PromptImageAttachmentsProps = { type PromptImageAttachmentsProps = {
@@ -23,36 +22,34 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
<div class="flex flex-wrap gap-2 px-3 pt-3"> <div class="flex flex-wrap gap-2 px-3 pt-3">
<For each={props.attachments}> <For each={props.attachments}>
{(attachment) => ( {(attachment) => (
<Tooltip value={attachment.filename} placement="top" contentClass="break-all"> <div class="relative group">
<div class="relative group"> <Show
<Show when={attachment.mime.startsWith("image/")}
when={attachment.mime.startsWith("image/")} fallback={
fallback={ <div class={fallbackClass}>
<div class={fallbackClass}> <Icon name="folder" class="size-6 text-text-weak" />
<Icon name="folder" class="size-6 text-text-weak" /> </div>
</div> }
} >
> <img
<img src={attachment.dataUrl}
src={attachment.dataUrl} alt={attachment.filename}
alt={attachment.filename} class={imageClass}
class={imageClass} onClick={() => props.onOpen(attachment)}
onClick={() => props.onOpen(attachment)} />
/> </Show>
</Show> <button
<button type="button"
type="button" onClick={() => props.onRemove(attachment.id)}
onClick={() => props.onRemove(attachment.id)} class={removeClass}
class={removeClass} aria-label={props.removeLabel}
aria-label={props.removeLabel} >
> <Icon name="close" class="size-3 text-text-weak" />
<Icon name="close" class="size-3 text-text-weak" /> </button>
</button> <div class={nameClass}>
<div class={nameClass}> <span class="text-10-regular text-white truncate block">{attachment.filename}</span>
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
</div>
</div> </div>
</Tooltip> </div>
)} )}
</For> </For>
</div> </div>
@@ -146,7 +146,7 @@ beforeAll(async () => {
add: (value: { add: (value: {
directory?: string directory?: string
sessionID?: string sessionID?: string
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } } message: { agent: string; model: { providerID: string; modelID: string }; variant?: string }
}) => { }) => {
optimistic.push(value) optimistic.push(value)
optimisticSeeded.push( optimisticSeeded.push(
@@ -310,7 +310,8 @@ describe("prompt submit worktree selection", () => {
expect(optimistic[0]).toMatchObject({ expect(optimistic[0]).toMatchObject({
message: { message: {
agent: "agent", agent: "agent",
model: { providerID: "provider", modelID: "model", variant: "high" }, model: { providerID: "provider", modelID: "model" },
variant: "high",
}, },
}) })
}) })
@@ -13,7 +13,6 @@ import { usePermission } from "@/context/permission"
import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt" import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { promptProbe } from "@/testing/prompt"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree" import { Worktree as WorktreeState } from "@/utils/worktree"
import { buildRequestParts } from "./build-request-parts" import { buildRequestParts } from "./build-request-parts"
@@ -121,7 +120,8 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
role: "user", role: "user",
time: { created: Date.now() }, time: { created: Date.now() },
agent: input.draft.agent, agent: input.draft.agent,
model: { ...input.draft.model, variant: input.draft.variant }, model: input.draft.model,
variant: input.draft.variant,
} }
const add = () => const add = () =>
@@ -307,7 +307,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
input.addToHistory(currentPrompt, mode) input.addToHistory(currentPrompt, mode)
input.resetHistoryNavigation() input.resetHistoryNavigation()
promptProbe.start()
const projectDirectory = sdk.directory const projectDirectory = sdk.directory
const isNewSession = !params.id const isNewSession = !params.id
@@ -427,7 +426,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return return
} }
promptProbe.submit({ sessionID: session.id, directory: sessionDirectory })
input.onSubmit?.() input.onSubmit?.()
if (mode === "shell") { if (mode === "shell") {
@@ -1,11 +1,11 @@
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { import {
children, children,
createEffect, createEffect,
createMemo, createMemo,
createSignal, createSignal,
type JSXElement, type JSXElement,
onCleanup,
onMount, onMount,
type ParentProps, type ParentProps,
Show, Show,
@@ -46,9 +46,12 @@ export function ServerRow(props: ServerRowProps) {
}) })
onMount(() => { onMount(() => {
if (typeof ResizeObserver !== "function") return
createResizeObserver([nameRef, versionRef], check)
check() check()
if (typeof ResizeObserver !== "function") return
const observer = new ResizeObserver(check)
if (nameRef) observer.observe(nameRef)
if (versionRef) observer.observe(versionRef)
onCleanup(() => observer.disconnect())
}) })
const tooltipValue = () => ( const tooltipValue = () => (
@@ -8,9 +8,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { useParams } from "@solidjs/router"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { import {
monoDefault, monoDefault,
@@ -21,7 +19,6 @@ import {
sansInput, sansInput,
useSettings, useSettings,
} from "@/context/settings" } from "@/context/settings"
import { decode64 } from "@/utils/base64"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "./link" import { Link } from "./link"
import { SettingsList } from "./settings-list" import { SettingsList } from "./settings-list"
@@ -67,9 +64,7 @@ const playDemoSound = (id: string | undefined) => {
export const SettingsGeneral: Component = () => { export const SettingsGeneral: Component = () => {
const theme = useTheme() const theme = useTheme()
const language = useLanguage() const language = useLanguage()
const permission = usePermission()
const platform = usePlatform() const platform = usePlatform()
const params = useParams()
const settings = useSettings() const settings = useSettings()
onMount(() => { onMount(() => {
@@ -81,31 +76,6 @@ export const SettingsGeneral: Component = () => {
}) })
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux") const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const dir = createMemo(() => decode64(params.dir))
const accepting = createMemo(() => {
const value = dir()
if (!value) return false
if (!params.id) return permission.isAutoAcceptingDirectory(value)
return permission.isAutoAccepting(params.id, value)
})
const toggleAccept = (checked: boolean) => {
const value = dir()
if (!value) return
if (!params.id) {
if (permission.isAutoAcceptingDirectory(value) === checked) return
permission.toggleAutoAcceptDirectory(value)
return
}
if (checked) {
permission.enableAutoAccept(params.id, value)
return
}
permission.disableAutoAccept(params.id, value)
}
const check = () => { const check = () => {
if (!platform.checkUpdate) return if (!platform.checkUpdate) return
@@ -169,6 +139,11 @@ export const SettingsGeneral: Component = () => {
{ value: "dark", label: language.t("theme.scheme.dark") }, { value: "dark", label: language.t("theme.scheme.dark") },
]) ])
const followupOptions = createMemo((): { value: "queue" | "steer"; label: string }[] => [
{ value: "queue", label: language.t("settings.general.row.followup.option.queue") },
{ value: "steer", label: language.t("settings.general.row.followup.option.steer") },
])
const languageOptions = createMemo(() => const languageOptions = createMemo(() =>
language.locales.map((locale) => ({ language.locales.map((locale) => ({
value: locale, value: locale,
@@ -231,15 +206,6 @@ export const SettingsGeneral: Component = () => {
/> />
</SettingsRow> </SettingsRow>
<SettingsRow
title={language.t("command.permissions.autoaccept.enable")}
description={language.t("toast.permissions.autoaccept.on.description")}
>
<div data-action="settings-auto-accept-permissions">
<Switch checked={accepting()} disabled={!dir()} onChange={toggleAccept} />
</div>
</SettingsRow>
<SettingsRow <SettingsRow
title={language.t("settings.general.row.reasoningSummaries.title")} title={language.t("settings.general.row.reasoningSummaries.title")}
description={language.t("settings.general.row.reasoningSummaries.description")} description={language.t("settings.general.row.reasoningSummaries.description")}
@@ -275,6 +241,24 @@ export const SettingsGeneral: Component = () => {
/> />
</div> </div>
</SettingsRow> </SettingsRow>
<SettingsRow
title={language.t("settings.general.row.followup.title")}
description={language.t("settings.general.row.followup.description")}
>
<Select
data-action="settings-followup"
options={followupOptions()}
current={followupOptions().find((o) => o.value === settings.general.followup())}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && settings.general.setFollowup(option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
triggerStyle={{ "min-width": "180px" }}
/>
</SettingsRow>
</SettingsList> </SettingsList>
</div> </div>
) )
@@ -1,6 +1,5 @@
import { Component, For, Show, createMemo, onCleanup, onMount } from "solid-js" import { Component, For, Show, createMemo, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -251,7 +250,8 @@ function useKeyCapture(input: {
input.stop() input.stop()
} }
makeEventListener(document, "keydown", handle, { capture: true }) document.addEventListener("keydown", handle, true)
onCleanup(() => document.removeEventListener("keydown", handle, true))
}) })
} }
+2 -7
View File
@@ -174,7 +174,6 @@ export const Terminal = (props: TerminalProps) => {
const auth = server.current?.http const auth = server.current?.http
const username = auth?.username ?? "opencode" const username = auth?.username ?? "opencode"
const password = auth?.password ?? "" const password = auth?.password ?? ""
const sameOrigin = new URL(url, location.href).origin === location.origin
let container!: HTMLDivElement let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"]) const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
const id = local.pty.id const id = local.pty.id
@@ -520,12 +519,8 @@ export const Terminal = (props: TerminalProps) => {
next.searchParams.set("directory", directory) next.searchParams.set("directory", directory)
next.searchParams.set("cursor", String(seek)) next.searchParams.set("cursor", String(seek))
next.protocol = next.protocol === "https:" ? "wss:" : "ws:" next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
if (!sameOrigin && password) { next.username = username
next.searchParams.set("auth_token", btoa(`${username}:${password}`)) next.password = password
// For same-origin requests, let the browser reuse the page's existing auth.
next.username = username
next.password = password
}
const socket = new WebSocket(next) const socket = new WebSocket(next)
socket.binaryType = "arraybuffer" socket.binaryType = "arraybuffer"
+5 -2
View File
@@ -2,7 +2,6 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js" import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { dict as en } from "@/i18n/en" import { dict as en } from "@/i18n/en"
@@ -379,7 +378,11 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
} }
onMount(() => { onMount(() => {
makeEventListener(document, "keydown", handleKeyDown) document.addEventListener("keydown", handleKeyDown)
})
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown)
}) })
function register(cb: () => CommandOption[]): void function register(cb: () => CommandOption[]): void
+14 -10
View File
@@ -1,8 +1,7 @@
import type { Event } from "@opencode-ai/sdk/v2/client" import type { Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus" import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener" import { batch, onCleanup } from "solid-js"
import { batch, onCleanup, onMount } from "solid-js"
import z from "zod" import z from "zod"
import { createSdkForServer } from "@/utils/server" import { createSdkForServer } from "@/utils/server"
import { useLanguage } from "./language" import { useLanguage } from "./language"
@@ -207,16 +206,21 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
clearHeartbeat() clearHeartbeat()
} }
onMount(() => { const onVisibility = () => {
makeEventListener(document, "visibilitychange", () => { if (typeof document === "undefined") return
if (document.visibilityState !== "visible") return if (document.visibilityState !== "visible") return
if (!started) return if (!started) return
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
attempt?.abort() attempt?.abort()
}) }
}) if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", onVisibility)
}
onCleanup(() => { onCleanup(() => {
if (typeof document !== "undefined") {
document.removeEventListener("visibilitychange", onVisibility)
}
stop() stop()
abort.abort() abort.abort()
flush() flush()
@@ -248,7 +248,7 @@ export async function bootstrapDirectory(input: {
input.sdk.vcs.get().then((x) => { input.sdk.vcs.get().then((x) => {
const next = x.data ?? input.store.vcs const next = x.data ?? input.store.vcs
input.setStore("vcs", next) input.setStore("vcs", next)
if (next) input.vcsCache.setStore("value", next) if (next?.branch) input.vcsCache.setStore("value", next)
}), }),
), ),
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))), () => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
@@ -494,10 +494,8 @@ describe("applyDirectoryEvent", () => {
}) })
test("updates vcs branch in store and cache", () => { test("updates vcs branch in store and cache", () => {
const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } })) const [store, setStore] = createStore(baseState())
const [cacheStore, setCacheStore] = createStore({ const [cacheStore, setCacheStore] = createStore({ value: undefined as State["vcs"] })
value: { branch: "main", default_branch: "main" } as State["vcs"],
})
applyDirectoryEvent({ applyDirectoryEvent({
event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } }, event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } },
@@ -513,8 +511,8 @@ describe("applyDirectoryEvent", () => {
}, },
}) })
expect(store.vcs).toEqual({ branch: "feature/test", default_branch: "main" }) expect(store.vcs).toEqual({ branch: "feature/test" })
expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" }) expect(cacheStore.value).toEqual({ branch: "feature/test" })
}) })
test("routes disposal and lsp events to side-effect handlers", () => { test("routes disposal and lsp events to side-effect handlers", () => {
@@ -1,6 +1,7 @@
import { Binary } from "@opencode-ai/util/binary" import { Binary } from "@opencode-ai/util/binary"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { import type {
FileDiff,
Message, Message,
Part, Part,
PermissionRequest, PermissionRequest,
@@ -8,13 +9,11 @@ import type {
QuestionRequest, QuestionRequest,
Session, Session,
SessionStatus, SessionStatus,
SnapshotFileDiff,
Todo, Todo,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import type { State, VcsCache } from "./types" import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim" import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache" import { dropSessionCaches } from "./session-cache"
import { diffs as list, message as clean } from "@/utils/diffs"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
@@ -162,8 +161,8 @@ export function applyDirectoryEvent(input: {
break break
} }
case "session.diff": { case "session.diff": {
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] } const props = event.properties as { sessionID: string; diff: FileDiff[] }
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" })) input.setStore("session_diff", props.sessionID, reconcile(props.diff, { key: "file" }))
break break
} }
case "todo.updated": { case "todo.updated": {
@@ -178,7 +177,7 @@ export function applyDirectoryEvent(input: {
break break
} }
case "message.updated": { case "message.updated": {
const info = clean((event.properties as { info: Message }).info) const info = (event.properties as { info: Message }).info
const messages = input.store.message[info.sessionID] const messages = input.store.message[info.sessionID]
if (!messages) { if (!messages) {
input.setStore("message", info.sessionID, [info]) input.setStore("message", info.sessionID, [info])
@@ -272,9 +271,9 @@ export function applyDirectoryEvent(input: {
break break
} }
case "vcs.branch.updated": { case "vcs.branch.updated": {
const props = event.properties as { branch?: string } const props = event.properties as { branch: string }
if (input.store.vcs?.branch === props.branch) break if (input.store.vcs?.branch === props.branch) break
const next = { ...input.store.vcs, branch: props.branch } const next = { branch: props.branch }
input.setStore("vcs", next) input.setStore("vcs", next)
if (input.vcsCache) input.vcsCache.setStore("value", next) if (input.vcsCache) input.vcsCache.setStore("value", next)
break break
@@ -1,11 +1,11 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { import type {
FileDiff,
Message, Message,
Part, Part,
PermissionRequest, PermissionRequest,
QuestionRequest, QuestionRequest,
SessionStatus, SessionStatus,
SnapshotFileDiff,
Todo, Todo,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache" import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
@@ -33,7 +33,7 @@ describe("app session cache", () => {
test("dropSessionCaches clears orphaned parts without message rows", () => { test("dropSessionCaches clears orphaned parts without message rows", () => {
const store: { const store: {
session_status: Record<string, SessionStatus | undefined> session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, SnapshotFileDiff[] | undefined> session_diff: Record<string, FileDiff[] | undefined>
todo: Record<string, Todo[] | undefined> todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined> message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined> part: Record<string, Part[] | undefined>
@@ -64,7 +64,7 @@ describe("app session cache", () => {
const m = msg("msg_1", "ses_1") const m = msg("msg_1", "ses_1")
const store: { const store: {
session_status: Record<string, SessionStatus | undefined> session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, SnapshotFileDiff[] | undefined> session_diff: Record<string, FileDiff[] | undefined>
todo: Record<string, Todo[] | undefined> todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined> message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined> part: Record<string, Part[] | undefined>
@@ -1,10 +1,10 @@
import type { import type {
FileDiff,
Message, Message,
Part, Part,
PermissionRequest, PermissionRequest,
QuestionRequest, QuestionRequest,
SessionStatus, SessionStatus,
SnapshotFileDiff,
Todo, Todo,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
@@ -12,7 +12,7 @@ export const SESSION_CACHE_LIMIT = 40
type SessionCache = { type SessionCache = {
session_status: Record<string, SessionStatus | undefined> session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, SnapshotFileDiff[] | undefined> session_diff: Record<string, FileDiff[] | undefined>
todo: Record<string, Todo[] | undefined> todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined> message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined> part: Record<string, Part[] | undefined>
@@ -2,6 +2,7 @@ import type {
Agent, Agent,
Command, Command,
Config, Config,
FileDiff,
LspStatus, LspStatus,
McpStatus, McpStatus,
Message, Message,
@@ -13,7 +14,6 @@ import type {
QuestionRequest, QuestionRequest,
Session, Session,
SessionStatus, SessionStatus,
SnapshotFileDiff,
Todo, Todo,
VcsInfo, VcsInfo,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
@@ -48,7 +48,7 @@ export type State = {
[sessionID: string]: SessionStatus [sessionID: string]: SessionStatus
} }
session_diff: { session_diff: {
[sessionID: string]: SnapshotFileDiff[] [sessionID: string]: FileDiff[]
} }
todo: { todo: {
[sessionID: string]: Todo[] [sessionID: string]: Todo[]
+4 -3
View File
@@ -1,7 +1,6 @@
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js" import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useGlobalSync } from "./global-sync" import { useGlobalSync } from "./global-sync"
import { useGlobalSDK } from "./global-sdk" import { useGlobalSDK } from "./global-sdk"
import { useServer } from "./server" import { useServer } from "./server"
@@ -367,10 +366,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
flush() flush()
} }
makeEventListener(window, "pagehide", flush) window.addEventListener("pagehide", flush)
makeEventListener(document, "visibilitychange", handleVisibility) document.addEventListener("visibilitychange", handleVisibility)
onCleanup(() => { onCleanup(() => {
window.removeEventListener("pagehide", flush)
document.removeEventListener("visibilitychange", handleVisibility)
scroll.dispose() scroll.dispose()
}) })
}) })
+3 -3
View File
@@ -11,7 +11,7 @@ import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } fro
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { useSync } from "./sync" import { useSync } from "./sync"
export type ModelKey = { providerID: string; modelID: string; variant?: string } export type ModelKey = { providerID: string; modelID: string }
type State = { type State = {
agent?: string agent?: string
@@ -373,7 +373,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
handoff.set(handoffKey(dir, session), next) handoff.set(handoffKey(dir, session), next)
setStore("draft", undefined) setStore("draft", undefined)
}, },
restore(msg: { sessionID: string; agent: string; model: ModelKey }) { restore(msg: { sessionID: string; agent: string; model: ModelKey; variant?: string }) {
const session = id() const session = id()
if (!session) return if (!session) return
if (msg.sessionID !== session) return if (msg.sessionID !== session) return
@@ -383,7 +383,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
setSaved("session", session, { setSaved("session", session, {
agent: msg.agent, agent: msg.agent,
model: msg.model, model: msg.model,
variant: msg.model.variant ?? null, variant: msg.variant ?? null,
}) })
}, },
}, },
+2 -10
View File
@@ -136,11 +136,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans)) root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
}) })
createEffect(() => {
if (store.general?.followup !== "queue") return
setStore("general", "followup", "steer")
})
return { return {
ready, ready,
get current() { get current() {
@@ -155,12 +150,9 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setReleaseNotes(value: boolean) { setReleaseNotes(value: boolean) {
setStore("general", "releaseNotes", value) setStore("general", "releaseNotes", value)
}, },
followup: withFallback( followup: withFallback(() => store.general?.followup, defaultSettings.general.followup),
() => (store.general?.followup === "queue" ? "steer" : store.general?.followup),
defaultSettings.general.followup,
),
setFollowup(value: "queue" | "steer") { setFollowup(value: "queue" | "steer") {
setStore("general", "followup", value === "queue" ? "steer" : value) setStore("general", "followup", value)
}, },
showReasoningSummaries: withFallback( showReasoningSummaries: withFallback(
() => store.general?.showReasoningSummaries, () => store.general?.showReasoningSummaries,
+4 -4
View File
@@ -13,7 +13,6 @@ import { useGlobalSync } from "./global-sync"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client" import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache" import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
import { diffs as list, message as clean } from "@/utils/diffs"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
@@ -301,7 +300,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }), input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
) )
const items = (messages.data ?? []).filter((x) => !!x?.info?.id) const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const session = items.map((x) => clean(x.info)).sort((a, b) => cmp(a.id, b.id)) const session = items.map((x) => x.info).sort((a, b) => cmp(a.id, b.id))
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) })) const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
return { return {
@@ -417,7 +416,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
role: "user", role: "user",
time: { created: Date.now() }, time: { created: Date.now() },
agent: input.agent, agent: input.agent,
model: { ...input.model, variant: input.variant }, model: input.model,
variant: input.variant,
} }
const [, setStore] = target() const [, setStore] = target()
setOptimistic(sdk.directory, input.sessionID, { message, parts: input.parts }) setOptimistic(sdk.directory, input.sessionID, { message, parts: input.parts })
@@ -510,7 +510,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return runInflight(inflightDiff, key, () => return runInflight(inflightDiff, key, () =>
retry(() => client.session.diff({ sessionID })).then((diff) => { retry(() => client.session.diff({ sessionID })).then((diff) => {
if (!tracked(directory, sessionID)) return if (!tracked(directory, sessionID)) return
setStore("session_diff", sessionID, reconcile(list(diff.data), { key: "file" })) setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
}), }),
) )
}, },
-4
View File
@@ -238,8 +238,6 @@ export const dict = {
"prompt.mode.shell": "Shell", "prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt", "prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc to exit", "prompt.mode.shell.exit": "esc to exit",
"session.child.promptDisabled": "Subagent sessions cannot be prompted.",
"session.child.backToParent": "Back to main session.",
"prompt.example.1": "Fix a TODO in the codebase", "prompt.example.1": "Fix a TODO in the codebase",
"prompt.example.2": "What is the tech stack of this project?", "prompt.example.2": "What is the tech stack of this project?",
@@ -537,8 +535,6 @@ export const dict = {
"session.review.noVcs.createGit.action": "Create Git repository", "session.review.noVcs.createGit.action": "Create Git repository",
"session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable", "session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
"session.review.noChanges": "No changes", "session.review.noChanges": "No changes",
"session.review.noUncommittedChanges": "No uncommitted changes yet",
"session.review.noBranchChanges": "No branch changes yet",
"session.files.selectToOpen": "Select a file to open", "session.files.selectToOpen": "Select a file to open",
"session.files.all": "All files", "session.files.all": "All files",
-40
View File
@@ -1,46 +1,6 @@
@import "@opencode-ai/ui/styles/tailwind"; @import "@opencode-ai/ui/styles/tailwind";
@layer components { @layer components {
@keyframes session-progress-whip {
0% {
clip-path: inset(0 100% 0 0 round 999px);
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
48% {
clip-path: inset(0 0 0 0 round 999px);
animation-timing-function: cubic-bezier(0.65, 0, 0.35, 1);
}
100% {
clip-path: inset(0 0 0 100% round 999px);
}
}
[data-component="session-progress"] {
position: absolute;
inset: 0 0 auto;
height: 2px;
overflow: hidden;
pointer-events: none;
opacity: 1;
transition: opacity 220ms ease-out;
}
[data-component="session-progress"][data-state="hiding"] {
opacity: 0;
}
[data-component="session-progress-bar"] {
width: 100%;
height: 100%;
border-radius: 999px;
background: var(--session-progress-color);
clip-path: inset(0 100% 0 0 round 999px);
animation: session-progress-whip var(--session-progress-ms, 1800ms) infinite;
will-change: clip-path;
}
[data-component="getting-started"] { [data-component="getting-started"] {
container-type: inline-size; container-type: inline-size;
container-name: getting-started; container-name: getting-started;
+31 -7
View File
@@ -12,7 +12,6 @@ import {
untrack, untrack,
type Accessor, type Accessor,
} from "solid-js" } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useNavigate, useParams } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { useLayout, LocalProject } from "@/context/layout" import { useLayout, LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
@@ -150,6 +149,7 @@ export default function Layout(props: ParentProps) {
const [state, setState] = createStore({ const [state, setState] = createStore({
autoselect: !initialDirectory, autoselect: !initialDirectory,
busyWorkspaces: {} as Record<string, boolean>, busyWorkspaces: {} as Record<string, boolean>,
hoverSession: undefined as string | undefined,
hoverProject: undefined as string | undefined, hoverProject: undefined as string | undefined,
scrollSessionKey: undefined as string | undefined, scrollSessionKey: undefined as string | undefined,
nav: undefined as HTMLElement | undefined, nav: undefined as HTMLElement | undefined,
@@ -193,6 +193,7 @@ export default function Layout(props: ParentProps) {
onActivate: (directory) => { onActivate: (directory) => {
globalSync.child(directory) globalSync.child(directory)
setState("hoverProject", directory) setState("hoverProject", directory)
setState("hoverSession", undefined)
}, },
}) })
@@ -214,11 +215,18 @@ export default function Layout(props: ParentProps) {
if (document.visibilityState !== "hidden") return if (document.visibilityState !== "hidden") return
reset() reset()
} }
makeEventListener(window, "pointerup", stop) window.addEventListener("pointerup", stop)
makeEventListener(window, "pointercancel", stop) window.addEventListener("pointercancel", stop)
makeEventListener(window, "blur", stop) window.addEventListener("blur", stop)
makeEventListener(window, "blur", blur) window.addEventListener("blur", blur)
makeEventListener(document, "visibilitychange", hide) document.addEventListener("visibilitychange", hide)
onCleanup(() => {
window.removeEventListener("pointerup", stop)
window.removeEventListener("pointercancel", stop)
window.removeEventListener("blur", stop)
window.removeEventListener("blur", blur)
document.removeEventListener("visibilitychange", hide)
})
}) })
const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined) const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
@@ -229,6 +237,7 @@ export default function Layout(props: ParentProps) {
aim.reset() aim.reset()
} }
const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined)) const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined))
const setHoverSession = (id: string | undefined) => setState("hoverSession", id)
const disarm = () => { const disarm = () => {
if (navLeave.current === undefined) return if (navLeave.current === undefined) return
@@ -238,6 +247,7 @@ export default function Layout(props: ParentProps) {
const reset = () => { const reset = () => {
disarm() disarm()
setState("hoverSession", undefined)
setHoverProject(undefined) setHoverProject(undefined)
} }
@@ -248,6 +258,7 @@ export default function Layout(props: ParentProps) {
navLeave.current = window.setTimeout(() => { navLeave.current = window.setTimeout(() => {
navLeave.current = undefined navLeave.current = undefined
setHoverProject(undefined) setHoverProject(undefined)
setState("hoverSession", undefined)
}, 300) }, 300)
} }
@@ -1383,7 +1394,8 @@ export default function Layout(props: ParentProps) {
} }
handleDeepLinks(drainPendingDeepLinks(window)) handleDeepLinks(drainPendingDeepLinks(window))
makeEventListener(window, deepLinkEvent, handler as EventListener) window.addEventListener(deepLinkEvent, handler as EventListener)
onCleanup(() => window.removeEventListener(deepLinkEvent, handler as EventListener))
}) })
async function renameProject(project: LocalProject, next: string) { async function renameProject(project: LocalProject, next: string) {
@@ -1967,6 +1979,9 @@ export default function Layout(props: ParentProps) {
navList: currentSessions, navList: currentSessions,
sidebarExpanded, sidebarExpanded,
sidebarHovering, sidebarHovering,
nav: () => state.nav,
hoverSession: () => state.hoverSession,
setHoverSession,
clearHoverProjectSoon, clearHoverProjectSoon,
prefetchSession, prefetchSession,
archiveSession, archiveSession,
@@ -1995,6 +2010,7 @@ export default function Layout(props: ParentProps) {
sidebarOpened: () => layout.sidebar.opened(), sidebarOpened: () => layout.sidebar.opened(),
sidebarHovering, sidebarHovering,
hoverProject: () => state.hoverProject, hoverProject: () => state.hoverProject,
nav: () => state.nav,
onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event), onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
onProjectMouseLeave: (worktree) => aim.leave(worktree), onProjectMouseLeave: (worktree) => aim.leave(worktree),
onProjectFocus: (worktree) => aim.activate(worktree), onProjectFocus: (worktree) => aim.activate(worktree),
@@ -2013,10 +2029,15 @@ export default function Layout(props: ParentProps) {
sessionProps: { sessionProps: {
navList: currentSessions, navList: currentSessions,
sidebarExpanded, sidebarExpanded,
sidebarHovering,
nav: () => state.nav,
hoverSession: () => state.hoverSession,
setHoverSession,
clearHoverProjectSoon, clearHoverProjectSoon,
prefetchSession, prefetchSession,
archiveSession, archiveSession,
}, },
setHoverSession,
} }
const SidebarPanel = (panelProps: { const SidebarPanel = (panelProps: {
@@ -2027,6 +2048,7 @@ export default function Layout(props: ParentProps) {
const project = panelProps.project const project = panelProps.project
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened())) const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened()) const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened())
const popover = createMemo(() => !!panelProps.mobile || panelProps.merged === false || layout.sidebar.opened())
const empty = createMemo(() => !params.dir && layout.projects.list().length === 0) const empty = createMemo(() => !params.dir && layout.projects.list().length === 0)
const projectName = createMemo(() => { const projectName = createMemo(() => {
const item = project() const item = project()
@@ -2228,6 +2250,7 @@ export default function Layout(props: ParentProps) {
project={project()!} project={project()!}
sortNow={sortNow} sortNow={sortNow}
mobile={panelProps.mobile} mobile={panelProps.mobile}
popover={popover()}
/> />
</div> </div>
</> </>
@@ -2272,6 +2295,7 @@ export default function Layout(props: ParentProps) {
project={project()!} project={project()!}
sortNow={sortNow} sortNow={sortNow}
mobile={panelProps.mobile} mobile={panelProps.mobile}
popover={popover()}
/> />
)} )}
</For> </For>
@@ -8,7 +8,6 @@ import {
} from "./deep-links" } from "./deep-links"
import { type Session } from "@opencode-ai/sdk/v2/client" import { type Session } from "@opencode-ai/sdk/v2/client"
import { import {
childSessionOnPath,
displayName, displayName,
effectiveWorkspaceOrder, effectiveWorkspaceOrder,
errorMessage, errorMessage,
@@ -199,19 +198,6 @@ describe("layout workspace helpers", () => {
expect(result?.id).toBe("root") expect(result?.id).toBe("root")
}) })
test("finds the direct child on the active session path", () => {
const list = [
session({ id: "root", directory: "/workspace" }),
session({ id: "child", directory: "/workspace", parentID: "root" }),
session({ id: "leaf", directory: "/workspace", parentID: "child" }),
]
expect(childSessionOnPath(list, "root", "leaf")?.id).toBe("child")
expect(childSessionOnPath(list, "child", "leaf")?.id).toBe("leaf")
expect(childSessionOnPath(list, "root", "root")).toBeUndefined()
expect(childSessionOnPath(list, "root", "other")).toBeUndefined()
})
test("formats fallback project display name", () => { test("formats fallback project display name", () => {
expect(displayName({ worktree: "/tmp/app" })).toBe("app") expect(displayName({ worktree: "/tmp/app" })).toBe("app")
expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App") expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App")
+11 -10
View File
@@ -46,17 +46,18 @@ export function hasProjectPermissions<T>(
return Object.values(request ?? {}).some((list) => list?.some(include)) return Object.values(request ?? {}).some((list) => list?.some(include))
} }
export const childSessionOnPath = (sessions: Session[] | undefined, rootID: string, activeID?: string) => { export const childMapByParent = (sessions: Session[] | undefined) => {
if (!activeID || activeID === rootID) return const map = new Map<string, string[]>()
const map = new Map((sessions ?? []).map((session) => [session.id, session])) for (const session of sessions ?? []) {
let id = activeID if (!session.parentID) continue
const existing = map.get(session.parentID)
while (id) { if (existing) {
const session = map.get(id) existing.push(session.id)
if (!session?.parentID) return continue
if (session.parentID === rootID) return session }
id = session.parentID map.set(session.parentID, [session.id])
} }
return map
} }
export const displayName = (project: { name?: string; worktree: string }) => export const displayName = (project: { name?: string; worktree: string }) =>
+208 -101
View File
@@ -1,21 +1,23 @@
import type { Session } from "@opencode-ai/sdk/v2/client" import type { Message, Session, TextPart, UserMessage } from "@opencode-ai/sdk/v2/client"
import { Avatar } from "@opencode-ai/ui/avatar" import { Avatar } from "@opencode-ai/ui/avatar"
import { HoverCard } from "@opencode-ai/ui/hover-card"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { MessageNav } from "@opencode-ai/ui/message-nav"
import { Spinner } from "@opencode-ai/ui/spinner" import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { base64Encode } from "@opencode-ai/util/encode"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
import { A, useParams } from "@solidjs/router" import { A, useNavigate, useParams } from "@solidjs/router"
import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js" import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout" import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
import { useNotification } from "@/context/notification" import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { messageAgentColor } from "@/utils/agent" import { messageAgentColor } from "@/utils/agent"
import { sessionTitle } from "@/utils/session-title"
import { sessionPermissionRequest } from "../session/composer/session-request-tree" import { sessionPermissionRequest } from "../session/composer/session-request-tree"
import { childSessionOnPath, hasProjectPermissions } from "./helpers" import { hasProjectPermissions } from "./helpers"
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
@@ -36,7 +38,6 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
) )
const notify = createMemo(() => props.notify && (hasPermissions() || unseenCount() > 0)) const notify = createMemo(() => props.notify && (hasPermissions() || unseenCount() > 0))
const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
return ( return (
<div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}> <div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}>
<div class="size-full rounded overflow-clip"> <div class="size-full rounded overflow-clip">
@@ -71,10 +72,13 @@ export type SessionItemProps = {
slug: string slug: string
mobile?: boolean mobile?: boolean
dense?: boolean dense?: boolean
showTooltip?: boolean popover?: boolean
showChild?: boolean children: Map<string, string[]>
level?: number
sidebarExpanded: Accessor<boolean> sidebarExpanded: Accessor<boolean>
sidebarHovering: Accessor<boolean>
nav: Accessor<HTMLElement | undefined>
hoverSession: Accessor<string | undefined>
setHoverSession: (id: string | undefined) => void
clearHoverProjectSoon: () => void clearHoverProjectSoon: () => void
prefetchSession: (session: Session, priority?: "high" | "low") => void prefetchSession: (session: Session, priority?: "high" | "low") => void
archiveSession: (session: Session) => Promise<void> archiveSession: (session: Session) => Promise<void>
@@ -90,52 +94,112 @@ const SessionRow = (props: {
hasPermissions: Accessor<boolean> hasPermissions: Accessor<boolean>
hasError: Accessor<boolean> hasError: Accessor<boolean>
unseenCount: Accessor<number> unseenCount: Accessor<number>
setHoverSession: (id: string | undefined) => void
clearHoverProjectSoon: () => void clearHoverProjectSoon: () => void
sidebarOpened: Accessor<boolean> sidebarOpened: Accessor<boolean>
warmHover: () => void
warmPress: () => void warmPress: () => void
warmFocus: () => void warmFocus: () => void
cancelHoverPrefetch: () => void
}): JSX.Element => (
<A
href={`/${props.slug}/session/${props.session.id}`}
class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
onPointerDown={props.warmPress}
onPointerEnter={props.warmHover}
onPointerLeave={props.cancelHoverPrefetch}
onFocus={props.warmFocus}
onClick={() => {
props.setHoverSession(undefined)
if (props.sidebarOpened()) return
props.clearHoverProjectSoon()
}}
>
<div
class="shrink-0 size-6 flex items-center justify-center"
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
>
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
<Match when={props.isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={props.hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={props.hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={props.unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{props.session.title}</span>
</A>
)
const SessionHoverPreview = (props: {
mobile?: boolean
nav: Accessor<HTMLElement | undefined>
hoverSession: Accessor<string | undefined>
session: Session
sidebarHovering: Accessor<boolean>
hoverReady: Accessor<boolean>
hoverMessages: Accessor<UserMessage[] | undefined>
language: ReturnType<typeof useLanguage>
isActive: Accessor<boolean>
slug: string
setHoverSession: (id: string | undefined) => void
messageLabel: (message: Message) => string | undefined
onMessageSelect: (message: Message) => void
trigger: JSX.Element
}): JSX.Element => { }): JSX.Element => {
const title = () => sessionTitle(props.session.title) let ref: HTMLDivElement | undefined
return ( return (
<A <HoverCard
href={`/${props.slug}/session/${props.session.id}`} openDelay={1000}
class={`flex items-center gap-2 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`} closeDelay={props.sidebarHovering() ? 600 : 0}
onPointerDown={props.warmPress} placement="right-start"
onFocus={props.warmFocus} gutter={16}
onClick={() => { shift={-2}
if (props.sidebarOpened()) return trigger={
props.clearHoverProjectSoon() <div ref={ref} class="min-w-0 w-full">
{props.trigger}
</div>
}
open={props.hoverSession() === props.session.id}
onOpenChange={(open) => {
if (!open) {
props.setHoverSession(undefined)
return
}
if (!ref?.matches(":hover")) return
props.setHoverSession(props.session.id)
}} }}
> >
<Show when={props.isWorking() || props.hasPermissions() || props.hasError() || props.unseenCount() > 0}> <Show
<div when={props.hoverReady()}
class="shrink-0 size-6 flex items-center justify-center" fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }} >
> <div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
<Switch> <MessageNav
<Match when={props.isWorking()}> messages={props.hoverMessages() ?? []}
<Spinner class="size-[15px]" /> current={undefined}
</Match> getLabel={props.messageLabel}
<Match when={props.hasPermissions()}> onMessageSelect={props.onMessageSelect}
<div class="size-1.5 rounded-full bg-surface-warning-strong" /> size="normal"
</Match> class="w-60"
<Match when={props.hasError()}> />
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={props.unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div> </div>
</Show> </Show>
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{title()}</span> </HoverCard>
</A>
) )
} }
export const SessionItem = (props: SessionItemProps): JSX.Element => { export const SessionItem = (props: SessionItemProps): JSX.Element => {
const params = useParams() const params = useParams()
const navigate = useNavigate()
const layout = useLayout() const layout = useLayout()
const language = useLanguage() const language = useLanguage()
const notification = useNotification() const notification = useNotification()
@@ -165,13 +229,18 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
) )
}) })
const tint = createMemo(() => messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent)) const tint = createMemo(() => {
const tooltip = createMemo(() => props.showTooltip ?? (props.mobile || !props.sidebarExpanded())) return messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent)
const currentChild = createMemo(() => {
if (!props.showChild) return
return childSessionOnPath(sessionStore.session, props.session.id, params.id)
}) })
const hoverMessages = createMemo(() =>
sessionStore.message[props.session.id]?.filter((message): message is UserMessage => message.role === "user"),
)
const hoverReady = createMemo(() => hoverMessages() !== undefined)
const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded())
const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
const isActive = createMemo(() => props.session.id === params.id)
const warm = (span: number, priority: "high" | "low") => { const warm = (span: number, priority: "high" | "low") => {
const nav = props.navList?.() const nav = props.navList?.()
const list = nav?.some((item) => item.id === props.session.id && item.directory === props.session.directory) const list = nav?.some((item) => item.id === props.session.id && item.directory === props.session.directory)
@@ -192,6 +261,30 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
} }
} }
const hoverPrefetch = {
current: undefined as ReturnType<typeof setTimeout> | undefined,
}
const cancelHoverPrefetch = () => {
if (hoverPrefetch.current === undefined) return
clearTimeout(hoverPrefetch.current)
hoverPrefetch.current = undefined
}
const scheduleHoverPrefetch = () => {
warm(1, "high")
if (hoverPrefetch.current !== undefined) return
hoverPrefetch.current = setTimeout(() => {
hoverPrefetch.current = undefined
warm(2, "low")
}, 80)
}
onCleanup(cancelHoverPrefetch)
const messageLabel = (message: Message) => {
const parts = sessionStore.part[message.id] ?? []
const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored)
return text?.text
}
const item = ( const item = (
<SessionRow <SessionRow
session={props.session} session={props.session}
@@ -203,74 +296,86 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
hasPermissions={hasPermissions} hasPermissions={hasPermissions}
hasError={hasError} hasError={hasError}
unseenCount={unseenCount} unseenCount={unseenCount}
setHoverSession={props.setHoverSession}
clearHoverProjectSoon={props.clearHoverProjectSoon} clearHoverProjectSoon={props.clearHoverProjectSoon}
sidebarOpened={layout.sidebar.opened} sidebarOpened={layout.sidebar.opened}
warmHover={scheduleHoverPrefetch}
warmPress={() => warm(2, "high")} warmPress={() => warm(2, "high")}
warmFocus={() => warm(2, "high")} warmFocus={() => warm(2, "high")}
cancelHoverPrefetch={cancelHoverPrefetch}
/> />
) )
return ( return (
<> <div
<div data-session-id={props.session.id}
data-session-id={props.session.id} class="group/session relative w-full min-w-0 rounded-md cursor-default pl-2 pr-3 transition-colors
class="group/session relative w-full min-w-0 rounded-md cursor-default pr-3 transition-colors hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active" hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
style={{ "padding-left": `${8 + (props.level ?? 0) * 16}px` }} >
> <div class="flex min-w-0 items-center gap-1">
<div class="flex min-w-0 items-center gap-1"> <div class="min-w-0 flex-1">
<div class="min-w-0 flex-1"> <Show
<Show when={hoverEnabled()}
when={!tooltip()} fallback={
fallback={ <Tooltip
<Tooltip placement={props.mobile ? "bottom" : "right"}
placement={props.mobile ? "bottom" : "right"} value={props.session.title}
value={sessionTitle(props.session.title)} gutter={10}
gutter={10} class="min-w-0 w-full"
class="min-w-0 w-full" >
> {item}
{item}
</Tooltip>
}
>
{item}
</Show>
</div>
<Show when={!props.level}>
<div
class="shrink-0 overflow-hidden transition-[width,opacity]"
classList={{
"w-6 opacity-100 pointer-events-auto": !!props.mobile,
"w-0 opacity-0 pointer-events-none": !props.mobile,
"group-hover/session:w-6 group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
"group-focus-within/session:w-6 group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
}}
>
<Tooltip value={language.t("common.archive")} placement="top">
<IconButton
icon="archive"
variant="ghost"
class="size-6 rounded-md"
aria-label={language.t("common.archive")}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void props.archiveSession(props.session)
}}
/>
</Tooltip> </Tooltip>
</div> }
>
<SessionHoverPreview
mobile={props.mobile}
nav={props.nav}
hoverSession={props.hoverSession}
session={props.session}
sidebarHovering={props.sidebarHovering}
hoverReady={hoverReady}
hoverMessages={hoverMessages}
language={language}
isActive={isActive}
slug={props.slug}
setHoverSession={props.setHoverSession}
messageLabel={messageLabel}
onMessageSelect={(message) => {
if (!isActive())
layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id)
navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`)
}}
trigger={item}
/>
</Show> </Show>
</div> </div>
<div
class="shrink-0 overflow-hidden transition-[width,opacity]"
classList={{
"w-6 opacity-100 pointer-events-auto": !!props.mobile,
"w-0 opacity-0 pointer-events-none": !props.mobile,
"group-hover/session:w-6 group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
"group-focus-within/session:w-6 group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
}}
>
<Tooltip value={language.t("common.archive")} placement="top">
<IconButton
icon="archive"
variant="ghost"
class="size-6 rounded-md"
aria-label={language.t("common.archive")}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void props.archiveSession(props.session)
}}
/>
</Tooltip>
</div>
</div> </div>
<Show when={currentChild()}> </div>
{(child) => (
<div class="w-full">
<SessionItem {...props} session={child()} level={(props.level ?? 0) + 1} />
</div>
)}
</Show>
</>
) )
} }
@@ -280,6 +385,7 @@ export const NewSessionItem = (props: {
dense?: boolean dense?: boolean
sidebarExpanded: Accessor<boolean> sidebarExpanded: Accessor<boolean>
clearHoverProjectSoon: () => void clearHoverProjectSoon: () => void
setHoverSession: (id: string | undefined) => void
}): JSX.Element => { }): JSX.Element => {
const layout = useLayout() const layout = useLayout()
const language = useLanguage() const language = useLanguage()
@@ -289,8 +395,9 @@ export const NewSessionItem = (props: {
<A <A
href={`/${props.slug}/session`} href={`/${props.slug}/session`}
end end
class={`flex items-center gap-2 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`} class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
onClick={() => { onClick={() => {
props.setHoverSession(undefined)
if (layout.sidebar.opened()) return if (layout.sidebar.opened()) return
props.clearHoverProjectSoon() props.clearHoverProjectSoon()
}} }}
@@ -1,4 +1,4 @@
import { createMemo, For, Show, type Accessor, type JSX } from "solid-js" import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
@@ -11,7 +11,7 @@ import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification" import { useNotification } from "@/context/notification"
import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items" import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
import { displayName, sortedRootSessions } from "./helpers" import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
export type ProjectSidebarContext = { export type ProjectSidebarContext = {
currentDir: Accessor<string> currentDir: Accessor<string>
@@ -19,6 +19,7 @@ export type ProjectSidebarContext = {
sidebarOpened: Accessor<boolean> sidebarOpened: Accessor<boolean>
sidebarHovering: Accessor<boolean> sidebarHovering: Accessor<boolean>
hoverProject: Accessor<string | undefined> hoverProject: Accessor<string | undefined>
nav: Accessor<HTMLElement | undefined>
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
onProjectMouseLeave: (worktree: string) => void onProjectMouseLeave: (worktree: string) => void
onProjectFocus: (worktree: string) => void onProjectFocus: (worktree: string) => void
@@ -31,7 +32,8 @@ export type ProjectSidebarContext = {
workspacesEnabled: (project: LocalProject) => boolean workspacesEnabled: (project: LocalProject) => boolean
workspaceIds: (project: LocalProject) => string[] workspaceIds: (project: LocalProject) => string[]
workspaceLabel: (directory: string, branch?: string, projectId?: string) => string workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
sessionProps: Omit<SessionItemProps, "session" | "list" | "slug" | "mobile" | "dense"> sessionProps: Omit<SessionItemProps, "session" | "list" | "slug" | "children" | "mobile" | "dense" | "popover">
setHoverSession: (id: string | undefined) => void
} }
export const ProjectDragOverlay = (props: { export const ProjectDragOverlay = (props: {
@@ -53,6 +55,7 @@ export const ProjectDragOverlay = (props: {
const ProjectTile = (props: { const ProjectTile = (props: {
project: LocalProject project: LocalProject
mobile?: boolean mobile?: boolean
nav: Accessor<HTMLElement | undefined>
sidebarHovering: Accessor<boolean> sidebarHovering: Accessor<boolean>
selected: Accessor<boolean> selected: Accessor<boolean>
active: Accessor<boolean> active: Accessor<boolean>
@@ -192,7 +195,9 @@ const ProjectPreviewPanel = (props: {
workspaces: Accessor<string[]> workspaces: Accessor<string[]>
label: (directory: string) => string label: (directory: string) => string
projectSessions: Accessor<ReturnType<typeof sortedRootSessions>> projectSessions: Accessor<ReturnType<typeof sortedRootSessions>>
projectChildren: Accessor<Map<string, string[]>>
workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions> workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions>
workspaceChildren: (directory: string) => Map<string, string[]>
ctx: ProjectSidebarContext ctx: ProjectSidebarContext
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
}): JSX.Element => ( }): JSX.Element => (
@@ -213,8 +218,9 @@ const ProjectPreviewPanel = (props: {
list={props.projectSessions()} list={props.projectSessions()}
slug={base64Encode(props.project.worktree)} slug={base64Encode(props.project.worktree)}
dense dense
showTooltip
mobile={props.mobile} mobile={props.mobile}
popover={false}
children={props.projectChildren()}
/> />
)} )}
</For> </For>
@@ -223,6 +229,7 @@ const ProjectPreviewPanel = (props: {
<For each={props.workspaces()}> <For each={props.workspaces()}>
{(directory) => { {(directory) => {
const sessions = createMemo(() => props.workspaceSessions(directory)) const sessions = createMemo(() => props.workspaceSessions(directory))
const children = createMemo(() => props.workspaceChildren(directory))
return ( return (
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div class="px-2 py-0.5 flex items-center gap-1 min-w-0"> <div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
@@ -239,8 +246,9 @@ const ProjectPreviewPanel = (props: {
list={sessions()} list={sessions()}
slug={base64Encode(directory)} slug={base64Encode(directory)}
dense dense
showTooltip
mobile={props.mobile} mobile={props.mobile}
popover={false}
children={children()}
/> />
)} )}
</For> </For>
@@ -302,14 +310,20 @@ export const SortableProject = (props: {
const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0]) const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0])
const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow())) const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()))
const projectChildren = createMemo(() => childMapByParent(projectStore().session))
const workspaceSessions = (directory: string) => { const workspaceSessions = (directory: string) => {
const [data] = globalSync.child(directory, { bootstrap: false }) const [data] = globalSync.child(directory, { bootstrap: false })
return sortedRootSessions(data, props.sortNow()) return sortedRootSessions(data, props.sortNow())
} }
const workspaceChildren = (directory: string) => {
const [data] = globalSync.child(directory, { bootstrap: false })
return childMapByParent(data.session)
}
const tile = () => ( const tile = () => (
<ProjectTile <ProjectTile
project={props.project} project={props.project}
mobile={props.mobile} mobile={props.mobile}
nav={props.ctx.nav}
sidebarHovering={props.ctx.sidebarHovering} sidebarHovering={props.ctx.sidebarHovering}
selected={selected} selected={selected}
active={active} active={active}
@@ -346,6 +360,7 @@ export const SortableProject = (props: {
if (state.menu) return if (state.menu) return
if (value && state.suppressHover) return if (value && state.suppressHover) return
props.ctx.onHoverOpenChanged(props.project.worktree, value) props.ctx.onHoverOpenChanged(props.project.worktree, value)
if (value) props.ctx.setHoverSession(undefined)
}} }}
> >
<ProjectPreviewPanel <ProjectPreviewPanel
@@ -356,7 +371,9 @@ export const SortableProject = (props: {
workspaces={workspaces} workspaces={workspaces}
label={label} label={label}
projectSessions={projectSessions} projectSessions={projectSessions}
projectChildren={projectChildren}
workspaceSessions={workspaceSessions} workspaceSessions={workspaceSessions}
workspaceChildren={workspaceChildren}
ctx={props.ctx} ctx={props.ctx}
language={language} language={language}
/> />
@@ -17,7 +17,7 @@ import { type LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items" import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
import { sortedRootSessions, workspaceKey } from "./helpers" import { childMapByParent, sortedRootSessions, workspaceKey } from "./helpers"
type InlineEditorComponent = (props: { type InlineEditorComponent = (props: {
id: string id: string
@@ -35,6 +35,9 @@ export type WorkspaceSidebarContext = {
navList: Accessor<Session[]> navList: Accessor<Session[]>
sidebarExpanded: Accessor<boolean> sidebarExpanded: Accessor<boolean>
sidebarHovering: Accessor<boolean> sidebarHovering: Accessor<boolean>
nav: Accessor<HTMLElement | undefined>
hoverSession: Accessor<string | undefined>
setHoverSession: (id: string | undefined) => void
clearHoverProjectSoon: () => void clearHoverProjectSoon: () => void
prefetchSession: (session: Session, priority?: "high" | "low") => void prefetchSession: (session: Session, priority?: "high" | "low") => void
archiveSession: (session: Session) => Promise<void> archiveSession: (session: Session) => Promise<void>
@@ -149,6 +152,7 @@ const WorkspaceActions = (props: {
showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"] showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"]
showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"] showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"]
root: string root: string
setHoverSession: WorkspaceSidebarContext["setHoverSession"]
clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"] clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"]
navigateToNewSession: () => void navigateToNewSession: () => void
}): JSX.Element => ( }): JSX.Element => (
@@ -222,6 +226,7 @@ const WorkspaceActions = (props: {
onClick={(event) => { onClick={(event) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
props.setHoverSession(undefined)
props.clearHoverProjectSoon() props.clearHoverProjectSoon()
props.navigateToNewSession() props.navigateToNewSession()
}} }}
@@ -234,10 +239,12 @@ const WorkspaceActions = (props: {
const WorkspaceSessionList = (props: { const WorkspaceSessionList = (props: {
slug: Accessor<string> slug: Accessor<string>
mobile?: boolean mobile?: boolean
popover?: boolean
ctx: WorkspaceSidebarContext ctx: WorkspaceSidebarContext
showNew: Accessor<boolean> showNew: Accessor<boolean>
loading: Accessor<boolean> loading: Accessor<boolean>
sessions: Accessor<Session[]> sessions: Accessor<Session[]>
children: Accessor<Map<string, string[]>>
hasMore: Accessor<boolean> hasMore: Accessor<boolean>
loadMore: () => Promise<void> loadMore: () => Promise<void>
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
@@ -249,6 +256,7 @@ const WorkspaceSessionList = (props: {
mobile={props.mobile} mobile={props.mobile}
sidebarExpanded={props.ctx.sidebarExpanded} sidebarExpanded={props.ctx.sidebarExpanded}
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
setHoverSession={props.ctx.setHoverSession}
/> />
</Show> </Show>
<Show when={props.loading()}> <Show when={props.loading()}>
@@ -262,8 +270,13 @@ const WorkspaceSessionList = (props: {
navList={props.ctx.navList} navList={props.ctx.navList}
slug={props.slug()} slug={props.slug()}
mobile={props.mobile} mobile={props.mobile}
showChild popover={props.popover}
children={props.children()}
sidebarExpanded={props.ctx.sidebarExpanded} sidebarExpanded={props.ctx.sidebarExpanded}
sidebarHovering={props.ctx.sidebarHovering}
nav={props.ctx.nav}
hoverSession={props.ctx.hoverSession}
setHoverSession={props.ctx.setHoverSession}
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
prefetchSession={props.ctx.prefetchSession} prefetchSession={props.ctx.prefetchSession}
archiveSession={props.ctx.archiveSession} archiveSession={props.ctx.archiveSession}
@@ -274,7 +287,7 @@ const WorkspaceSessionList = (props: {
<div class="relative w-full py-1"> <div class="relative w-full py-1">
<Button <Button
variant="ghost" variant="ghost"
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-2 pr-10" class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10"
size="large" size="large"
onClick={(e: MouseEvent) => { onClick={(e: MouseEvent) => {
props.loadMore() props.loadMore()
@@ -294,6 +307,7 @@ export const SortableWorkspace = (props: {
project: LocalProject project: LocalProject
sortNow: Accessor<number> sortNow: Accessor<number>
mobile?: boolean mobile?: boolean
popover?: boolean
}): JSX.Element => { }): JSX.Element => {
const navigate = useNavigate() const navigate = useNavigate()
const params = useParams() const params = useParams()
@@ -307,6 +321,7 @@ export const SortableWorkspace = (props: {
}) })
const slug = createMemo(() => base64Encode(props.directory)) const slug = createMemo(() => base64Encode(props.directory))
const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow())) const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
const children = createMemo(() => childMapByParent(workspaceStore.session))
const local = createMemo(() => props.directory === props.project.worktree) const local = createMemo(() => props.directory === props.project.worktree)
const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory)) const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory))
const workspaceValue = createMemo(() => { const workspaceValue = createMemo(() => {
@@ -413,6 +428,7 @@ export const SortableWorkspace = (props: {
showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog} showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog}
showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog} showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog}
root={props.project.worktree} root={props.project.worktree}
setHoverSession={props.ctx.setHoverSession}
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
navigateToNewSession={() => navigate(`/${slug()}/session`)} navigateToNewSession={() => navigate(`/${slug()}/session`)}
/> />
@@ -424,10 +440,12 @@ export const SortableWorkspace = (props: {
<WorkspaceSessionList <WorkspaceSessionList
slug={slug} slug={slug}
mobile={props.mobile} mobile={props.mobile}
popover={props.popover}
ctx={props.ctx} ctx={props.ctx}
showNew={showNew} showNew={showNew}
loading={loading} loading={loading}
sessions={sessions} sessions={sessions}
children={children}
hasMore={hasMore} hasMore={hasMore}
loadMore={loadMore} loadMore={loadMore}
language={language} language={language}
@@ -443,6 +461,7 @@ export const LocalWorkspace = (props: {
project: LocalProject project: LocalProject
sortNow: Accessor<number> sortNow: Accessor<number>
mobile?: boolean mobile?: boolean
popover?: boolean
}): JSX.Element => { }): JSX.Element => {
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const language = useLanguage() const language = useLanguage()
@@ -452,6 +471,7 @@ export const LocalWorkspace = (props: {
}) })
const slug = createMemo(() => base64Encode(props.project.worktree)) const slug = createMemo(() => base64Encode(props.project.worktree))
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow())) const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
const children = createMemo(() => childMapByParent(workspace().store.session))
const booted = createMemo((prev) => prev || workspace().store.status === "complete", false) const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
const count = createMemo(() => sessions()?.length ?? 0) const count = createMemo(() => sessions()?.length ?? 0)
const loading = createMemo(() => !booted() && count() === 0) const loading = createMemo(() => !booted() && count() === 0)
@@ -469,10 +489,12 @@ export const LocalWorkspace = (props: {
<WorkspaceSessionList <WorkspaceSessionList
slug={slug} slug={slug}
mobile={props.mobile} mobile={props.mobile}
popover={props.popover}
ctx={props.ctx} ctx={props.ctx}
showNew={() => false} showNew={() => false}
loading={loading} loading={loading}
sessions={sessions} sessions={sessions}
children={children}
hasMore={hasMore} hasMore={hasMore}
loadMore={loadMore} loadMore={loadMore}
language={language} language={language}
+79 -264
View File
@@ -1,4 +1,4 @@
import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2" import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query" import { useMutation } from "@tanstack/solid-query"
import { import {
@@ -14,7 +14,6 @@ import {
onMount, onMount,
untrack, untrack,
} from "solid-js" } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createMediaQuery } from "@solid-primitives/media" import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer" import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
@@ -58,7 +57,6 @@ import { TerminalPanel } from "@/pages/session/terminal-panel"
import { useSessionCommands } from "@/pages/session/use-session-commands" import { useSessionCommands } from "@/pages/session/use-session-commands"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
import { diffs as list } from "@/utils/diffs"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { extractPromptFromParts } from "@/utils/prompt" import { extractPromptFromParts } from "@/utils/prompt"
import { same } from "@/utils/same" import { same } from "@/utils/same"
@@ -69,9 +67,6 @@ type FollowupItem = FollowupDraft & { id: string }
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context"> type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
const emptyFollowups: FollowupItem[] = [] const emptyFollowups: FollowupItem[] = []
type ChangeMode = "git" | "branch" | "turn"
type VcsMode = "git" | "branch"
type SessionHistoryWindowInput = { type SessionHistoryWindowInput = {
sessionID: () => string | undefined sessionID: () => string | undefined
messagesReady: () => boolean messagesReady: () => boolean
@@ -334,9 +329,10 @@ export default function Page() {
const { params, sessionKey, tabs, view } = useSessionLayout() const { params, sessionKey, tabs, view } = useSessionLayout()
createEffect(() => { createEffect(() => {
if (!prompt.ready()) return if (!untrack(() => prompt.ready())) return
prompt.ready()
untrack(() => { untrack(() => {
if (params.id) return if (params.id || !prompt.ready()) return
const text = searchParams.prompt const text = searchParams.prompt
if (!text) return if (!text) return
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
@@ -351,7 +347,6 @@ export default function Page() {
scroll: { scroll: {
overflow: false, overflow: false,
bottom: true, bottom: true,
jump: false,
}, },
}) })
@@ -430,18 +425,16 @@ export default function Page() {
} }
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const isChildSession = createMemo(() => !!info()?.parentID) const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const diffs = createMemo(() => (params.id ? list(sync.data.session_diff[params.id]) : [])) const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) const hasReview = createMemo(() => reviewCount() > 0)
const hasSessionReview = createMemo(() => sessionCount() > 0)
const canReview = createMemo(() => !!sync.project)
const reviewTab = createMemo(() => isDesktop()) const reviewTab = createMemo(() => isDesktop())
const tabState = createSessionTabs({ const tabState = createSessionTabs({
tabs, tabs,
pathFromTab: file.pathFromTab, pathFromTab: file.pathFromTab,
normalizeTab, normalizeTab,
review: reviewTab, review: reviewTab,
hasReview: canReview, hasReview,
}) })
const contextOpen = tabState.contextOpen const contextOpen = tabState.contextOpen
const openedTabs = tabState.openedTabs const openedTabs = tabState.openedTabs
@@ -464,6 +457,7 @@ export default function Page() {
if (!id) return false if (!id) return false
return sync.session.history.loading(id) return sync.session.history.loading(id)
}) })
const userMessages = createMemo( const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[], () => messages().filter((m) => m.role === "user") as UserMessage[],
emptyUserMessages, emptyUserMessages,
@@ -516,31 +510,11 @@ export default function Page() {
const [store, setStore] = createStore({ const [store, setStore] = createStore({
messageId: undefined as string | undefined, messageId: undefined as string | undefined,
mobileTab: "session" as "session" | "changes", mobileTab: "session" as "session" | "changes",
changes: "git" as ChangeMode, changes: "session" as "session" | "turn",
newSessionWorktree: "main", newSessionWorktree: "main",
deferRender: false, deferRender: false,
}) })
const [vcs, setVcs] = createStore<{
diff: {
git: VcsFileDiff[]
branch: VcsFileDiff[]
}
ready: {
git: boolean
branch: boolean
}
}>({
diff: {
git: [] as VcsFileDiff[],
branch: [] as VcsFileDiff[],
},
ready: {
git: false,
branch: false,
},
})
const [followup, setFollowup] = persisted( const [followup, setFollowup] = persisted(
Persist.workspace(sdk.directory, "followup", ["followup.v1"]), Persist.workspace(sdk.directory, "followup", ["followup.v1"]),
createStore<{ createStore<{
@@ -574,68 +548,6 @@ export default function Page() {
let todoTimer: number | undefined let todoTimer: number | undefined
let diffFrame: number | undefined let diffFrame: number | undefined
let diffTimer: number | undefined let diffTimer: number | undefined
const vcsTask = new Map<VcsMode, Promise<void>>()
const vcsRun = new Map<VcsMode, number>()
const bumpVcs = (mode: VcsMode) => {
const next = (vcsRun.get(mode) ?? 0) + 1
vcsRun.set(mode, next)
return next
}
const resetVcs = (mode?: VcsMode) => {
const list = mode ? [mode] : (["git", "branch"] as const)
list.forEach((item) => {
bumpVcs(item)
vcsTask.delete(item)
setVcs("diff", item, [])
setVcs("ready", item, false)
})
}
const loadVcs = (mode: VcsMode, force = false) => {
if (sync.project?.vcs !== "git") return Promise.resolve()
if (!force && vcs.ready[mode]) return Promise.resolve()
if (force) {
if (vcsTask.has(mode)) bumpVcs(mode)
vcsTask.delete(mode)
setVcs("ready", mode, false)
}
const current = vcsTask.get(mode)
if (current) return current
const run = bumpVcs(mode)
const task = sdk.client.vcs
.diff({ mode })
.then((result) => {
if (vcsRun.get(mode) !== run) return
setVcs("diff", mode, list(result.data))
setVcs("ready", mode, true)
})
.catch((error) => {
if (vcsRun.get(mode) !== run) return
console.debug("[session-review] failed to load vcs diff", { mode, error })
setVcs("diff", mode, [])
setVcs("ready", mode, true)
})
.finally(() => {
if (vcsTask.get(mode) === task) vcsTask.delete(mode)
})
vcsTask.set(mode, task)
return task
}
const refreshVcs = () => {
resetVcs()
const mode = untrack(vcsMode)
if (!mode) return
if (!untrack(wantsReview)) return
void loadVcs(mode, true)
}
createComputed((prev) => { createComputed((prev) => {
const open = desktopReviewOpen() const open = desktopReviewOpen()
@@ -650,37 +562,8 @@ export default function Page() {
return open return open
}, desktopReviewOpen()) }, desktopReviewOpen())
const turnDiffs = createMemo(() => list(lastUserMessage()?.summary?.diffs)) const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
const nogit = createMemo(() => !!sync.project && sync.project.vcs !== "git") const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
const changesOptions = createMemo<ChangeMode[]>(() => {
const list: ChangeMode[] = []
if (sync.project?.vcs === "git") list.push("git")
if (
sync.project?.vcs === "git" &&
sync.data.vcs?.branch &&
sync.data.vcs?.default_branch &&
sync.data.vcs.branch !== sync.data.vcs.default_branch
) {
list.push("branch")
}
list.push("turn")
return list
})
const vcsMode = createMemo<VcsMode | undefined>(() => {
if (store.changes === "git" || store.changes === "branch") return store.changes
})
const reviewDiffs = createMemo(() => {
if (store.changes === "git") return list(vcs.diff.git)
if (store.changes === "branch") return list(vcs.diff.branch)
return turnDiffs()
})
const reviewCount = createMemo(() => reviewDiffs().length)
const hasReview = createMemo(() => reviewCount() > 0)
const reviewReady = createMemo(() => {
if (store.changes === "git") return vcs.ready.git
if (store.changes === "branch") return vcs.ready.branch
return true
})
const newSessionWorktree = createMemo(() => { const newSessionWorktree = createMemo(() => {
if (store.newSessionWorktree === "create") return "create" if (store.newSessionWorktree === "create") return "create"
@@ -746,6 +629,19 @@ export default function Page() {
scrollToMessage(msgs[targetIndex], "auto") scrollToMessage(msgs[targetIndex], "auto")
} }
const diffsReady = createMemo(() => {
const id = params.id
if (!id) return true
if (!hasReview()) return true
return sync.data.session_diff[id] !== undefined
})
const reviewEmptyKey = createMemo(() => {
const project = sync.project
if (project && !project.vcs) return "session.review.noVcs"
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
return "session.review.empty"
})
function upsert(next: Project) { function upsert(next: Project) {
const list = globalSync.data.project const list = globalSync.data.project
sync.set("project", next.id) sync.set("project", next.id)
@@ -893,46 +789,13 @@ export default function Page() {
sessionKey, sessionKey,
() => { () => {
setStore("messageId", undefined) setStore("messageId", undefined)
setStore("changes", "git") setStore("changes", "session")
setUi("pendingMessage", undefined) setUi("pendingMessage", undefined)
}, },
{ defer: true }, { defer: true },
), ),
) )
createEffect(
on(
() => sdk.directory,
() => {
resetVcs()
},
{ defer: true },
),
)
createEffect(
on(
() => [sync.data.vcs?.branch, sync.data.vcs?.default_branch] as const,
(next, prev) => {
if (prev === undefined || same(next, prev)) return
refreshVcs()
},
{ defer: true },
),
)
const stopVcs = sdk.event.listen((evt) => {
if (evt.details.type !== "file.watcher.updated") return
const props =
typeof evt.details.properties === "object" && evt.details.properties
? (evt.details.properties as Record<string, unknown>)
: undefined
const file = typeof props?.file === "string" ? props.file : undefined
if (!file || file.startsWith(".git/")) return
refreshVcs()
})
onCleanup(stopVcs)
createEffect( createEffect(
on( on(
() => params.dir, () => params.dir,
@@ -1049,46 +912,12 @@ export default function Page() {
} }
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
if (composer.blocked() || isChildSession()) return if (composer.blocked()) return
inputRef?.focus() inputRef?.focus()
} }
} }
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes") const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
const wantsReview = createMemo(() =>
isDesktop()
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
: store.mobileTab === "changes",
)
createEffect(() => {
const list = changesOptions()
if (list.includes(store.changes)) return
const next = list[0]
if (!next) return
setStore("changes", next)
})
createEffect(() => {
const mode = vcsMode()
if (!mode) return
if (!wantsReview()) return
void loadVcs(mode)
})
createEffect(
on(
() => sync.data.session_status[params.id ?? ""]?.type,
(next, prev) => {
const mode = vcsMode()
if (!mode) return
if (!wantsReview()) return
if (next !== "idle" || prev === undefined || prev === "idle") return
void loadVcs(mode, true)
},
{ defer: true },
),
)
const fileTreeTab = () => layout.fileTree.tab() const fileTreeTab = () => layout.fileTree.tab()
const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value) const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
@@ -1118,10 +947,7 @@ export default function Page() {
setFileTreeTab("all") setFileTreeTab("all")
} }
const focusInput = () => { const focusInput = () => inputRef?.focus()
if (isChildSession()) return
inputRef?.focus()
}
useSessionCommands({ useSessionCommands({
navigateMessageByOffset, navigateMessageByOffset,
@@ -1138,22 +964,21 @@ export default function Page() {
loadFile: file.load, loadFile: file.load,
}) })
const changesTitle = () => { const changesOptions = ["session", "turn"] as const
if (!canReview()) { const changesOptionsList = [...changesOptions]
return null
}
const label = (option: ChangeMode) => { const changesTitle = () => {
if (option === "git") return language.t("ui.sessionReview.title.git") if (!hasReview()) {
if (option === "branch") return language.t("ui.sessionReview.title.branch") return null
return language.t("ui.sessionReview.title.lastTurn")
} }
return ( return (
<Select <Select
options={changesOptions()} options={changesOptionsList}
current={store.changes} current={store.changes}
label={label} label={(option) =>
option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
}
onSelect={(option) => option && setStore("changes", option)} onSelect={(option) => option && setStore("changes", option)}
variant="ghost" variant="ghost"
size="small" size="small"
@@ -1162,48 +987,40 @@ export default function Page() {
) )
} }
const empty = (text: string) => ( const emptyTurn = () => (
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6"> <div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<div class="text-14-regular text-text-weak max-w-56">{text}</div> <div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
</div> </div>
) )
const createGit = (input: { emptyClass: string }) => (
<div class={input.emptyClass}>
<div class="flex flex-col gap-3">
<div class="text-14-medium text-text-strong">{language.t("session.review.noVcs.createGit.title")}</div>
<div class="text-14-regular text-text-base max-w-md" style={{ "line-height": "var(--line-height-normal)" }}>
{language.t("session.review.noVcs.createGit.description")}
</div>
</div>
<Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
{gitMutation.isPending
? language.t("session.review.noVcs.createGit.actionLoading")
: language.t("session.review.noVcs.createGit.action")}
</Button>
</div>
)
const reviewEmptyText = createMemo(() => {
if (store.changes === "git") return language.t("session.review.noUncommittedChanges")
if (store.changes === "branch") return language.t("session.review.noBranchChanges")
return language.t("session.review.noChanges")
})
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => { const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
if (store.changes === "git" || store.changes === "branch") { if (store.changes === "turn") return emptyTurn()
if (!reviewReady()) return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
return empty(reviewEmptyText()) if (hasReview() && !diffsReady()) {
return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
} }
if (store.changes === "turn") { if (reviewEmptyKey() === "session.review.noVcs") {
if (nogit()) return createGit(input) return (
return empty(reviewEmptyText()) <div class={input.emptyClass}>
<div class="flex flex-col gap-3">
<div class="text-14-medium text-text-strong">{language.t("session.review.noVcs.createGit.title")}</div>
<div class="text-14-regular text-text-base max-w-md" style={{ "line-height": "var(--line-height-normal)" }}>
{language.t("session.review.noVcs.createGit.description")}
</div>
</div>
<Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
{gitMutation.isPending
? language.t("session.review.noVcs.createGit.actionLoading")
: language.t("session.review.noVcs.createGit.action")}
</Button>
</div>
)
} }
return ( return (
<div class={input.emptyClass}> <div class={input.emptyClass}>
<div class="text-14-regular text-text-weak max-w-56">{reviewEmptyText()}</div> <div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
</div> </div>
) )
} }
@@ -1310,7 +1127,7 @@ export default function Page() {
const pending = tree.pendingDiff const pending = tree.pendingDiff
if (!pending) return if (!pending) return
if (!tree.reviewScroll) return if (!tree.reviewScroll) return
if (!reviewReady()) return if (!diffsReady()) return
const attempt = (count: number) => { const attempt = (count: number) => {
if (tree.pendingDiff !== pending) return if (tree.pendingDiff !== pending) return
@@ -1351,7 +1168,10 @@ export default function Page() {
const id = params.id const id = params.id
if (!id) return if (!id) return
if (!wantsReview()) return const wants = isDesktop()
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
: store.mobileTab === "changes"
if (!wants) return
if (sync.data.session_diff[id] !== undefined) return if (sync.data.session_diff[id] !== undefined) return
if (sync.status === "loading") return if (sync.status === "loading") return
@@ -1360,7 +1180,13 @@ export default function Page() {
createEffect( createEffect(
on( on(
() => [sessionKey(), wantsReview()] as const, () =>
[
sessionKey(),
isDesktop()
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
: store.mobileTab === "changes",
] as const,
([key, wants]) => { ([key, wants]) => {
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame) if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
if (diffTimer !== undefined) window.clearTimeout(diffTimer) if (diffTimer !== undefined) window.clearTimeout(diffTimer)
@@ -1421,17 +1247,13 @@ export default function Page() {
let scrollStateTarget: HTMLDivElement | undefined let scrollStateTarget: HTMLDivElement | undefined
let fillFrame: number | undefined let fillFrame: number | undefined
const jumpThreshold = (el: HTMLDivElement) => Math.max(400, el.clientHeight)
const updateScrollState = (el: HTMLDivElement) => { const updateScrollState = (el: HTMLDivElement) => {
const max = el.scrollHeight - el.clientHeight const max = el.scrollHeight - el.clientHeight
const distance = max - el.scrollTop
const overflow = max > 1 const overflow = max > 1
const bottom = !overflow || distance <= 2 const bottom = !overflow || el.scrollTop >= max - 2
const jump = overflow && distance > jumpThreshold(el)
if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom && ui.scroll.jump === jump) return if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return
setUi("scroll", { overflow, bottom, jump }) setUi("scroll", { overflow, bottom })
} }
const scheduleScrollState = (el: HTMLDivElement) => { const scheduleScrollState = (el: HTMLDivElement) => {
@@ -1645,7 +1467,7 @@ export default function Page() {
const queueEnabled = createMemo(() => { const queueEnabled = createMemo(() => {
const id = params.id const id = params.id
if (!id) return false if (!id) return false
return settings.general.followup() === "queue" && busy(id) && !composer.blocked() && !isChildSession() return settings.general.followup() === "queue" && busy(id) && !composer.blocked()
}) })
const followupText = (item: FollowupDraft) => { const followupText = (item: FollowupDraft) => {
@@ -1677,7 +1499,6 @@ export default function Page() {
const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) }))) const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) })))
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => { const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
if (sync.session.get(sessionID)?.parentID) return Promise.resolve()
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id) const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
if (!item) return Promise.resolve() if (!item) return Promise.resolve()
if (followupBusy(sessionID)) return Promise.resolve() if (followupBusy(sessionID)) return Promise.resolve()
@@ -1808,7 +1629,6 @@ export default function Page() {
if (followupBusy(sessionID)) return if (followupBusy(sessionID)) return
if (followup.failed[sessionID] === item.id) return if (followup.failed[sessionID] === item.id) return
if (followup.paused[sessionID]) return if (followup.paused[sessionID]) return
if (isChildSession()) return
if (composer.blocked()) return if (composer.blocked()) return
if (busy(sessionID)) return if (busy(sessionID)) return
@@ -1868,10 +1688,11 @@ export default function Page() {
) )
onMount(() => { onMount(() => {
makeEventListener(document, "keydown", handleKeyDown) document.addEventListener("keydown", handleKeyDown)
}) })
onCleanup(() => { onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown)
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame) if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame) if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer) if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
@@ -1990,7 +1811,7 @@ export default function Page() {
}} }}
onResponseSubmit={resumeScroll} onResponseSubmit={resumeScroll}
followup={ followup={
params.id && !isChildSession() params.id
? { ? {
queue: queueEnabled, queue: queueEnabled,
items: followupDock(), items: followupDock(),
@@ -2042,12 +1863,6 @@ export default function Page() {
</div> </div>
<SessionSidePanel <SessionSidePanel
canReview={canReview}
diffs={reviewDiffs}
diffsReady={reviewReady}
empty={reviewEmptyText}
hasReview={hasReview}
reviewCount={reviewCount}
reviewPanel={reviewPanel} reviewPanel={reviewPanel}
activeDiff={tree.activeDiff} activeDiff={tree.activeDiff}
focusReviewDiff={focusReviewDiff} focusReviewDiff={focusReviewDiff}
@@ -1,11 +1,9 @@
import { Show, createEffect, createMemo, onCleanup } from "solid-js" import { Show, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useNavigate } from "@solidjs/router"
import { useSpring } from "@opencode-ai/ui/motion-spring" import { useSpring } from "@opencode-ai/ui/motion-spring"
import { PromptInput } from "@/components/prompt-input" import { PromptInput } from "@/components/prompt-input"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt" import { usePrompt } from "@/context/prompt"
import { useSync } from "@/context/sync"
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff" import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
import { useSessionKey } from "@/pages/session/session-layout" import { useSessionKey } from "@/pages/session/session-layout"
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock" import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
@@ -15,7 +13,6 @@ import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
import type { SessionComposerState } from "@/pages/session/composer/session-composer-state" import type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock" import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
import type { FollowupDraft } from "@/components/prompt-input/submit" import type { FollowupDraft } from "@/components/prompt-input/submit"
import { createResizeObserver } from "@solid-primitives/resize-observer"
export function SessionComposerRegion(props: { export function SessionComposerRegion(props: {
state: SessionComposerState state: SessionComposerState
@@ -45,17 +42,11 @@ export function SessionComposerRegion(props: {
} }
setPromptDockRef: (el: HTMLDivElement) => void setPromptDockRef: (el: HTMLDivElement) => void
}) { }) {
const navigate = useNavigate()
const prompt = usePrompt() const prompt = usePrompt()
const language = useLanguage() const language = useLanguage()
const route = useSessionKey() const route = useSessionKey()
const sync = useSync()
const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt) const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt)
const info = createMemo(() => (route.params.id ? sync.session.get(route.params.id) : undefined))
const parentID = createMemo(() => info()?.parentID)
const child = createMemo(() => !!parentID())
const showComposer = createMemo(() => !props.state.blocked() || child())
const previewPrompt = () => const previewPrompt = () =>
prompt prompt
@@ -121,18 +112,16 @@ export function SessionComposerRegion(props: {
const lift = createMemo(() => (rolled() ? 18 : 36 * value())) const lift = createMemo(() => (rolled() ? 18 : 36 * value()))
const full = createMemo(() => Math.max(78, store.height)) const full = createMemo(() => Math.max(78, store.height))
const openParent = () => {
const id = parentID()
if (!id) return
navigate(`/${route.params.dir}/session/${id}`)
}
createEffect(() => { createEffect(() => {
const el = store.body const el = store.body
if (!el) return if (!el) return
const update = () => setStore("height", el.getBoundingClientRect().height) const update = () => {
createResizeObserver(store.body, update) setStore("height", el.getBoundingClientRect().height)
}
update() update()
const observer = new ResizeObserver(update)
observer.observe(el)
onCleanup(() => observer.disconnect())
}) })
return ( return (
@@ -170,7 +159,7 @@ export function SessionComposerRegion(props: {
)} )}
</Show> </Show>
<Show when={showComposer()}> <Show when={!props.state.blocked()}>
<Show <Show
when={prompt.ready()} when={prompt.ready()}
fallback={ fallback={
@@ -246,40 +235,17 @@ export function SessionComposerRegion(props: {
onEdit={props.followup!.onEdit} onEdit={props.followup!.onEdit}
/> />
</Show> </Show>
<Show <PromptInput
when={child()} ref={props.inputRef}
fallback={ newSessionWorktree={props.newSessionWorktree}
<Show when={!props.state.blocked()}> onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
<PromptInput edit={props.followup?.edit}
ref={props.inputRef} onEditLoaded={props.followup?.onEditLoaded}
newSessionWorktree={props.newSessionWorktree} shouldQueue={props.followup?.queue}
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset} onQueue={props.followup?.onQueue}
edit={props.followup?.edit} onAbort={props.followup?.onAbort}
onEditLoaded={props.followup?.onEditLoaded} onSubmit={props.onSubmit}
shouldQueue={props.followup?.queue} />
onQueue={props.followup?.onQueue}
onAbort={props.followup?.onAbort}
onSubmit={props.onSubmit}
/>
</Show>
}
>
<div
ref={props.inputRef}
class="w-full rounded-[12px] border border-border-weak-base bg-background-base p-3 text-16-regular text-text-weak"
>
<span>{language.t("session.child.promptDisabled")} </span>
<Show when={parentID()}>
<button
type="button"
class="text-text-base transition-colors hover:text-text-strong"
onClick={openParent}
>
{language.t("session.child.backToParent")}
</button>
</Show>
</div>
</Show>
</div> </div>
</Show> </Show>
</Show> </Show>
@@ -1,6 +1,5 @@
import { createEffect, createMemo, on, onCleanup, onMount } from "solid-js" import { createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2" import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
@@ -87,7 +86,8 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
pull() pull()
} }
makeEventListener(window, composerEvent, onEvent) window.addEventListener(composerEvent, onEvent)
onCleanup(() => window.removeEventListener(composerEvent, onEvent))
}) })
const todos = createMemo((): Todo[] => { const todos = createMemo((): Todo[] => {
@@ -8,8 +8,6 @@ import { showToast } from "@opencode-ai/ui/toast"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
const cache = new Map<string, { tab: number; answers: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>() const cache = new Map<string, { tab: number; answers: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>()
@@ -174,14 +172,17 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
} }
update() update()
window.addEventListener("resize", update)
makeEventListener(window, "resize", update)
const dock = root?.closest('[data-component="session-prompt-dock"]') const dock = root?.closest('[data-component="session-prompt-dock"]')
const scroller = document.querySelector(".scroll-view__viewport") const scroller = document.querySelector(".scroll-view__viewport")
createResizeObserver([dock, scroller], update) const observer = new ResizeObserver(update)
if (dock instanceof HTMLElement) observer.observe(dock)
if (scroller instanceof HTMLElement) observer.observe(scroller)
onCleanup(() => { onCleanup(() => {
window.removeEventListener("resize", update)
observer.disconnect()
if (raf !== undefined) cancelAnimationFrame(raf) if (raf !== undefined) cancelAnimationFrame(raf)
}) })
@@ -6,8 +6,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { useSpring } from "@opencode-ai/ui/motion-spring" import { useSpring } from "@opencode-ai/ui/motion-spring"
import { TextReveal } from "@opencode-ai/ui/text-reveal" import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough" import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
import { createResizeObserver } from "@solid-primitives/resize-observer" import { Index, createEffect, createMemo, on, onCleanup } from "solid-js"
import { Index, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { composerEnabled, composerProbe } from "@/testing/session-composer" import { composerEnabled, composerProbe } from "@/testing/session-composer"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -92,7 +91,9 @@ export function SessionTodoDock(props: {
setStore("height", el.getBoundingClientRect().height) setStore("height", el.getBoundingClientRect().height)
} }
update() update()
createResizeObserver(el, update) const observer = new ResizeObserver(update)
observer.observe(el)
onCleanup(() => observer.disconnect())
}) })
createEffect(() => { createEffect(() => {
@@ -210,25 +211,76 @@ export function SessionTodoDock(props: {
opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`, opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
}} }}
> >
<TodoList todos={props.todos} /> <TodoList todos={props.todos} open={!store.collapsed} />
</div> </div>
</div> </div>
</DockTray> </DockTray>
) )
} }
function TodoList(props: { todos: Todo[] }) { function TodoList(props: { todos: Todo[]; open: boolean }) {
const [store, setStore] = createStore({ const [store, setStore] = createStore({
stuck: false, stuck: false,
scrolling: false,
})
let scrollRef!: HTMLDivElement
let timer: number | undefined
const inProgress = createMemo(() => props.todos.findIndex((todo) => todo.status === "in_progress"))
const ensure = () => {
if (!props.open) return
if (store.scrolling) return
if (!scrollRef || scrollRef.offsetParent === null) return
const el = scrollRef.querySelector("[data-in-progress]")
if (!(el instanceof HTMLElement)) return
const topFade = 16
const bottomFade = 44
const container = scrollRef.getBoundingClientRect()
const rect = el.getBoundingClientRect()
const top = rect.top - container.top + scrollRef.scrollTop
const bottom = rect.bottom - container.top + scrollRef.scrollTop
const viewTop = scrollRef.scrollTop + topFade
const viewBottom = scrollRef.scrollTop + scrollRef.clientHeight - bottomFade
if (top < viewTop) {
scrollRef.scrollTop = Math.max(0, top - topFade)
} else if (bottom > viewBottom) {
scrollRef.scrollTop = bottom - (scrollRef.clientHeight - bottomFade)
}
setStore("stuck", scrollRef.scrollTop > 0)
}
createEffect(
on([() => props.open, inProgress], () => {
if (!props.open || inProgress() < 0) return
requestAnimationFrame(ensure)
}),
)
onCleanup(() => {
if (!timer) return
window.clearTimeout(timer)
}) })
return ( return (
<div class="relative"> <div class="relative">
<div <div
class="px-3 pb-11 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar" class="px-3 pb-11 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar"
ref={scrollRef}
style={{ "overflow-anchor": "none" }} style={{ "overflow-anchor": "none" }}
onScroll={(e) => { onScroll={(e) => {
setStore("stuck", e.currentTarget.scrollTop > 0) setStore("stuck", e.currentTarget.scrollTop > 0)
setStore("scrolling", true)
if (timer) window.clearTimeout(timer)
timer = window.setTimeout(() => {
setStore("scrolling", false)
if (inProgress() < 0) return
requestAnimationFrame(ensure)
}, 250)
}} }}
> >
<Index each={props.todos}> <Index each={props.todos}>
+24 -16
View File
@@ -1,7 +1,6 @@
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Switch } from "solid-js" import { createEffect, createMemo, Match, on, onCleanup, Switch } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { FileSearchHandle } from "@opencode-ai/ui/file" import type { FileSearchHandle } from "@opencode-ai/ui/file"
import { useFileComponent } from "@opencode-ai/ui/context/file" import { useFileComponent } from "@opencode-ai/ui/context/file"
import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
@@ -60,7 +59,7 @@ function createScrollSync(input: { tab: () => string; view: ReturnType<typeof us
let scrollFrame: number | undefined let scrollFrame: number | undefined
let restoreFrame: number | undefined let restoreFrame: number | undefined
let pending: ScrollPos | undefined let pending: ScrollPos | undefined
const [code, setCode] = createSignal<HTMLElement[]>([]) let code: HTMLElement[] = []
const getCode = () => { const getCode = () => {
const el = scroll const el = scroll
@@ -107,9 +106,17 @@ function createScrollSync(input: { tab: () => string; view: ReturnType<typeof us
const sync = () => { const sync = () => {
const next = getCode() const next = getCode()
const current = code() if (next.length === code.length && next.every((el, i) => el === code[i])) return
if (next.length === current.length && next.every((el, i) => el === current[i])) return
setCode(next) for (const item of code) {
item.removeEventListener("scroll", onCodeScroll)
}
code = next
for (const item of code) {
item.addEventListener("scroll", onCodeScroll)
}
} }
const restore = () => { const restore = () => {
@@ -121,14 +128,14 @@ function createScrollSync(input: { tab: () => string; view: ReturnType<typeof us
sync() sync()
if (code().length > 0) { if (code.length > 0) {
for (const item of code()) { for (const item of code) {
if (item.scrollLeft !== pos.x) item.scrollLeft = pos.x if (item.scrollLeft !== pos.x) item.scrollLeft = pos.x
} }
} }
if (el.scrollTop !== pos.y) el.scrollTop = pos.y if (el.scrollTop !== pos.y) el.scrollTop = pos.y
if (code().length > 0) return if (code.length > 0) return
if (el.scrollLeft !== pos.x) el.scrollLeft = pos.x if (el.scrollLeft !== pos.x) el.scrollLeft = pos.x
} }
@@ -142,24 +149,24 @@ function createScrollSync(input: { tab: () => string; view: ReturnType<typeof us
} }
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
if (code().length === 0) sync() if (code.length === 0) sync()
save({ save({
x: code()[0]?.scrollLeft ?? event.currentTarget.scrollLeft, x: code[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
y: event.currentTarget.scrollTop, y: event.currentTarget.scrollTop,
}) })
} }
createEffect(() => {
for (const item of code()) makeEventListener(item, "scroll", onCodeScroll)
})
const setViewport = (el: HTMLDivElement) => { const setViewport = (el: HTMLDivElement) => {
scroll = el scroll = el
restore() restore()
} }
onCleanup(() => { onCleanup(() => {
for (const item of code) {
item.removeEventListener("scroll", onCodeScroll)
}
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame) if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame) if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
}) })
@@ -351,7 +358,8 @@ export function FileTabContent(props: { tab: string }) {
find?.focus() find?.focus()
} }
makeEventListener(window, "keydown", onKeyDown, { capture: true }) window.addEventListener("keydown", onKeyDown, { capture: true })
onCleanup(() => window.removeEventListener("keydown", onKeyDown, { capture: true }))
}) })
createEffect( createEffect(
+8 -4
View File
@@ -1,6 +1,5 @@
import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js" import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { same } from "@/utils/same" import { same } from "@/utils/same"
const emptyTabs: string[] = [] const emptyTabs: string[] = []
@@ -172,9 +171,14 @@ export const createSizing = () => {
} }
onMount(() => { onMount(() => {
makeEventListener(window, "pointerup", stop) window.addEventListener("pointerup", stop)
makeEventListener(window, "pointercancel", stop) window.addEventListener("pointercancel", stop)
makeEventListener(window, "blur", stop) window.addEventListener("blur", stop)
onCleanup(() => {
window.removeEventListener("pointerup", stop)
window.removeEventListener("pointercancel", stop)
window.removeEventListener("blur", stop)
})
}) })
onCleanup(() => { onCleanup(() => {
@@ -21,7 +21,6 @@ import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage" import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSessionKey } from "@/pages/session/session-layout" import { useSessionKey } from "@/pages/session/session-layout"
import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSDK } from "@/context/global-sdk"
@@ -30,7 +29,6 @@ import { useSettings } from "@/context/settings"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { messageAgentColor } from "@/utils/agent" import { messageAgentColor } from "@/utils/agent"
import { sessionTitle } from "@/utils/session-title"
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import { makeTimer } from "@solid-primitives/timer" import { makeTimer } from "@solid-primitives/timer"
@@ -45,6 +43,7 @@ type MessageComment = {
const emptyMessages: MessageType[] = [] const emptyMessages: MessageType[] = []
const idle = { type: "idle" as const } const idle = { type: "idle" as const }
type UserActions = { type UserActions = {
fork?: (input: { sessionID: string; messageID: string }) => Promise<void> | void fork?: (input: { sessionID: string; messageID: string }) => Promise<void> | void
revert?: (input: { sessionID: string; messageID: string }) => Promise<void> | void revert?: (input: { sessionID: string; messageID: string }) => Promise<void> | void
@@ -69,16 +68,6 @@ const messageComments = (parts: Part[]): MessageComment[] =>
] ]
}) })
const taskDescription = (part: Part, sessionID: string) => {
if (part.type !== "tool" || part.tool !== "task") return
const metadata = "metadata" in part.state ? part.state.metadata : undefined
if (metadata?.sessionId !== sessionID) return
const value = part.state.input?.description
if (typeof value === "string" && value) return value
}
const pace = (width: number) => Math.round(Math.max(1200, Math.min(3200, (Math.max(width, 360) * 2000) / 900)))
const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => { const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
const current = target instanceof Element ? target : undefined const current = target instanceof Element ? target : undefined
const nested = current?.closest("[data-scrollable]") const nested = current?.closest("[data-scrollable]")
@@ -211,7 +200,7 @@ export function MessageTimeline(props: {
mobileChanges: boolean mobileChanges: boolean
mobileFallback: JSX.Element mobileFallback: JSX.Element
actions?: UserActions actions?: UserActions
scroll: { overflow: boolean; bottom: boolean; jump: boolean } scroll: { overflow: boolean; bottom: boolean }
onResumeScroll: () => void onResumeScroll: () => void
setScrollRef: (el: HTMLDivElement | undefined) => void setScrollRef: (el: HTMLDivElement | undefined) => void
onScheduleScrollState: (el: HTMLDivElement) => void onScheduleScrollState: (el: HTMLDivElement) => void
@@ -302,36 +291,9 @@ export function MessageTimeline(props: {
return sync.session.get(id) return sync.session.get(id)
}) })
const titleValue = createMemo(() => info()?.title) const titleValue = createMemo(() => info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()))
const shareUrl = createMemo(() => info()?.share?.url) const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled") const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const parentID = createMemo(() => info()?.parentID) const parentID = createMemo(() => info()?.parentID)
const parent = createMemo(() => {
const id = parentID()
if (!id) return
return sync.session.get(id)
})
const parentMessages = createMemo(() => {
const id = parentID()
if (!id) return emptyMessages
return sync.data.message[id] ?? emptyMessages
})
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
const childTaskDescription = createMemo(() => {
const id = sessionID()
if (!id) return
return parentMessages()
.flatMap((message) => sync.data.part[message.id] ?? [])
.map((part) => taskDescription(part, id))
.findLast((value): value is string => !!value)
})
const childTitle = createMemo(() => {
if (!parentID()) return titleLabel() ?? ""
if (childTaskDescription()) return childTaskDescription()
const value = titleLabel()?.replace(/\s+\(@[^)]+ subagent\)$/, "")
if (value) return value
return language.t("command.session.new")
})
const showHeader = createMemo(() => !!(titleValue() || parentID())) const showHeader = createMemo(() => !!(titleValue() || parentID()))
const stageCfg = { init: 1, batch: 3 } const stageCfg = { init: 1, batch: 3 }
const staging = createTimelineStaging({ const staging = createTimelineStaging({
@@ -354,20 +316,8 @@ export function MessageTimeline(props: {
open: false, open: false,
dismiss: null as "escape" | "outside" | null, dismiss: null as "escape" | "outside" | null,
}) })
const [bar, setBar] = createStore({
ms: pace(640),
})
let more: HTMLButtonElement | undefined let more: HTMLButtonElement | undefined
let head: HTMLDivElement | undefined
createResizeObserver(
() => head,
() => {
if (!head || head.clientWidth <= 0) return
setBar("ms", pace(head.clientWidth))
},
)
const viewShare = () => { const viewShare = () => {
const url = shareUrl() const url = shareUrl()
@@ -447,21 +397,9 @@ export function MessageTimeline(props: {
), ),
) )
createEffect(
on(
() => [parentID(), childTaskDescription()] as const,
([id, description]) => {
if (!id || description) return
if (sync.data.message[id] !== undefined) return
void sync.session.sync(id)
},
{ defer: true },
),
)
const openTitleEditor = () => { const openTitleEditor = () => {
if (!sessionID() || parentID()) return if (!sessionID()) return
setTitle({ editing: true, draft: titleLabel() ?? "" }) setTitle({ editing: true, draft: titleValue() ?? "" })
requestAnimationFrame(() => { requestAnimationFrame(() => {
titleRef?.focus() titleRef?.focus()
titleRef?.select() titleRef?.select()
@@ -479,7 +417,7 @@ export function MessageTimeline(props: {
if (titleMutation.isPending) return if (titleMutation.isPending) return
const next = title.draft.trim() const next = title.draft.trim()
if (!next || next === (titleLabel() ?? "")) { if (!next || next === (titleValue() ?? "")) {
setTitle("editing", false) setTitle("editing", false)
return return
} }
@@ -594,9 +532,7 @@ export function MessageTimeline(props: {
} }
function DialogDeleteSession(props: { sessionID: string }) { function DialogDeleteSession(props: { sessionID: string }) {
const name = createMemo( const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
() => sessionTitle(sync.session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
)
const handleDelete = async () => { const handleDelete = async () => {
await deleteSession(props.sessionID) await deleteSession(props.sessionID)
dialog.close() dialog.close()
@@ -632,24 +568,17 @@ export function MessageTimeline(props: {
<div <div
class="absolute left-1/2 -translate-x-1/2 bottom-6 z-[60] pointer-events-none transition-all duration-200 ease-out" class="absolute left-1/2 -translate-x-1/2 bottom-6 z-[60] pointer-events-none transition-all duration-200 ease-out"
classList={{ classList={{
"opacity-100 translate-y-0 scale-100": props.scroll.overflow && props.scroll.jump && !staging.isStaging(), "opacity-100 translate-y-0 scale-100":
props.scroll.overflow && !props.scroll.bottom && !staging.isStaging(),
"opacity-0 translate-y-2 scale-95 pointer-events-none": "opacity-0 translate-y-2 scale-95 pointer-events-none":
!props.scroll.overflow || !props.scroll.jump || staging.isStaging(), !props.scroll.overflow || props.scroll.bottom || staging.isStaging(),
}} }}
> >
<button <button
class="pointer-events-auto flex items-center justify-center w-10 h-8 bg-transparent border-none cursor-pointer p-0 group" class="pointer-events-auto size-8 flex items-center justify-center rounded-full bg-background-base border border-border-base shadow-sm text-text-base hover:bg-background-stronger transition-colors"
onClick={props.onResumeScroll} onClick={props.onResumeScroll}
> >
<div <Icon name="arrow-down-to-line" />
class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-border-weaker-base bg-[color-mix(in_srgb,var(--surface-raised-stronger-non-alpha)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--border-weak-base)] group-hover:[--icon-base:var(--icon-hover)]"
style={{
"box-shadow":
"0 51px 60px 0 rgba(0,0,0,0.10), 0 15px 18px 0 rgba(0,0,0,0.12), 0 6.386px 7.513px 0 rgba(0,0,0,0.12), 0 2.31px 2.717px 0 rgba(0,0,0,0.20)",
}}
>
<Icon name="arrow-down-to-line" size="small" />
</div>
</button> </button>
</div> </div>
<ScrollView <ScrollView
@@ -707,53 +636,27 @@ export function MessageTimeline(props: {
<div ref={props.setContentRef} class="min-w-0 w-full"> <div ref={props.setContentRef} class="min-w-0 w-full">
<Show when={showHeader()}> <Show when={showHeader()}>
<div <div
ref={(el) => {
head = el
setBar("ms", pace(el.clientWidth))
}}
data-session-title data-session-title
classList={{ classList={{
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true, "sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
relative: true,
"w-full": true, "w-full": true,
"pb-4": true, "pb-4": true,
"pl-2 pr-3 md:pl-4 md:pr-3": true, "pl-2 pr-3 md:pl-4 md:pr-3": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}} }}
> >
<Show when={workingStatus() !== "hidden"}>
<div
data-component="session-progress"
data-state={workingStatus()}
aria-hidden="true"
style={{
"--session-progress-color": tint() ?? "var(--icon-interactive-base)",
"--session-progress-ms": `${bar.ms}ms`,
}}
>
<div data-component="session-progress-bar" />
</div>
</Show>
<div class="h-12 w-full flex items-center justify-between gap-2"> <div class="h-12 w-full flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3"> <div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
<Show when={parentID()}>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={navigateParent}
aria-label={language.t("common.goBack")}
/>
</Show>
<div class="flex items-center min-w-0 grow-1"> <div class="flex items-center min-w-0 grow-1">
<Show when={parentID()}>
<button
type="button"
data-slot="session-title-parent"
class="min-w-0 max-w-[40%] truncate text-14-medium text-text-weak transition-colors hover:text-text-base"
onClick={navigateParent}
>
{parentTitle()}
</button>
<span
data-slot="session-title-separator"
class="px-2 text-14-medium text-text-weak"
aria-hidden="true"
>
/
</span>
</Show>
<div <div
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]" class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{ style={{
@@ -771,16 +674,15 @@ export function MessageTimeline(props: {
</div> </div>
</Show> </Show>
</div> </div>
<Show when={childTitle() || title.editing}> <Show when={titleValue() || title.editing}>
<Show <Show
when={title.editing} when={title.editing}
fallback={ fallback={
<h1 <h1
data-slot="session-title-child"
class="text-14-medium text-text-strong truncate grow-1 min-w-0" class="text-14-medium text-text-strong truncate grow-1 min-w-0"
onDblClick={openTitleEditor} onDblClick={openTitleEditor}
> >
{childTitle()} {titleValue()}
</h1> </h1>
} }
> >
@@ -788,7 +690,6 @@ export function MessageTimeline(props: {
ref={(el) => { ref={(el) => {
titleRef = el titleRef = el
}} }}
data-slot="session-title-child"
value={title.draft} value={title.draft}
disabled={titleMutation.isPending} disabled={titleMutation.isPending}
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]" class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
@@ -816,179 +717,177 @@ export function MessageTimeline(props: {
{(id) => ( {(id) => (
<div class="shrink-0 flex items-center gap-3"> <div class="shrink-0 flex items-center gap-3">
<SessionContextUsage placement="bottom" /> <SessionContextUsage placement="bottom" />
<Show when={!parentID()}> <DropdownMenu
<DropdownMenu gutter={4}
gutter={4} placement="bottom-end"
placement="bottom-end" open={title.menuOpen}
open={title.menuOpen} onOpenChange={(open) => {
onOpenChange={(open) => { setTitle("menuOpen", open)
setTitle("menuOpen", open) if (open) return
if (open) return }}
>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
classList={{
"bg-surface-base-active": share.open || title.pendingShare,
}} }}
> aria-label={language.t("common.moreOptions")}
<DropdownMenu.Trigger aria-expanded={title.menuOpen || share.open || title.pendingShare}
as={IconButton} ref={(el: HTMLButtonElement) => {
icon="dot-grid" more = el
variant="ghost" }}
class="size-6 rounded-md data-[expanded]:bg-surface-base-active" />
classList={{ <DropdownMenu.Portal>
"bg-surface-base-active": share.open || title.pendingShare, <DropdownMenu.Content
style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => {
if (title.pendingRename) {
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
return
}
if (title.pendingShare) {
event.preventDefault()
requestAnimationFrame(() => {
setShare({ open: true, dismiss: null })
setTitle("pendingShare", false)
})
}
}} }}
aria-label={language.t("common.moreOptions")} >
aria-expanded={title.menuOpen || share.open || title.pendingShare} <DropdownMenu.Item
ref={(el: HTMLButtonElement) => { onSelect={() => {
more = el setTitle("pendingRename", true)
}} setTitle("menuOpen", false)
/>
<DropdownMenu.Portal>
<DropdownMenu.Content
style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => {
if (title.pendingRename) {
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
return
}
if (title.pendingShare) {
event.preventDefault()
requestAnimationFrame(() => {
setShare({ open: true, dismiss: null })
setTitle("pendingShare", false)
})
}
}} }}
> >
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={shareEnabled()}>
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => { onSelect={() => {
setTitle("pendingRename", true) setTitle({ pendingShare: true, menuOpen: false })
setTitle("menuOpen", false)
}} }}
> >
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>
{language.t("session.share.action.share")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
<Show when={shareEnabled()}> </Show>
<DropdownMenu.Item <DropdownMenu.Item onSelect={() => void archiveSession(id())}>
onSelect={() => { <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
setTitle({ pendingShare: true, menuOpen: false }) </DropdownMenu.Item>
}} <DropdownMenu.Separator />
> <DropdownMenu.Item
<DropdownMenu.ItemLabel> onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
{language.t("session.share.action.share")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
>
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
<KobaltePopover
open={share.open}
anchorRef={() => more}
placement="bottom-end"
gutter={4}
modal={false}
onOpenChange={(open) => {
if (open) setShare("dismiss", null)
setShare("open", open)
}}
>
<KobaltePopover.Portal>
<KobaltePopover.Content
data-component="popover-content"
style={{ "min-width": "320px" }}
onEscapeKeyDown={(event) => {
setShare({ dismiss: "escape", open: false })
event.preventDefault()
event.stopPropagation()
}}
onPointerDownOutside={() => {
setShare({ dismiss: "outside", open: false })
}}
onFocusOutside={() => {
setShare({ dismiss: "outside", open: false })
}}
onCloseAutoFocus={(event) => {
if (share.dismiss === "outside") event.preventDefault()
setShare("dismiss", null)
}}
> >
<div class="flex flex-col p-3"> <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
<div class="flex flex-col gap-1"> </DropdownMenu.Item>
<div class="text-13-medium text-text-strong"> </DropdownMenu.Content>
{language.t("session.share.popover.title")} </DropdownMenu.Portal>
</div> </DropdownMenu>
<div class="text-12-regular text-text-weak">
{shareUrl() <KobaltePopover
? language.t("session.share.popover.description.shared") open={share.open}
: language.t("session.share.popover.description.unshared")} anchorRef={() => more}
</div> placement="bottom-end"
gutter={4}
modal={false}
onOpenChange={(open) => {
if (open) setShare("dismiss", null)
setShare("open", open)
}}
>
<KobaltePopover.Portal>
<KobaltePopover.Content
data-component="popover-content"
style={{ "min-width": "320px" }}
onEscapeKeyDown={(event) => {
setShare({ dismiss: "escape", open: false })
event.preventDefault()
event.stopPropagation()
}}
onPointerDownOutside={() => {
setShare({ dismiss: "outside", open: false })
}}
onFocusOutside={() => {
setShare({ dismiss: "outside", open: false })
}}
onCloseAutoFocus={(event) => {
if (share.dismiss === "outside") event.preventDefault()
setShare("dismiss", null)
}}
>
<div class="flex flex-col p-3">
<div class="flex flex-col gap-1">
<div class="text-13-medium text-text-strong">
{language.t("session.share.popover.title")}
</div> </div>
<div class="mt-3 flex flex-col gap-2"> <div class="text-12-regular text-text-weak">
<Show {shareUrl()
when={shareUrl()} ? language.t("session.share.popover.description.shared")
fallback={ : language.t("session.share.popover.description.unshared")}
</div>
</div>
<div class="mt-3 flex flex-col gap-2">
<Show
when={shareUrl()}
fallback={
<Button
size="large"
variant="primary"
class="w-full"
onClick={shareSession}
disabled={shareMutation.isPending}
>
{shareMutation.isPending
? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")}
</Button>
}
>
<div class="flex flex-col gap-2">
<TextField
value={shareUrl() ?? ""}
readOnly
copyable
copyKind="link"
tabIndex={-1}
class="w-full"
/>
<div class="grid grid-cols-2 gap-2">
<Button
size="large"
variant="secondary"
class="w-full shadow-none border border-border-weak-base"
onClick={unshareSession}
disabled={unshareMutation.isPending}
>
{unshareMutation.isPending
? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")}
</Button>
<Button <Button
size="large" size="large"
variant="primary" variant="primary"
class="w-full" class="w-full"
onClick={shareSession} onClick={viewShare}
disabled={shareMutation.isPending} disabled={unshareMutation.isPending}
> >
{shareMutation.isPending {language.t("session.share.action.view")}
? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")}
</Button> </Button>
}
>
<div class="flex flex-col gap-2">
<TextField
value={shareUrl() ?? ""}
readOnly
copyable
copyKind="link"
tabIndex={-1}
class="w-full"
/>
<div class="grid grid-cols-2 gap-2">
<Button
size="large"
variant="secondary"
class="w-full shadow-none border border-border-weak-base"
onClick={unshareSession}
disabled={unshareMutation.isPending}
>
{unshareMutation.isPending
? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")}
</Button>
<Button
size="large"
variant="primary"
class="w-full"
onClick={viewShare}
disabled={unshareMutation.isPending}
>
{language.t("session.share.action.view")}
</Button>
</div>
</div> </div>
</Show> </div>
</div> </Show>
</div> </div>
</KobaltePopover.Content> </div>
</KobaltePopover.Portal> </KobaltePopover.Content>
</KobaltePopover> </KobaltePopover.Portal>
</Show> </KobaltePopover>
</div> </div>
)} )}
</Show> </Show>
+15 -11
View File
@@ -1,6 +1,5 @@
import { createEffect, createSignal, onCleanup, type JSX } from "solid-js" import { createEffect, onCleanup, type JSX } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener" import type { FileDiff } from "@opencode-ai/sdk/v2"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { SessionReview } from "@opencode-ai/ui/session-review" import { SessionReview } from "@opencode-ai/ui/session-review"
import type { import type {
SessionReviewCommentActions, SessionReviewCommentActions,
@@ -14,12 +13,10 @@ import type { LineComment } from "@/context/comments"
export type DiffStyle = "unified" | "split" export type DiffStyle = "unified" | "split"
type ReviewDiff = SnapshotFileDiff | VcsFileDiff
export interface SessionReviewTabProps { export interface SessionReviewTabProps {
title?: JSX.Element title?: JSX.Element
empty?: JSX.Element empty?: JSX.Element
diffs: () => ReviewDiff[] diffs: () => FileDiff[]
view: () => ReturnType<ReturnType<typeof useLayout>["view"]> view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
diffStyle: DiffStyle diffStyle: DiffStyle
onDiffStyleChange?: (style: DiffStyle) => void onDiffStyleChange?: (style: DiffStyle) => void
@@ -126,6 +123,13 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
onCleanup(() => { onCleanup(() => {
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame) if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
if (scroll) {
scroll.removeEventListener("wheel", handleInteraction, { capture: true })
scroll.removeEventListener("mousewheel", handleInteraction, { capture: true })
scroll.removeEventListener("pointerdown", handleInteraction, { capture: true })
scroll.removeEventListener("touchstart", handleInteraction, { capture: true })
scroll.removeEventListener("keydown", handleInteraction, { capture: true })
}
}) })
return ( return (
@@ -134,11 +138,11 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
empty={props.empty} empty={props.empty}
scrollRef={(el) => { scrollRef={(el) => {
scroll = el scroll = el
makeEventListener(el, "wheel", handleInteraction, { passive: true, capture: true }) el.addEventListener("wheel", handleInteraction, { passive: true, capture: true })
makeEventListener(el, "mousewheel", handleInteraction, { passive: true, capture: true }) el.addEventListener("mousewheel", handleInteraction, { passive: true, capture: true })
makeEventListener(el, "pointerdown", handleInteraction, { passive: true, capture: true }) el.addEventListener("pointerdown", handleInteraction, { passive: true, capture: true })
makeEventListener(el, "touchstart", handleInteraction, { passive: true, capture: true }) el.addEventListener("touchstart", handleInteraction, { passive: true, capture: true })
makeEventListener(el, "keydown", handleInteraction, { capture: true }) el.addEventListener("keydown", handleInteraction, { passive: true, capture: true })
props.onScrollRef?.(el) props.onScrollRef?.(el)
queueRestore() queueRestore()
}} }}
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import type { UserMessage } from "@opencode-ai/sdk/v2" import type { UserMessage } from "@opencode-ai/sdk/v2"
import { resetSessionModel, syncSessionModel } from "./session-model-helpers" import { resetSessionModel, syncSessionModel } from "./session-model-helpers"
const message = (input?: { agent?: string; model?: UserMessage["model"] }) => const message = (input?: Partial<Pick<UserMessage, "agent" | "model" | "variant">>) =>
({ ({
id: "msg", id: "msg",
sessionID: "session", sessionID: "session",
@@ -10,6 +10,7 @@ const message = (input?: { agent?: string; model?: UserMessage["model"] }) =>
time: { created: 1 }, time: { created: 1 },
agent: input?.agent ?? "build", agent: input?.agent ?? "build",
model: input?.model ?? { providerID: "anthropic", modelID: "claude-sonnet-4" }, model: input?.model ?? { providerID: "anthropic", modelID: "claude-sonnet-4" },
variant: input?.variant,
}) as UserMessage }) as UserMessage
describe("syncSessionModel", () => { describe("syncSessionModel", () => {
@@ -25,12 +26,10 @@ describe("syncSessionModel", () => {
reset() {}, reset() {},
}, },
}, },
message({ model: { providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" } }), message({ variant: "high" }),
) )
expect(calls).toEqual([ expect(calls).toEqual([message({ variant: "high" })])
message({ model: { providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" } }),
])
}) })
}) })
@@ -8,7 +8,6 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo" import { Mark } from "@opencode-ai/ui/logo"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -19,6 +18,7 @@ import { useCommand } from "@/context/command"
import { useFile, type SelectedLineRange } from "@/context/file" import { useFile, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs" import { FileTabContent } from "@/pages/session/file-tabs"
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers" import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
@@ -26,12 +26,6 @@ import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
export function SessionSidePanel(props: { export function SessionSidePanel(props: {
canReview: () => boolean
diffs: () => (SnapshotFileDiff | VcsFileDiff)[]
diffsReady: () => boolean
empty: () => string
hasReview: () => boolean
reviewCount: () => number
reviewPanel: () => JSX.Element reviewPanel: () => JSX.Element
activeDiff?: string activeDiff?: string
focusReviewDiff: (path: string) => void focusReviewDiff: (path: string) => void
@@ -39,11 +33,12 @@ export function SessionSidePanel(props: {
size: Sizing size: Sizing
}) { }) {
const layout = useLayout() const layout = useLayout()
const sync = useSync()
const file = useFile() const file = useFile()
const language = useLanguage() const language = useLanguage()
const command = useCommand() const command = useCommand()
const dialog = useDialog() const dialog = useDialog()
const { sessionKey, tabs, view } = useSessionLayout() const { params, sessionKey, tabs, view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)") const isDesktop = createMediaQuery("(min-width: 768px)")
@@ -58,7 +53,24 @@ export function SessionSidePanel(props: {
}) })
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px")) const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
const diffFiles = createMemo(() => props.diffs().map((d) => d.file)) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasReview = createMemo(() => reviewCount() > 0)
const diffsReady = createMemo(() => {
const id = params.id
if (!id) return true
if (!hasReview()) return true
return sync.data.session_diff[id] !== undefined
})
const reviewEmptyKey = createMemo(() => {
if (sync.project && !sync.project.vcs) return "session.review.noVcs"
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
return "session.review.noChanges"
})
const diffFiles = createMemo(() => diffs().map((d) => d.file))
const kinds = createMemo(() => { const kinds = createMemo(() => {
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => { const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
if (!a) return b if (!a) return b
@@ -69,7 +81,7 @@ export function SessionSidePanel(props: {
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "") const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
const out = new Map<string, "add" | "del" | "mix">() const out = new Map<string, "add" | "del" | "mix">()
for (const diff of props.diffs()) { for (const diff of diffs()) {
const file = normalize(diff.file) const file = normalize(diff.file)
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix" const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
@@ -123,7 +135,7 @@ export function SessionSidePanel(props: {
pathFromTab: file.pathFromTab, pathFromTab: file.pathFromTab,
normalizeTab, normalizeTab,
review: reviewTab, review: reviewTab,
hasReview: props.canReview, hasReview,
}) })
const contextOpen = tabState.contextOpen const contextOpen = tabState.contextOpen
const openedTabs = tabState.openedTabs const openedTabs = tabState.openedTabs
@@ -228,12 +240,12 @@ export function SessionSidePanel(props: {
onCleanup(stop) onCleanup(stop)
}} }}
> >
<Show when={reviewTab() && props.canReview()}> <Show when={reviewTab()}>
<Tabs.Trigger value="review"> <Tabs.Trigger value="review">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<div>{language.t("session.tab.review")}</div> <div>{language.t("session.tab.review")}</div>
<Show when={props.hasReview()}> <Show when={hasReview()}>
<div>{props.reviewCount()}</div> <div>{reviewCount()}</div>
</Show> </Show>
</div> </div>
</Tabs.Trigger> </Tabs.Trigger>
@@ -292,7 +304,7 @@ export function SessionSidePanel(props: {
</Tabs.List> </Tabs.List>
</div> </div>
<Show when={reviewTab() && props.canReview()}> <Show when={reviewTab()}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict"> <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show> <Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
</Tabs.Content> </Tabs.Content>
@@ -366,10 +378,8 @@ export function SessionSidePanel(props: {
> >
<Tabs.List> <Tabs.List>
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}> <Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
{props.reviewCount()}{" "} {reviewCount()}{" "}
{language.t( {language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
)}
</Tabs.Trigger> </Tabs.Trigger>
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}> <Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
{language.t("session.files.all")} {language.t("session.files.all")}
@@ -377,9 +387,9 @@ export function SessionSidePanel(props: {
</Tabs.List> </Tabs.List>
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0"> <Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
<Switch> <Switch>
<Match when={props.hasReview() || !props.diffsReady()}> <Match when={hasReview()}>
<Show <Show
when={props.diffsReady()} when={diffsReady()}
fallback={ fallback={
<div class="px-2 py-2 text-12-regular text-text-weak"> <div class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")} {language.t("common.loading")}
@@ -398,7 +408,11 @@ export function SessionSidePanel(props: {
/> />
</Show> </Show>
</Match> </Match>
<Match when={true}>{empty(props.empty())}</Match> <Match when={true}>
{empty(
language.t(sync.project && !sync.project.vcs ? "session.review.noChanges" : reviewEmptyKey()),
)}
</Match>
</Switch> </Switch>
</Tabs.Content> </Tabs.Content>
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0"> <Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
@@ -1,6 +1,5 @@
import { For, Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js" import { For, Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -51,8 +50,12 @@ export function TerminalPanel() {
const port = window.visualViewport const port = window.visualViewport
sync() sync()
makeEventListener(window, "resize", sync) window.addEventListener("resize", sync)
if (port) makeEventListener(port, "resize", sync) port?.addEventListener("resize", sync)
onCleanup(() => {
window.removeEventListener("resize", sync)
port?.removeEventListener("resize", sync)
})
}) })
createEffect(() => { createEffect(() => {
@@ -52,7 +52,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
if (!id) return if (!id) return
return sync.session.get(id) return sync.session.get(id)
} }
const hasReview = () => !!params.id const hasReview = () => {
const id = params.id
if (!id) return false
return Math.max(info()?.summary?.files ?? 0, (sync.data.session_diff[id] ?? []).length) > 0
}
const normalizeTab = (tab: string) => { const normalizeTab = (tab: string) => {
if (!tab.startsWith("file://")) return tab if (!tab.startsWith("file://")) return tab
return file.tab(tab) return file.tab(tab)
-27
View File
@@ -10,13 +10,6 @@ export type PromptProbeState = {
selects: number selects: number
} }
export type PromptSendState = {
started: number
count: number
sessionID?: string
directory?: string
}
export const promptEnabled = () => { export const promptEnabled = () => {
if (typeof window === "undefined") return false if (typeof window === "undefined") return false
return (window as E2EWindow).__opencode_e2e?.prompt?.enabled === true return (window as E2EWindow).__opencode_e2e?.prompt?.enabled === true
@@ -60,24 +53,4 @@ export const promptProbe = {
if (!state) return if (!state) return
state.current = undefined state.current = undefined
}, },
start() {
const state = root()
if (!state) return
state.sent = {
started: (state.sent?.started ?? 0) + 1,
count: state.sent?.count ?? 0,
sessionID: state.sent?.sessionID,
directory: state.sent?.directory,
}
},
submit(input: { sessionID: string; directory: string }) {
const state = root()
if (!state) return
state.sent = {
started: state.sent?.started ?? 0,
count: (state.sent?.count ?? 0) + 1,
sessionID: input.sessionID,
directory: input.directory,
}
},
} }
-1
View File
@@ -23,7 +23,6 @@ export type E2EWindow = Window & {
prompt?: { prompt?: {
enabled?: boolean enabled?: boolean
current?: import("./prompt").PromptProbeState current?: import("./prompt").PromptProbeState
sent?: import("./prompt").PromptSendState
} }
terminal?: { terminal?: {
enabled?: boolean enabled?: boolean
+1 -22
View File
@@ -5,30 +5,9 @@ const defaults: Record<string, string> = {
plan: "var(--icon-agent-plan-base)", plan: "var(--icon-agent-plan-base)",
} }
const palette = [
"var(--icon-agent-ask-base)",
"var(--icon-agent-build-base)",
"var(--icon-agent-docs-base)",
"var(--icon-agent-plan-base)",
"var(--syntax-info)",
"var(--syntax-success)",
"var(--syntax-warning)",
"var(--syntax-property)",
"var(--syntax-constant)",
"var(--text-diff-add-base)",
"var(--text-diff-delete-base)",
"var(--icon-warning-base)",
]
function tone(name: string) {
let hash = 0
for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0
return palette[hash % palette.length]
}
export function agentColor(name: string, custom?: string) { export function agentColor(name: string, custom?: string) {
if (custom) return custom if (custom) return custom
return defaults[name] ?? defaults[name.toLowerCase()] ?? tone(name.toLowerCase()) return defaults[name] ?? defaults[name.toLowerCase()]
} }
export function messageAgentColor( export function messageAgentColor(
-74
View File
@@ -1,74 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
import type { Message } from "@opencode-ai/sdk/v2/client"
import { diffs, message } from "./diffs"
const item = {
file: "src/app.ts",
patch: "@@ -1 +1 @@\n-old\n+new\n",
additions: 1,
deletions: 1,
status: "modified",
} satisfies SnapshotFileDiff
describe("diffs", () => {
test("keeps valid arrays", () => {
expect(diffs([item])).toEqual([item])
})
test("wraps a single diff object", () => {
expect(diffs(item)).toEqual([item])
})
test("reads keyed diff objects", () => {
expect(diffs({ a: item })).toEqual([item])
})
test("drops invalid entries", () => {
expect(
diffs([
item,
{ file: "src/bad.ts", additions: 1, deletions: 1 },
{ patch: item.patch, additions: 1, deletions: 1 },
]),
).toEqual([item])
})
})
describe("message", () => {
test("normalizes user summaries with object diffs", () => {
const input = {
id: "msg_1",
sessionID: "ses_1",
role: "user",
time: { created: 1 },
agent: "build",
model: { providerID: "openai", modelID: "gpt-5" },
summary: {
title: "Edit",
diffs: { a: item },
},
} as unknown as Message
expect(message(input)).toMatchObject({
summary: {
title: "Edit",
diffs: [item],
},
})
})
test("drops invalid user summaries", () => {
const input = {
id: "msg_1",
sessionID: "ses_1",
role: "user",
time: { created: 1 },
agent: "build",
model: { providerID: "openai", modelID: "gpt-5" },
summary: true,
} as unknown as Message
expect(message(input)).toMatchObject({ summary: undefined })
})
})
-49
View File
@@ -1,49 +0,0 @@
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { Message } from "@opencode-ai/sdk/v2/client"
type Diff = SnapshotFileDiff | VcsFileDiff
function diff(value: unknown): value is Diff {
if (!value || typeof value !== "object" || Array.isArray(value)) return false
if (!("file" in value) || typeof value.file !== "string") return false
if (!("patch" in value) || typeof value.patch !== "string") return false
if (!("additions" in value) || typeof value.additions !== "number") return false
if (!("deletions" in value) || typeof value.deletions !== "number") return false
if (!("status" in value) || value.status === undefined) return true
return value.status === "added" || value.status === "deleted" || value.status === "modified"
}
function object(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
export function diffs(value: unknown): Diff[] {
if (Array.isArray(value) && value.every(diff)) return value
if (Array.isArray(value)) return value.filter(diff)
if (diff(value)) return [value]
if (!object(value)) return []
return Object.values(value).filter(diff)
}
export function message(value: Message): Message {
if (value.role !== "user") return value
const raw = value.summary as unknown
if (raw === undefined) return value
if (!object(raw)) return { ...value, summary: undefined }
const title = typeof raw.title === "string" ? raw.title : undefined
const body = typeof raw.body === "string" ? raw.body : undefined
const next = diffs(raw.diffs)
if (title === raw.title && body === raw.body && next === raw.diffs) return value
return {
...value,
summary: {
...(title === undefined ? {} : { title }),
...(body === undefined ? {} : { body }),
diffs: next,
},
}
}
-7
View File
@@ -1,7 +0,0 @@
const pattern = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
export function sessionTitle(title?: string) {
if (!title) return title
const match = title.match(pattern)
return match?.[1] ?? title
}
-66
View File
@@ -1,66 +0,0 @@
import { describe, expect, test } from "bun:test"
import { bodyText, inputMatch, promptMatch } from "../../e2e/prompt/mock"
function hit(body: Record<string, unknown>) {
return { body }
}
describe("promptMatch", () => {
test("matches token in serialized body", () => {
const match = promptMatch("hello")
expect(match(hit({ messages: [{ role: "user", content: "say hello" }] }))).toBe(true)
expect(match(hit({ messages: [{ role: "user", content: "say goodbye" }] }))).toBe(false)
})
})
describe("inputMatch", () => {
test("matches exact tool input in chat completions body", () => {
const input = { questions: [{ header: "Need input", question: "Pick one" }] }
const match = inputMatch(input)
// The seed prompt embeds JSON.stringify(input) in the user message
const prompt = `Use this JSON input: ${JSON.stringify(input)}`
const body = { messages: [{ role: "user", content: prompt }] }
expect(match(hit(body))).toBe(true)
})
test("matches exact tool input in responses API body", () => {
const input = { questions: [{ header: "Need input", question: "Pick one" }] }
const match = inputMatch(input)
const prompt = `Use this JSON input: ${JSON.stringify(input)}`
const body = { model: "test", input: [{ role: "user", content: [{ type: "input_text", text: prompt }] }] }
expect(match(hit(body))).toBe(true)
})
test("matches patchText with newlines", () => {
const patchText = "*** Begin Patch\n*** Add File: test.txt\n+line1\n*** End Patch"
const match = inputMatch({ patchText })
const prompt = `Use this JSON input: ${JSON.stringify({ patchText })}`
const body = { messages: [{ role: "user", content: prompt }] }
expect(match(hit(body))).toBe(true)
// Also works in responses API format
const respBody = { model: "test", input: [{ role: "user", content: [{ type: "input_text", text: prompt }] }] }
expect(match(hit(respBody))).toBe(true)
})
test("does not match unrelated requests", () => {
const input = { questions: [{ header: "Need input" }] }
const match = inputMatch(input)
expect(match(hit({ messages: [{ role: "user", content: "hello" }] }))).toBe(false)
expect(match(hit({ model: "test", input: [] }))).toBe(false)
})
test("does not match partial input", () => {
const input = { questions: [{ header: "Need input", question: "Pick one" }] }
const match = inputMatch(input)
// Only header, missing question
const partial = `Use this JSON input: ${JSON.stringify({ questions: [{ header: "Need input" }] })}`
const body = { messages: [{ role: "user", content: partial }] }
expect(match(hit(body))).toBe(false)
})
})
-27
View File
@@ -1,27 +0,0 @@
import { describe, expect, test } from "bun:test"
import path from "node:path"
import { fileURLToPath } from "node:url"
const dir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../e2e")
function hasPrompt(src: string) {
if (!src.includes("withProject(")) return false
if (src.includes("withNoReplyPrompt(")) return false
if (src.includes("session.promptAsync({") && !src.includes("noReply: true")) return true
if (!src.includes("promptSelector")) return false
return src.includes('keyboard.press("Enter")') || src.includes('prompt.press("Enter")')
}
describe("e2e llm guard", () => {
test("withProject specs do not submit prompt replies", async () => {
const bad: string[] = []
for await (const file of new Bun.Glob("**/*.spec.ts").scan({ cwd: dir, absolute: true })) {
const src = await Bun.file(file).text()
if (!hasPrompt(src)) continue
bad.push(path.relative(dir, file))
}
expect(bad).toEqual([])
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.4.3", "version": "1.3.13",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
@@ -204,14 +204,6 @@ export function IconGemini(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
) )
} }
export function IconMiMo(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C8.016 0 4.756.255 2.493 2.516.23 4.776 0 8.033 0 12.012c0 3.98.23 7.235 2.494 9.497C4.757 23.77 8.017 24 12 24c3.983 0 7.243-.23 9.506-2.491C23.77 19.247 24 15.99 24 12.012c0-3.984-.233-7.243-2.502-9.504C19.234.252 15.978 0 12 0zM4.906 7.405h5.624c1.47 0 3.007.068 3.764.827.746.746.827 2.233.83 3.676v4.54a.15.15 0 0 1-.152.147h-1.947a.15.15 0 0 1-.152-.148V11.83c-.002-.806-.048-1.634-.464-2.051-.358-.36-1.026-.441-1.72-.458H7.158a.15.15 0 0 0-.151.147v6.98a.15.15 0 0 1-.152.148H4.906a.15.15 0 0 1-.15-.148V7.554a.15.15 0 0 1 .15-.149zm12.131 0h1.949a.15.15 0 0 1 .15.15v8.892a.15.15 0 0 1-.15.148h-1.949a.15.15 0 0 1-.151-.148V7.554a.15.15 0 0 1 .151-.149zM8.92 10.948h2.046c.083 0 .15.066.15.147v5.352a.15.15 0 0 1-.15.148H8.92a.15.15 0 0 1-.152-.148v-5.352a.15.15 0 0 1 .152-.147Z" />
</svg>
)
}
export function IconXiaomi(props: JSX.SvgSVGAttributes<SVGSVGElement>) { export function IconXiaomi(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return ( return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> <svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
+5 -5
View File
@@ -9,8 +9,8 @@ export const config = {
github: { github: {
repoUrl: "https://github.com/anomalyco/opencode", repoUrl: "https://github.com/anomalyco/opencode",
starsFormatted: { starsFormatted: {
compact: "140K", compact: "120K",
full: "140,000", full: "120,000",
}, },
}, },
@@ -22,8 +22,8 @@ export const config = {
// Static stats (used on landing page) // Static stats (used on landing page)
stats: { stats: {
contributors: "850", contributors: "800",
commits: "11,000", commits: "10,000",
monthlyUsers: "6.5M", monthlyUsers: "5M",
}, },
} as const } as const
+5 -8
View File
@@ -249,7 +249,7 @@ export const dict = {
"go.title": "OpenCode Go | نماذج برمجة منخفضة التكلفة للجميع", "go.title": "OpenCode Go | نماذج برمجة منخفضة التكلفة للجميع",
"go.meta.description": "go.meta.description":
"يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5.1 وGLM-5 و Kimi K2.5 وMiMo-V2-Pro وMiMo-V2-Omni و MiniMax M2.5 وMiniMax M2.7.", "يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5 و Kimi K2.5 و MiniMax M2.5 وMiniMax M2.7.",
"go.hero.title": "نماذج برمجة منخفضة التكلفة للجميع", "go.hero.title": "نماذج برمجة منخفضة التكلفة للجميع",
"go.hero.body": "go.hero.body":
"يجلب Go البرمجة الوكيلة للمبرمجين حول العالم. يوفر حدودًا سخية ووصولًا موثوقًا إلى أقوى النماذج مفتوحة المصدر، حتى تتمكن من البناء باستخدام وكلاء أقوياء دون القلق بشأن التكلفة أو التوفر.", "يجلب Go البرمجة الوكيلة للمبرمجين حول العالم. يوفر حدودًا سخية ووصولًا موثوقًا إلى أقوى النماذج مفتوحة المصدر، حتى تتمكن من البناء باستخدام وكلاء أقوياء دون القلق بشأن التكلفة أو التوفر.",
@@ -297,7 +297,7 @@ export const dict = {
"go.problem.item1": "أسعار اشتراك منخفضة التكلفة", "go.problem.item1": "أسعار اشتراك منخفضة التكلفة",
"go.problem.item2": "حدود سخية ووصول موثوق", "go.problem.item2": "حدود سخية ووصول موثوق",
"go.problem.item3": "مصمم لأكبر عدد ممكن من المبرمجين", "go.problem.item3": "مصمم لأكبر عدد ممكن من المبرمجين",
"go.problem.item4": "يتضمن GLM-5.1 وGLM-5 وKimi K2.5 وMiMo-V2-Pro وMiMo-V2-Omni وMiniMax M2.5 وMiniMax M2.7", "go.problem.item4": "يتضمن GLM-5 وKimi K2.5 وMiniMax M2.5 وMiniMax M2.7",
"go.how.title": "كيف يعمل Go", "go.how.title": "كيف يعمل Go",
"go.how.body": "يبدأ Go من $5 للشهر الأول، ثم $10/شهر. يمكنك استخدامه مع OpenCode أو أي وكيل.", "go.how.body": "يبدأ Go من $5 للشهر الأول، ثم $10/شهر. يمكنك استخدامه مع OpenCode أو أي وكيل.",
"go.how.step1.title": "أنشئ حسابًا", "go.how.step1.title": "أنشئ حسابًا",
@@ -318,11 +318,10 @@ export const dict = {
"go.faq.q1": "ما هو OpenCode Go؟", "go.faq.q1": "ما هو OpenCode Go؟",
"go.faq.a1": "Go هو اشتراك منخفض التكلفة يمنحك وصولًا موثوقًا إلى نماذج مفتوحة المصدر قادرة على البرمجة الوكيلة.", "go.faq.a1": "Go هو اشتراك منخفض التكلفة يمنحك وصولًا موثوقًا إلى نماذج مفتوحة المصدر قادرة على البرمجة الوكيلة.",
"go.faq.q2": "ما النماذج التي يتضمنها Go؟", "go.faq.q2": "ما النماذج التي يتضمنها Go؟",
"go.faq.a2": "go.faq.a2": "يتضمن Go نماذج GLM-5 وKimi K2.5 وMiniMax M2.5 وMiniMax M2.7، مع حدود سخية ووصول موثوق.",
"يتضمن Go نماذج GLM-5.1 وGLM-5 وKimi K2.5 وMiMo-V2-Pro وMiMo-V2-Omni وMiniMax M2.5 وMiniMax M2.7، مع حدود سخية ووصول موثوق.",
"go.faq.q3": "هل Go هو نفسه Zen؟", "go.faq.q3": "هل Go هو نفسه Zen؟",
"go.faq.a3": "go.faq.a3":
"لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح GLM-5.1 وGLM-5 و Kimi K2.5 وMiMo-V2-Pro وMiMo-V2-Omni و MiniMax M2.5 وMiniMax M2.7.", "لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح GLM-5 و Kimi K2.5 و MiniMax M2.5 وMiniMax M2.7.",
"go.faq.q4": "كم تكلفة Go؟", "go.faq.q4": "كم تكلفة Go؟",
"go.faq.a4.p1.beforePricing": "تكلفة Go", "go.faq.a4.p1.beforePricing": "تكلفة Go",
"go.faq.a4.p1.pricingLink": "$5 للشهر الأول", "go.faq.a4.p1.pricingLink": "$5 للشهر الأول",
@@ -345,7 +344,7 @@ export const dict = {
"go.faq.q9": "ما الفرق بين النماذج المجانية وGo؟", "go.faq.q9": "ما الفرق بين النماذج المجانية وGo؟",
"go.faq.a9": "go.faq.a9":
"تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج GLM-5.1 وGLM-5 وKimi K2.5 وMiMo-V2-Pro وMiMo-V2-Omni وMiniMax M2.5 وMiniMax M2.7 مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).", "تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج GLM-5 وKimi K2.5 وMiniMax M2.5 وMiniMax M2.7 مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).",
"zen.api.error.rateLimitExceeded": "تم تجاوز حد الطلبات. يرجى المحاولة مرة أخرى لاحقًا.", "zen.api.error.rateLimitExceeded": "تم تجاوز حد الطلبات. يرجى المحاولة مرة أخرى لاحقًا.",
"zen.api.error.modelNotSupported": "النموذج {{model}} غير مدعوم", "zen.api.error.modelNotSupported": "النموذج {{model}} غير مدعوم",
@@ -364,8 +363,6 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"لقد وصلت إلى حد الإنفاق الشهري البالغ ${{amount}}. إدارة حدودك هنا: {{membersUrl}}", "لقد وصلت إلى حد الإنفاق الشهري البالغ ${{amount}}. إدارة حدودك هنا: {{membersUrl}}",
"zen.api.error.modelDisabled": "النموذج معطل", "zen.api.error.modelDisabled": "النموذج معطل",
"zen.api.error.trialEnded":
"انتهى العرض المجاني لـ {{model}}. يمكنك مواصلة استخدام النموذج بالاشتراك في OpenCode Go - {{link}}",
"black.meta.title": "OpenCode Black | الوصول إلى أفضل نماذج البرمجة في العالم", "black.meta.title": "OpenCode Black | الوصول إلى أفضل نماذج البرمجة في العالم",
"black.meta.description": "احصل على وصول إلى Claude، GPT، Gemini والمزيد مع خطط اشتراك OpenCode Black.", "black.meta.description": "احصل على وصول إلى Claude، GPT، Gemini والمزيد مع خطط اشتراك OpenCode Black.",
+5 -8
View File
@@ -253,7 +253,7 @@ export const dict = {
"go.title": "OpenCode Go | Modelos de codificação de baixo custo para todos", "go.title": "OpenCode Go | Modelos de codificação de baixo custo para todos",
"go.meta.description": "go.meta.description":
"O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5.1, GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 e MiniMax M2.7.", "O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7.",
"go.hero.title": "Modelos de codificação de baixo custo para todos", "go.hero.title": "Modelos de codificação de baixo custo para todos",
"go.hero.body": "go.hero.body":
"O Go traz a codificação com agentes para programadores em todo o mundo. Oferecendo limites generosos e acesso confiável aos modelos de código aberto mais capazes, para que você possa construir com agentes poderosos sem se preocupar com custos ou disponibilidade.", "O Go traz a codificação com agentes para programadores em todo o mundo. Oferecendo limites generosos e acesso confiável aos modelos de código aberto mais capazes, para que você possa construir com agentes poderosos sem se preocupar com custos ou disponibilidade.",
@@ -302,7 +302,7 @@ export const dict = {
"go.problem.item1": "Preço de assinatura de baixo custo", "go.problem.item1": "Preço de assinatura de baixo custo",
"go.problem.item2": "Limites generosos e acesso confiável", "go.problem.item2": "Limites generosos e acesso confiável",
"go.problem.item3": "Feito para o maior número possível de programadores", "go.problem.item3": "Feito para o maior número possível de programadores",
"go.problem.item4": "Inclui GLM-5.1, GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 e MiniMax M2.7", "go.problem.item4": "Inclui GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7",
"go.how.title": "Como o Go funciona", "go.how.title": "Como o Go funciona",
"go.how.body": "go.how.body":
"O Go começa em $5 no primeiro mês, depois $10/mês. Você pode usá-lo com o OpenCode ou qualquer agente.", "O Go começa em $5 no primeiro mês, depois $10/mês. Você pode usá-lo com o OpenCode ou qualquer agente.",
@@ -325,11 +325,10 @@ export const dict = {
"go.faq.a1": "go.faq.a1":
"Go é uma assinatura de baixo custo que oferece acesso confiável a modelos de código aberto capazes para codificação com agentes.", "Go é uma assinatura de baixo custo que oferece acesso confiável a modelos de código aberto capazes para codificação com agentes.",
"go.faq.q2": "Quais modelos o Go inclui?", "go.faq.q2": "Quais modelos o Go inclui?",
"go.faq.a2": "go.faq.a2": "Go inclui GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7, com limites generosos e acesso confiável.",
"Go inclui GLM-5.1, GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 e MiniMax M2.7, com limites generosos e acesso confiável.",
"go.faq.q3": "O Go é o mesmo que o Zen?", "go.faq.q3": "O Go é o mesmo que o Zen?",
"go.faq.a3": "go.faq.a3":
"Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source GLM-5.1, GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 e MiniMax M2.7.", "Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7.",
"go.faq.q4": "Quanto custa o Go?", "go.faq.q4": "Quanto custa o Go?",
"go.faq.a4.p1.beforePricing": "O Go custa", "go.faq.a4.p1.beforePricing": "O Go custa",
"go.faq.a4.p1.pricingLink": "$5 no primeiro mês", "go.faq.a4.p1.pricingLink": "$5 no primeiro mês",
@@ -353,7 +352,7 @@ export const dict = {
"go.faq.q9": "Qual a diferença entre os modelos gratuitos e o Go?", "go.faq.q9": "Qual a diferença entre os modelos gratuitos e o Go?",
"go.faq.a9": "go.faq.a9":
"Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui GLM-5.1, GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 e MiniMax M2.7 com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).", "Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7 com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).",
"zen.api.error.rateLimitExceeded": "Limite de taxa excedido. Por favor, tente novamente mais tarde.", "zen.api.error.rateLimitExceeded": "Limite de taxa excedido. Por favor, tente novamente mais tarde.",
"zen.api.error.modelNotSupported": "Modelo {{model}} não suportado", "zen.api.error.modelNotSupported": "Modelo {{model}} não suportado",
@@ -372,8 +371,6 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"Você atingiu seu limite de gastos mensais de ${{amount}}. Gerencie seus limites aqui: {{membersUrl}}", "Você atingiu seu limite de gastos mensais de ${{amount}}. Gerencie seus limites aqui: {{membersUrl}}",
"zen.api.error.modelDisabled": "O modelo está desabilitado", "zen.api.error.modelDisabled": "O modelo está desabilitado",
"zen.api.error.trialEnded":
"A promoção gratuita do {{model}} terminou. Você pode continuar usando o modelo assinando o OpenCode Go - {{link}}",
"black.meta.title": "OpenCode Black | Acesse os melhores modelos de codificação do mundo", "black.meta.title": "OpenCode Black | Acesse os melhores modelos de codificação do mundo",
"black.meta.description": "Tenha acesso ao Claude, GPT, Gemini e mais com os planos de assinatura OpenCode Black.", "black.meta.description": "Tenha acesso ao Claude, GPT, Gemini e mais com os planos de assinatura OpenCode Black.",
+5 -7
View File
@@ -251,7 +251,7 @@ export const dict = {
"go.title": "OpenCode Go | Kodningsmodeller til lav pris for alle", "go.title": "OpenCode Go | Kodningsmodeller til lav pris for alle",
"go.meta.description": "go.meta.description":
"Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5.1, GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 og MiniMax M2.7.", "Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7.",
"go.hero.title": "Kodningsmodeller til lav pris for alle", "go.hero.title": "Kodningsmodeller til lav pris for alle",
"go.hero.body": "go.hero.body":
"Go bringer agentisk kodning til programmører over hele verden. Med generøse grænser og pålidelig adgang til de mest kapable open source-modeller, så du kan bygge med kraftfulde agenter uden at bekymre dig om omkostninger eller tilgængelighed.", "Go bringer agentisk kodning til programmører over hele verden. Med generøse grænser og pålidelig adgang til de mest kapable open source-modeller, så du kan bygge med kraftfulde agenter uden at bekymre dig om omkostninger eller tilgængelighed.",
@@ -299,7 +299,7 @@ export const dict = {
"go.problem.item1": "Lavpris abonnementspriser", "go.problem.item1": "Lavpris abonnementspriser",
"go.problem.item2": "Generøse grænser og pålidelig adgang", "go.problem.item2": "Generøse grænser og pålidelig adgang",
"go.problem.item3": "Bygget til så mange programmører som muligt", "go.problem.item3": "Bygget til så mange programmører som muligt",
"go.problem.item4": "Inkluderer GLM-5.1, GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 og MiniMax M2.7", "go.problem.item4": "Inkluderer GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7",
"go.how.title": "Hvordan Go virker", "go.how.title": "Hvordan Go virker",
"go.how.body": "go.how.body":
"Go starter ved $5 for den første måned, derefter $10/måned. Du kan bruge det med OpenCode eller enhver agent.", "Go starter ved $5 for den første måned, derefter $10/måned. Du kan bruge det med OpenCode eller enhver agent.",
@@ -323,10 +323,10 @@ export const dict = {
"Go er et lavprisabonnement, der giver dig pålidelig adgang til kapable open source-modeller til agentisk kodning.", "Go er et lavprisabonnement, der giver dig pålidelig adgang til kapable open source-modeller til agentisk kodning.",
"go.faq.q2": "Hvilke modeller inkluderer Go?", "go.faq.q2": "Hvilke modeller inkluderer Go?",
"go.faq.a2": "go.faq.a2":
"Go inkluderer GLM-5.1, GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 og MiniMax M2.7, med generøse grænser og pålidelig adgang.", "Go inkluderer GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7, med generøse grænser og pålidelig adgang.",
"go.faq.q3": "Er Go det samme som Zen?", "go.faq.q3": "Er Go det samme som Zen?",
"go.faq.a3": "go.faq.a3":
"Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne GLM-5.1, GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 og MiniMax M2.7.", "Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7.",
"go.faq.q4": "Hvad koster Go?", "go.faq.q4": "Hvad koster Go?",
"go.faq.a4.p1.beforePricing": "Go koster", "go.faq.a4.p1.beforePricing": "Go koster",
"go.faq.a4.p1.pricingLink": "$5 første måned", "go.faq.a4.p1.pricingLink": "$5 første måned",
@@ -349,7 +349,7 @@ export const dict = {
"go.faq.q9": "Hvad er forskellen på gratis modeller og Go?", "go.faq.q9": "Hvad er forskellen på gratis modeller og Go?",
"go.faq.a9": "go.faq.a9":
"Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 og MiniMax M2.7 med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).", "Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7 med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).",
"zen.api.error.rateLimitExceeded": "Hastighedsgrænse overskredet. Prøv venligst igen senere.", "zen.api.error.rateLimitExceeded": "Hastighedsgrænse overskredet. Prøv venligst igen senere.",
"zen.api.error.modelNotSupported": "Model {{model}} understøttes ikke", "zen.api.error.modelNotSupported": "Model {{model}} understøttes ikke",
@@ -368,8 +368,6 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"Du har nået din månedlige forbrugsgrænse på ${{amount}}. Administrer dine grænser her: {{membersUrl}}", "Du har nået din månedlige forbrugsgrænse på ${{amount}}. Administrer dine grænser her: {{membersUrl}}",
"zen.api.error.modelDisabled": "Modellen er deaktiveret", "zen.api.error.modelDisabled": "Modellen er deaktiveret",
"zen.api.error.trialEnded":
"Den gratis kampagne for {{model}} er afsluttet. Du kan fortsætte med at bruge modellen ved at abonnere på OpenCode Go - {{link}}",
"black.meta.title": "OpenCode Black | Få adgang til verdens bedste kodningsmodeller", "black.meta.title": "OpenCode Black | Få adgang til verdens bedste kodningsmodeller",
"black.meta.description": "Få adgang til Claude, GPT, Gemini og mere med OpenCode Black-abonnementer.", "black.meta.description": "Få adgang til Claude, GPT, Gemini og mere med OpenCode Black-abonnementer.",
+5 -7
View File
@@ -253,7 +253,7 @@ export const dict = {
"go.title": "OpenCode Go | Kostengünstige Coding-Modelle für alle", "go.title": "OpenCode Go | Kostengünstige Coding-Modelle für alle",
"go.meta.description": "go.meta.description":
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5.1, GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 und MiniMax M2.7.", "Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5, Kimi K2.5, MiniMax M2.5 und MiniMax M2.7.",
"go.hero.title": "Kostengünstige Coding-Modelle für alle", "go.hero.title": "Kostengünstige Coding-Modelle für alle",
"go.hero.body": "go.hero.body":
"Go bringt Agentic Coding zu Programmierern auf der ganzen Welt. Mit großzügigen Limits und zuverlässigem Zugang zu den leistungsfähigsten Open-Source-Modellen, damit du mit leistungsstarken Agenten entwickeln kannst, ohne dir Gedanken über Kosten oder Verfügbarkeit zu machen.", "Go bringt Agentic Coding zu Programmierern auf der ganzen Welt. Mit großzügigen Limits und zuverlässigem Zugang zu den leistungsfähigsten Open-Source-Modellen, damit du mit leistungsstarken Agenten entwickeln kannst, ohne dir Gedanken über Kosten oder Verfügbarkeit zu machen.",
@@ -301,7 +301,7 @@ export const dict = {
"go.problem.item1": "Kostengünstiges Abonnement", "go.problem.item1": "Kostengünstiges Abonnement",
"go.problem.item2": "Großzügige Limits und zuverlässiger Zugang", "go.problem.item2": "Großzügige Limits und zuverlässiger Zugang",
"go.problem.item3": "Für so viele Programmierer wie möglich gebaut", "go.problem.item3": "Für so viele Programmierer wie möglich gebaut",
"go.problem.item4": "Beinhaltet GLM-5.1, GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 und MiniMax M2.7", "go.problem.item4": "Beinhaltet GLM-5, Kimi K2.5, MiniMax M2.5 und MiniMax M2.7",
"go.how.title": "Wie Go funktioniert", "go.how.title": "Wie Go funktioniert",
"go.how.body": "go.how.body":
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat. Du kannst es mit OpenCode oder jedem Agenten nutzen.", "Go beginnt bei $5 für den ersten Monat, danach $10/Monat. Du kannst es mit OpenCode oder jedem Agenten nutzen.",
@@ -325,10 +325,10 @@ export const dict = {
"Go ist ein kostengünstiges Abonnement, das dir zuverlässigen Zugang zu leistungsfähigen Open-Source-Modellen für Agentic Coding bietet.", "Go ist ein kostengünstiges Abonnement, das dir zuverlässigen Zugang zu leistungsfähigen Open-Source-Modellen für Agentic Coding bietet.",
"go.faq.q2": "Welche Modelle beinhaltet Go?", "go.faq.q2": "Welche Modelle beinhaltet Go?",
"go.faq.a2": "go.faq.a2":
"Go beinhaltet GLM-5.1, GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 und MiniMax M2.7, mit großzügigen Limits und zuverlässigem Zugang.", "Go beinhaltet GLM-5, Kimi K2.5, MiniMax M2.5 und MiniMax M2.7, mit großzügigen Limits und zuverlässigem Zugang.",
"go.faq.q3": "Ist Go dasselbe wie Zen?", "go.faq.q3": "Ist Go dasselbe wie Zen?",
"go.faq.a3": "go.faq.a3":
"Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen GLM-5.1, GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 und MiniMax M2.7.", "Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen GLM-5, Kimi K2.5, MiniMax M2.5 und MiniMax M2.7.",
"go.faq.q4": "Wie viel kostet Go?", "go.faq.q4": "Wie viel kostet Go?",
"go.faq.a4.p1.beforePricing": "Go kostet", "go.faq.a4.p1.beforePricing": "Go kostet",
"go.faq.a4.p1.pricingLink": "$5 im ersten Monat", "go.faq.a4.p1.pricingLink": "$5 im ersten Monat",
@@ -352,7 +352,7 @@ export const dict = {
"go.faq.q9": "Was ist der Unterschied zwischen kostenlosen Modellen und Go?", "go.faq.q9": "Was ist der Unterschied zwischen kostenlosen Modellen und Go?",
"go.faq.a9": "go.faq.a9":
"Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet GLM-5.1, GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 und MiniMax M2.7 mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).", "Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet GLM-5, Kimi K2.5, MiniMax M2.5 und MiniMax M2.7 mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).",
"zen.api.error.rateLimitExceeded": "Ratenlimit überschritten. Bitte versuche es später erneut.", "zen.api.error.rateLimitExceeded": "Ratenlimit überschritten. Bitte versuche es später erneut.",
"zen.api.error.modelNotSupported": "Modell {{model}} wird nicht unterstützt", "zen.api.error.modelNotSupported": "Modell {{model}} wird nicht unterstützt",
@@ -371,8 +371,6 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"Du hast dein monatliches Ausgabenlimit von ${{amount}} erreicht. Verwalte deine Limits hier: {{membersUrl}}", "Du hast dein monatliches Ausgabenlimit von ${{amount}} erreicht. Verwalte deine Limits hier: {{membersUrl}}",
"zen.api.error.modelDisabled": "Modell ist deaktiviert", "zen.api.error.modelDisabled": "Modell ist deaktiviert",
"zen.api.error.trialEnded":
"Die kostenlose Aktion für {{model}} ist beendet. Du kannst das Modell weiterhin nutzen, indem du OpenCode Go abonnierst - {{link}}",
"black.meta.title": "OpenCode Black | Zugriff auf die weltweit besten Coding-Modelle", "black.meta.title": "OpenCode Black | Zugriff auf die weltweit besten Coding-Modelle",
"black.meta.description": "Erhalte Zugriff auf Claude, GPT, Gemini und mehr mit OpenCode Black Abos.", "black.meta.description": "Erhalte Zugriff auf Claude, GPT, Gemini und mehr mit OpenCode Black Abos.",
+5 -7
View File
@@ -248,7 +248,7 @@ export const dict = {
"go.title": "OpenCode Go | Low cost coding models for everyone", "go.title": "OpenCode Go | Low cost coding models for everyone",
"go.meta.description": "go.meta.description":
"Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5.1, GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5, and MiniMax M2.7.", "Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5, Kimi K2.5, MiniMax M2.5, and MiniMax M2.7.",
"go.hero.title": "Low cost coding models for everyone", "go.hero.title": "Low cost coding models for everyone",
"go.hero.body": "go.hero.body":
"Go brings agentic coding to programmers around the world. Offering generous limits and reliable access to the most capable open-source models, so you can build with powerful agents without worrying about cost or availability.", "Go brings agentic coding to programmers around the world. Offering generous limits and reliable access to the most capable open-source models, so you can build with powerful agents without worrying about cost or availability.",
@@ -295,7 +295,7 @@ export const dict = {
"go.problem.item1": "Low cost subscription pricing", "go.problem.item1": "Low cost subscription pricing",
"go.problem.item2": "Generous limits and reliable access", "go.problem.item2": "Generous limits and reliable access",
"go.problem.item3": "Built for as many programmers as possible", "go.problem.item3": "Built for as many programmers as possible",
"go.problem.item4": "Includes GLM-5.1, GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5, and MiniMax M2.7", "go.problem.item4": "Includes GLM-5, Kimi K2.5, MiniMax M2.5, and MiniMax M2.7",
"go.how.title": "How Go works", "go.how.title": "How Go works",
"go.how.body": "Go starts at $5 for your first month, then $10/month. You can use it with OpenCode or any agent.", "go.how.body": "Go starts at $5 for your first month, then $10/month. You can use it with OpenCode or any agent.",
"go.how.step1.title": "Create an account", "go.how.step1.title": "Create an account",
@@ -318,10 +318,10 @@ export const dict = {
"Go is a low-cost subscription that gives you reliable access to capable open-source models for agentic coding.", "Go is a low-cost subscription that gives you reliable access to capable open-source models for agentic coding.",
"go.faq.q2": "What models does Go include?", "go.faq.q2": "What models does Go include?",
"go.faq.a2": "go.faq.a2":
"Go includes GLM-5.1, GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5, and MiniMax M2.7, with generous limits and reliable access.", "Go includes GLM-5, Kimi K2.5, MiniMax M2.5, and MiniMax M2.7, with generous limits and reliable access.",
"go.faq.q3": "Is Go the same as Zen?", "go.faq.q3": "Is Go the same as Zen?",
"go.faq.a3": "go.faq.a3":
"No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models GLM-5.1, GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5, and MiniMax M2.7.", "No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models GLM-5, Kimi K2.5, MiniMax M2.5, and MiniMax M2.7.",
"go.faq.q4": "How much does Go cost?", "go.faq.q4": "How much does Go cost?",
"go.faq.a4.p1.beforePricing": "Go costs", "go.faq.a4.p1.beforePricing": "Go costs",
"go.faq.a4.p1.pricingLink": "$5 first month", "go.faq.a4.p1.pricingLink": "$5 first month",
@@ -345,7 +345,7 @@ export const dict = {
"go.faq.q9": "What is the difference between free models and Go?", "go.faq.q9": "What is the difference between free models and Go?",
"go.faq.a9": "go.faq.a9":
"Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5.1, GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5, and MiniMax M2.7 with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).", "Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5, Kimi K2.5, MiniMax M2.5, and MiniMax M2.7 with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).",
"zen.api.error.rateLimitExceeded": "Rate limit exceeded. Please try again later.", "zen.api.error.rateLimitExceeded": "Rate limit exceeded. Please try again later.",
"zen.api.error.modelNotSupported": "Model {{model}} not supported", "zen.api.error.modelNotSupported": "Model {{model}} not supported",
@@ -364,8 +364,6 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"You have reached your monthly spending limit of ${{amount}}. Manage your limits here: {{membersUrl}}", "You have reached your monthly spending limit of ${{amount}}. Manage your limits here: {{membersUrl}}",
"zen.api.error.modelDisabled": "Model is disabled", "zen.api.error.modelDisabled": "Model is disabled",
"zen.api.error.trialEnded":
"Free promotion has ended for {{model}}. You can continue using the model by subscribing to OpenCode Go - {{link}}",
"black.meta.title": "OpenCode Black | Access all the world's best coding models", "black.meta.title": "OpenCode Black | Access all the world's best coding models",
"black.meta.description": "Get access to Claude, GPT, Gemini and more with OpenCode Black subscription plans.", "black.meta.description": "Get access to Claude, GPT, Gemini and more with OpenCode Black subscription plans.",

Some files were not shown because too many files have changed in this diff Show More