Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
839efafad6 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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 }
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
|
||||||
})
|
|
||||||
@@ -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)
|
},
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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" }))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 }) =>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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,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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user