mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-04 04:54:50 +00:00
Compare commits
193 Commits
v1.3.4
...
refactor/e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74a2abe973 | ||
|
|
f2d4ced8ea | ||
|
|
ae7e2eb3fb | ||
|
|
a32ffaba35 | ||
|
|
a4e75a0794 | ||
|
|
35350b1d25 | ||
|
|
263dcf75b5 | ||
|
|
7994dce0f2 | ||
|
|
fbfa148e4e | ||
|
|
9d57f21f9f | ||
|
|
3deee3a02b | ||
|
|
2002f08f2e | ||
|
|
c307505f8b | ||
|
|
6359d00fb4 | ||
|
|
b969066a20 | ||
|
|
500dcfc586 | ||
|
|
7b8dc8065e | ||
|
|
e89527c9f0 | ||
|
|
aa2239d5de | ||
|
|
8daeacc989 | ||
|
|
81d3ac3bf0 | ||
|
|
eb6f1dada8 | ||
|
|
8e9e79d276 | ||
|
|
38014fe448 | ||
|
|
8942fc21aa | ||
|
|
7f45943a9e | ||
|
|
6e1400fc45 | ||
|
|
bf26c08d51 | ||
|
|
29f7dc073b | ||
|
|
5e1b513527 | ||
|
|
f549fde874 | ||
|
|
6dfb30448c | ||
|
|
b5b5f7e019 | ||
|
|
ae7b49b034 | ||
|
|
f151c660b1 | ||
|
|
c3ef69c866 | ||
|
|
363891126c | ||
|
|
1989704abe | ||
|
|
f0a9ebfed4 | ||
|
|
7e32f80d82 | ||
|
|
966d9cfa41 | ||
|
|
92e820fdc8 | ||
|
|
c4b3971548 | ||
|
|
3faabdadb7 | ||
|
|
93a139315c | ||
|
|
10ca1ace6b | ||
|
|
c3dfd08ba8 | ||
|
|
510a1e8140 | ||
|
|
159ede2d5c | ||
|
|
291a857fb8 | ||
|
|
57a5236e71 | ||
|
|
23c8656080 | ||
|
|
ec3ae17e4d | ||
|
|
69d047ae7d | ||
|
|
327f62526a | ||
|
|
d540d363a7 | ||
|
|
db93891373 | ||
|
|
0f488996b3 | ||
|
|
a6f524ca08 | ||
|
|
811c7e2494 | ||
|
|
ebaa99aba2 | ||
|
|
d66e6dc25f | ||
|
|
336d28f112 | ||
|
|
916afb5220 | ||
|
|
5daf2fa7f0 | ||
|
|
733a3bd031 | ||
|
|
2e8e278441 | ||
|
|
0bae38c062 | ||
|
|
a09b086729 | ||
|
|
df1c6c9e8d | ||
|
|
789d86f7b0 | ||
|
|
e148b318b7 | ||
|
|
0cad775427 | ||
|
|
00d6841f84 | ||
|
|
8a8f7b3e90 | ||
|
|
c526caae7b | ||
|
|
b1c07488bd | ||
|
|
92f8e03160 | ||
|
|
f6fd43e574 | ||
|
|
854484babf | ||
|
|
e4ff1ea778 | ||
|
|
26fb6b8788 | ||
|
|
4214ae205d | ||
|
|
d9d4f895bc | ||
|
|
48db7cf07a | ||
|
|
802d165572 | ||
|
|
f7f41dc3a0 | ||
|
|
1fcfb69bf7 | ||
|
|
fa96cb9c6e | ||
|
|
cc30bfc94b | ||
|
|
880c0a7477 | ||
|
|
eabf3caeb9 | ||
|
|
c9326fc199 | ||
|
|
d7481f4593 | ||
|
|
f3f728ec27 | ||
|
|
c619caefdd | ||
|
|
c559af51ce | ||
|
|
d1e0a4640c | ||
|
|
f9e71ec515 | ||
|
|
ef538c9707 | ||
|
|
2f405daa98 | ||
|
|
a9c85b7c27 | ||
|
|
897d83c589 | ||
|
|
0a125e5d4d | ||
|
|
38d2276592 | ||
|
|
d58004a864 | ||
|
|
5fd833aa18 | ||
|
|
44f83015cd | ||
|
|
9a1c9ae15a | ||
|
|
a3a6cf1c07 | ||
|
|
47a676111a | ||
|
|
1df5ad470a | ||
|
|
506dd75818 | ||
|
|
c8ecd64022 | ||
|
|
ca376a4cff | ||
|
|
7532d99e5b | ||
|
|
181b5f6236 | ||
|
|
6314f09c14 | ||
|
|
4b4b7832aa | ||
|
|
4280307013 | ||
|
|
9b09a7e766 | ||
|
|
3fc0367b93 | ||
|
|
954a6ca88e | ||
|
|
0c03a3ee10 | ||
|
|
53330a518f | ||
|
|
892bdebaac | ||
|
|
18121300f3 | ||
|
|
d6d4446f46 | ||
|
|
26cc924ea2 | ||
|
|
4dd866d5c4 | ||
|
|
beab4cc2c2 | ||
|
|
567a91191a | ||
|
|
434d82bbe2 | ||
|
|
2929774acb | ||
|
|
6e61a46a84 | ||
|
|
2daf4b805a | ||
|
|
7342e650c0 | ||
|
|
8c2e2ecc95 | ||
|
|
25a2b739e6 | ||
|
|
85c16926c4 | ||
|
|
2e78fdec43 | ||
|
|
1fcb920eb4 | ||
|
|
b1e89c344b | ||
|
|
befbedacdc | ||
|
|
2cc738fb17 | ||
|
|
71b20698bb | ||
|
|
3df18dcde1 | ||
|
|
a898c2ea3a | ||
|
|
bf777298c8 | ||
|
|
93fad99f7f | ||
|
|
057848deb8 | ||
|
|
1de06452d3 | ||
|
|
58f60629a1 | ||
|
|
39a47c9b8c | ||
|
|
ea88044f2e | ||
|
|
e6f6f7aff1 | ||
|
|
48e97b47af | ||
|
|
fe120e3cbf | ||
|
|
f2dd774660 | ||
|
|
e7ff0f17c8 | ||
|
|
2ed756c72c | ||
|
|
054f4be185 | ||
|
|
e3e1e9af50 | ||
|
|
c8389cf96d | ||
|
|
c5442d418d | ||
|
|
fa95a61c4e | ||
|
|
9f3c2bd861 | ||
|
|
c2f78224ae | ||
|
|
14f9e21d5c | ||
|
|
8e4bab5181 | ||
|
|
3c32013eb1 | ||
|
|
47d2ab120a | ||
|
|
186af2723d | ||
|
|
6926fe1c74 | ||
|
|
ee018d5c82 | ||
|
|
0465579d6b | ||
|
|
196a03caff | ||
|
|
b234370080 | ||
|
|
5d2dc8888c | ||
|
|
0b1018f6dd | ||
|
|
afb6abff73 | ||
|
|
e7f94f9b9a | ||
|
|
72c77d0e7b | ||
|
|
5c15755a10 | ||
|
|
3a4bfeb5b5 | ||
|
|
1037c72d99 | ||
|
|
ba00e9a993 | ||
|
|
963dad75ef | ||
|
|
7e9b721e97 | ||
|
|
a5b1dc081d | ||
|
|
0bc2f99f2d | ||
|
|
55895d0663 | ||
|
|
72cb9dfa31 |
5
.github/VOUCHED.td
vendored
5
.github/VOUCHED.td
vendored
@@ -11,6 +11,7 @@ adamdotdevin
|
||||
-agusbasari29 AI PR slop
|
||||
ariane-emory
|
||||
-atharvau AI review spamming literally every PR
|
||||
-borealbytes
|
||||
-danieljoshuanazareth
|
||||
-danieljoshuanazareth
|
||||
edemaine
|
||||
@@ -21,8 +22,10 @@ jayair
|
||||
kitlangton
|
||||
kommander
|
||||
-opencode2026
|
||||
-opencodeengineer bot that spams issues
|
||||
r44vc0rp
|
||||
rekram1-node
|
||||
-robinmordasiewicz
|
||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||
thdxr
|
||||
-OpenCodeEngineer bot that spams issues
|
||||
-toastythebot
|
||||
|
||||
5
.github/workflows/docs-locale-sync.yml
vendored
5
.github/workflows/docs-locale-sync.yml
vendored
@@ -9,7 +9,8 @@ on:
|
||||
|
||||
jobs:
|
||||
sync-locales:
|
||||
if: github.actor != 'opencode-agent[bot]'
|
||||
if: false
|
||||
#if: github.actor != 'opencode-agent[bot]'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -34,7 +35,7 @@ jobs:
|
||||
- name: Compute changed English docs
|
||||
id: changes
|
||||
run: |
|
||||
FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- 'packages/web/src/content/docs/*.mdx' || true)
|
||||
FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- ':(glob)packages/web/src/content/docs/*.mdx' || true)
|
||||
if [ -z "$FILES" ]; then
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No English docs changed in push range"
|
||||
|
||||
9
.github/workflows/test.yml
vendored
9
.github/workflows/test.yml
vendored
@@ -47,6 +47,11 @@ jobs:
|
||||
|
||||
- name: Run unit tests
|
||||
run: bun turbo test
|
||||
env:
|
||||
# Bun 1.3.11 intermittently crashes on Windows during test teardown
|
||||
# inside the native @parcel/watcher binding. Unit CI does not rely on
|
||||
# the live watcher backend there, so disable it for that platform.
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
|
||||
e2e:
|
||||
name: e2e (${{ matrix.settings.name }})
|
||||
@@ -100,15 +105,17 @@ jobs:
|
||||
run: bun --cwd packages/app test:e2e:local
|
||||
env:
|
||||
CI: true
|
||||
PLAYWRIGHT_JUNIT_OUTPUT: e2e/junit-${{ matrix.settings.name }}.xml
|
||||
timeout-minutes: 30
|
||||
|
||||
- name: Upload Playwright artifacts
|
||||
if: failure()
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }}
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
path: |
|
||||
packages/app/e2e/junit-*.xml
|
||||
packages/app/e2e/test-results
|
||||
packages/app/e2e/playwright-report
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -25,6 +25,7 @@ target
|
||||
|
||||
# Local dev files
|
||||
opencode-dev
|
||||
UPCOMING_CHANGELOG.md
|
||||
logs/
|
||||
*.bun-build
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
@@ -1,23 +1,46 @@
|
||||
---
|
||||
model: opencode/kimi-k2.5
|
||||
model: opencode/gpt-5.4
|
||||
---
|
||||
|
||||
create UPCOMING_CHANGELOG.md
|
||||
Create `UPCOMING_CHANGELOG.md` from the structured changelog input below.
|
||||
If `UPCOMING_CHANGELOG.md` already exists, ignore its current contents completely.
|
||||
Do not preserve, merge, or reuse text from the existing file.
|
||||
|
||||
it should have sections
|
||||
The input already contains the exact commit range since the last non-draft release.
|
||||
The commits are already filtered to the release-relevant packages and grouped into
|
||||
the release sections. Do not fetch GitHub releases, PRs, or build your own commit list.
|
||||
The input may also include a `## Community Contributors Input` section.
|
||||
|
||||
```
|
||||
## TUI
|
||||
Before writing any entry you keep, inspect the real diff with
|
||||
`git show --stat --format='' <hash>` or `git show --format='' <hash>` so you can
|
||||
understand the actual code changes and not just the commit message (they may be misleading).
|
||||
Do not use `git log` or author metadata when deciding attribution.
|
||||
|
||||
## Desktop
|
||||
Rules:
|
||||
|
||||
## Core
|
||||
- Write the final file with sections in this order:
|
||||
`## Core`, `## TUI`, `## Desktop`, `## SDK`, `## Extensions`
|
||||
- Only include sections that have at least one notable entry
|
||||
- Keep one bullet per commit you keep
|
||||
- Skip commits that are entirely internal, CI, tests, refactors, or otherwise not user-facing
|
||||
- Start each bullet with a capital letter
|
||||
- Prefer what changed for users over what code changed internally
|
||||
- Do not copy raw commit prefixes like `fix:` or `feat:` or trailing PR numbers like `(#123)`
|
||||
- Community attribution is deterministic: only preserve an existing `(@username)` suffix from the changelog input
|
||||
- If an input bullet has no `(@username)` suffix, do not add one
|
||||
- Never add a new `(@username)` suffix from `git show`, commit authors, names, or email addresses
|
||||
- If no notable entries remain and there is no contributor block, write exactly `No notable changes.`
|
||||
- If no notable entries remain but there is a contributor block, omit all release sections and return only the contributor block
|
||||
- If the input contains `## Community Contributors Input`, append the block below that heading to the end of the final file verbatim
|
||||
- Do not add, remove, rewrite, or reorder contributor names or commit titles in that block
|
||||
- Do not derive the thank-you section from the main summary bullets
|
||||
- Do not include the heading `## Community Contributors Input` in the final file
|
||||
- Focus on writing the least words to get your point across - users will skim read the changelog, so we should be precise
|
||||
|
||||
## Misc
|
||||
```
|
||||
**Importantly, the changelog is for users (who are at least slightly technical), they may use the TUI, Desktop, SDK, Plugins and so forth. Be thorough in understanding flow on effects may not be immediately apparent. e.g. a package upgrade looks internal but may patch a bug. Or a refactor may also stabilise some race condition that fixes bugs for users. The PR title/body + commit message will give you the authors context, usually containing the outcome not just technical detail**
|
||||
|
||||
fetch the latest github release for this repository to determine the last release version.
|
||||
<changelog_input>
|
||||
|
||||
find each PR that was merged since the last release
|
||||
!`bun script/raw-changelog.ts $ARGUMENTS`
|
||||
|
||||
for each PR spawn a subagent to summarize what the PR was about. focus on user facing changes. if it was entirely internal or code related you can ignore it. also skip docs updates. each subagent should append its summary to UPCOMING_CHANGELOG.md into the appropriate section.
|
||||
</changelog_input>
|
||||
|
||||
@@ -653,23 +653,30 @@ const home = (api: TuiPluginApi, input: Cfg) => ({
|
||||
const skin = look(ctx.theme.current)
|
||||
type Prompt = (props: {
|
||||
workspaceID?: string
|
||||
visible?: boolean
|
||||
disabled?: boolean
|
||||
onSubmit?: () => void
|
||||
hint?: JSX.Element
|
||||
right?: JSX.Element
|
||||
showPlaceholder?: boolean
|
||||
placeholders?: {
|
||||
normal?: string[]
|
||||
shell?: string[]
|
||||
}
|
||||
}) => JSX.Element
|
||||
if (!("Prompt" in api.ui)) return null
|
||||
const view = api.ui.Prompt
|
||||
if (typeof view !== "function") return null
|
||||
const Prompt = view as Prompt
|
||||
type Slot = (
|
||||
props: { name: string; mode?: unknown; children?: JSX.Element } & Record<string, unknown>,
|
||||
) => JSX.Element | null
|
||||
const ui = api.ui as TuiPluginApi["ui"] & { Prompt: Prompt; Slot: Slot }
|
||||
const Prompt = ui.Prompt
|
||||
const Slot = ui.Slot
|
||||
const normal = [
|
||||
`[SMOKE] route check for ${input.label}`,
|
||||
"[SMOKE] confirm home_prompt slot override",
|
||||
"[SMOKE] verify api.ui.Prompt rendering",
|
||||
"[SMOKE] verify prompt-right slot passthrough",
|
||||
]
|
||||
const shell = ["printf '[SMOKE] home prompt\n'", "git status --short", "bun --version"]
|
||||
const Hint = (
|
||||
const hint = (
|
||||
<box flexShrink={0} flexDirection="row" gap={1}>
|
||||
<text fg={skin.muted}>
|
||||
<span style={{ fg: skin.accent }}>•</span> smoke home prompt
|
||||
@@ -677,7 +684,46 @@ const home = (api: TuiPluginApi, input: Cfg) => ({
|
||||
</box>
|
||||
)
|
||||
|
||||
return <Prompt workspaceID={value.workspace_id} hint={Hint} placeholders={{ normal, shell }} />
|
||||
return (
|
||||
<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) {
|
||||
const skin = look(ctx.theme.current)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-ppK5TVMsmy/7uP1kc6hw3gHMxokD/hBZYt5IGHR3/ok=",
|
||||
"aarch64-linux": "sha256-lrZjanBS8iHJa5TJJHQ9Gaz+lUqNaTgAUuDd6QHu8No=",
|
||||
"aarch64-darwin": "sha256-EojkRZQF5NqKPF3Bd/8UIiNngpkBk7uAM8m875bfOUo=",
|
||||
"x86_64-darwin": "sha256-fEO9Hx8yikkvdGj8nC06fy4u/hTGWO6kjENsU/B2OyY="
|
||||
"x86_64-linux": "sha256-0jwPCu2Lod433GPQLHN8eEkhfpPviDFfkFJmuvkRdlE=",
|
||||
"aarch64-linux": "sha256-Qi0IkGkaIBKZsPLTO8kaTbCVL0cEfVOm/Y/6VUVI9TY=",
|
||||
"aarch64-darwin": "sha256-1eZBBLgYVkjg5RYN/etR1Mb5UjU3VelElBB5ug5hQdc=",
|
||||
"x86_64-darwin": "sha256-jdXgA+kZb/foFHR40UiPif6rsA2GDVCCVHnJR3jBUGI="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,9 @@
|
||||
"packages/slack"
|
||||
],
|
||||
"catalog": {
|
||||
"@effect/platform-node": "4.0.0-beta.42",
|
||||
"@effect/platform-node": "4.0.0-beta.43",
|
||||
"@types/bun": "1.3.11",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"ulid": "3.0.1",
|
||||
@@ -45,8 +46,9 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||
"effect": "4.0.0-beta.42",
|
||||
"effect": "4.0.0-beta.43",
|
||||
"ai": "6.0.138",
|
||||
"cross-spawn": "7.0.6",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
"fuzzysort": "3.1.0",
|
||||
@@ -104,6 +106,7 @@
|
||||
"protobufjs",
|
||||
"tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
"tree-sitter-powershell",
|
||||
"web-tree-sitter",
|
||||
"electron"
|
||||
],
|
||||
|
||||
@@ -59,8 +59,10 @@ test("test description", async ({ page, sdk, gotoSession }) => {
|
||||
### Using Fixtures
|
||||
|
||||
- `page` - Playwright page
|
||||
- `sdk` - OpenCode SDK client for API calls
|
||||
- `gotoSession(sessionID?)` - Navigate to session
|
||||
- `llm` - Mock LLM server for queuing responses (`text`, `tool`, `toolMatch`, `textMatch`, etc.)
|
||||
- `project` - Golden-path project fixture (call `project.open()` first, then use `project.sdk`, `project.prompt(...)`, `project.gotoSession(...)`, `project.trackSession(...)`)
|
||||
- `sdk` - OpenCode SDK client for API calls (worker-scoped, shared directory)
|
||||
- `gotoSession(sessionID?)` - Navigate to session (worker-scoped, shared directory)
|
||||
|
||||
### Helper Functions
|
||||
|
||||
@@ -73,12 +75,9 @@ test("test description", async ({ page, sdk, gotoSession }) => {
|
||||
- `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output
|
||||
- `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output
|
||||
- `withSession(sdk, title, callback)` - Create temp session
|
||||
- `withProject(...)` - Create temp project/workspace
|
||||
- `sessionIDFromUrl(url)` - Read session ID from URL
|
||||
- `slugFromUrl(url)` - Read workspace slug from URL
|
||||
- `waitSlug(page, skip?)` - Wait for resolved workspace slug
|
||||
- `trackSession(sessionID, directory?)` - Register session for fixture cleanup
|
||||
- `trackDirectory(directory)` - Register directory for fixture cleanup
|
||||
- `clickListItem(container, filter)` - Click list item by key/text
|
||||
|
||||
**Selectors** (`selectors.ts`):
|
||||
@@ -128,9 +127,9 @@ test("test with cleanup", async ({ page, sdk, gotoSession }) => {
|
||||
})
|
||||
```
|
||||
|
||||
- Prefer `withSession(...)` for temp sessions
|
||||
- In `withProject(...)` tests that create sessions or extra workspaces, call `trackSession(sessionID, directory?)` and `trackDirectory(directory)`
|
||||
- This lets fixture teardown abort, wait for idle, and clean up safely under CI concurrency
|
||||
- Prefer the `project` fixture for tests that need a dedicated project with LLM mocking — call `project.open()` then use `project.prompt(...)`, `project.trackSession(...)`, etc.
|
||||
- Use `withSession(sdk, title, callback)` for lightweight temp sessions on the shared worker directory
|
||||
- Call `project.trackSession(sessionID, directory?)` and `project.trackDirectory(directory)` for any resources created outside the fixture so teardown can clean them up
|
||||
- Avoid calling `sdk.session.delete(...)` directly
|
||||
|
||||
### Timeouts
|
||||
|
||||
@@ -7,7 +7,6 @@ import { execSync } from "node:child_process"
|
||||
import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
|
||||
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
||||
import {
|
||||
dropdownMenuTriggerSelector,
|
||||
dropdownMenuContentSelector,
|
||||
projectSwitchSelector,
|
||||
projectMenuTriggerSelector,
|
||||
@@ -206,7 +205,7 @@ export async function closeDialog(page: Page, dialog: Locator) {
|
||||
await expect(dialog).toHaveCount(0)
|
||||
}
|
||||
|
||||
export async function isSidebarClosed(page: Page) {
|
||||
async function isSidebarClosed(page: Page) {
|
||||
const button = await waitSidebarButton(page, "isSidebarClosed")
|
||||
return (await button.getAttribute("aria-expanded")) !== "true"
|
||||
}
|
||||
@@ -237,7 +236,7 @@ async function errorBoundaryText(page: Page) {
|
||||
return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
|
||||
}
|
||||
|
||||
export async function assertHealthy(page: Page, context: string) {
|
||||
async function assertHealthy(page: Page, context: string) {
|
||||
const text = await errorBoundaryText(page)
|
||||
if (!text) return
|
||||
console.log(`[e2e:error-boundary][${context}]\n${text}`)
|
||||
@@ -312,61 +311,7 @@ export async function openSettings(page: Page) {
|
||||
return dialog
|
||||
}
|
||||
|
||||
export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) {
|
||||
await page.addInitScript(
|
||||
(args: { directory: string; serverUrl: string; extra: string[] }) => {
|
||||
const key = "opencode.global.dat:server"
|
||||
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 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,
|
||||
projects: nextProjects,
|
||||
lastProject,
|
||||
}),
|
||||
)
|
||||
},
|
||||
{ directory: input.directory, serverUrl, extra: input.extra ?? [] },
|
||||
)
|
||||
}
|
||||
|
||||
export async function createTestProject() {
|
||||
export async function createTestProject(input?: { serverUrl?: string }) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
|
||||
const id = `e2e-${path.basename(root)}`
|
||||
|
||||
@@ -381,7 +326,7 @@ export async function createTestProject() {
|
||||
stdio: "ignore",
|
||||
})
|
||||
|
||||
return resolveDirectory(root)
|
||||
return resolveDirectory(root, input?.serverUrl)
|
||||
}
|
||||
|
||||
export async function cleanupTestProject(directory: string) {
|
||||
@@ -430,22 +375,22 @@ export async function waitSlug(page: Page, skip: string[] = []) {
|
||||
return next
|
||||
}
|
||||
|
||||
export async function resolveSlug(slug: string) {
|
||||
export async function resolveSlug(slug: string, input?: { serverUrl?: string }) {
|
||||
const directory = base64Decode(slug)
|
||||
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
||||
const resolved = await resolveDirectory(directory)
|
||||
const resolved = await resolveDirectory(directory, input?.serverUrl)
|
||||
return { directory: resolved, slug: base64Encode(resolved), raw: slug }
|
||||
}
|
||||
|
||||
export async function waitDir(page: Page, directory: string) {
|
||||
const target = await resolveDirectory(directory)
|
||||
export async function waitDir(page: Page, directory: string, input?: { serverUrl?: string }) {
|
||||
const target = await resolveDirectory(directory, input?.serverUrl)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await assertHealthy(page, "waitDir")
|
||||
const slug = slugFromUrl(page.url())
|
||||
if (!slug) return ""
|
||||
return resolveSlug(slug)
|
||||
return resolveSlug(slug, input)
|
||||
.then((item) => item.directory)
|
||||
.catch(() => "")
|
||||
},
|
||||
@@ -455,25 +400,33 @@ export async function waitDir(page: Page, directory: string) {
|
||||
return { directory: target, slug: base64Encode(target) }
|
||||
}
|
||||
|
||||
export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) {
|
||||
const target = await resolveDirectory(input.directory)
|
||||
export async function waitSession(
|
||||
page: Page,
|
||||
input: {
|
||||
directory: string
|
||||
sessionID?: string
|
||||
serverUrl?: string
|
||||
allowAnySession?: boolean
|
||||
},
|
||||
) {
|
||||
const target = await resolveDirectory(input.directory, input.serverUrl)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await assertHealthy(page, "waitSession")
|
||||
const slug = slugFromUrl(page.url())
|
||||
if (!slug) return false
|
||||
const resolved = await resolveSlug(slug).catch(() => undefined)
|
||||
const resolved = await resolveSlug(slug, { serverUrl: input.serverUrl }).catch(() => undefined)
|
||||
if (!resolved || resolved.directory !== target) return false
|
||||
const current = sessionIDFromUrl(page.url())
|
||||
if (input.sessionID && current !== input.sessionID) return false
|
||||
if (!input.sessionID && current) return false
|
||||
if (!input.sessionID && !input.allowAnySession && current) return false
|
||||
|
||||
const state = await probeSession(page)
|
||||
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
|
||||
if (!input.sessionID && state?.sessionID) return false
|
||||
if (!input.sessionID && !input.allowAnySession && state?.sessionID) return false
|
||||
if (state?.dir) {
|
||||
const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
|
||||
const dir = await resolveDirectory(state.dir, input.serverUrl).catch(() => state.dir ?? "")
|
||||
if (dir !== target) return false
|
||||
}
|
||||
|
||||
@@ -489,9 +442,9 @@ export async function waitSession(page: Page, input: { directory: string; sessio
|
||||
return { directory: target, slug: base64Encode(target) }
|
||||
}
|
||||
|
||||
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) {
|
||||
const sdk = createSdk(directory)
|
||||
const target = await resolveDirectory(directory)
|
||||
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000, serverUrl?: string) {
|
||||
const sdk = createSdk(directory, serverUrl)
|
||||
const target = await resolveDirectory(directory, serverUrl)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
@@ -501,7 +454,7 @@ export async function waitSessionSaved(directory: string, sessionID: string, tim
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
if (!data?.directory) return ""
|
||||
return resolveDirectory(data.directory).catch(() => data.directory)
|
||||
return resolveDirectory(data.directory, serverUrl).catch(() => data.directory)
|
||||
},
|
||||
{ timeout },
|
||||
)
|
||||
@@ -578,12 +531,15 @@ export async function confirmDialog(page: Page, buttonName: string | RegExp) {
|
||||
}
|
||||
|
||||
export async function openSharePopover(page: Page) {
|
||||
const rightSection = page.locator(titlebarRightSelector)
|
||||
const shareButton = rightSection.getByRole("button", { name: "Share" }).first()
|
||||
await expect(shareButton).toBeVisible()
|
||||
const scroller = page.locator(".scroll-view__viewport").first()
|
||||
await expect(scroller).toBeVisible()
|
||||
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
|
||||
await expect(menuTrigger).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const popoverBody = page
|
||||
.locator(popoverBodySelector)
|
||||
.locator('[data-component="popover-content"]')
|
||||
.filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
|
||||
.first()
|
||||
|
||||
@@ -593,16 +549,13 @@ export async function openSharePopover(page: Page) {
|
||||
.catch(() => false)
|
||||
|
||||
if (!opened) {
|
||||
await shareButton.click()
|
||||
await expect(popoverBody).toBeVisible()
|
||||
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||
await menuTrigger.click()
|
||||
await clickMenuItem(menu, /share/i)
|
||||
await expect(menu).toHaveCount(0)
|
||||
await expect(popoverBody).toBeVisible({ timeout: 30_000 })
|
||||
}
|
||||
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()
|
||||
return { rightSection: scroller, popoverBody }
|
||||
}
|
||||
|
||||
export async function clickListItem(
|
||||
@@ -666,8 +619,9 @@ export async function cleanupSession(input: {
|
||||
sessionID: string
|
||||
directory?: string
|
||||
sdk?: ReturnType<typeof createSdk>
|
||||
serverUrl?: string
|
||||
}) {
|
||||
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory) : undefined)
|
||||
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory, input.serverUrl) : undefined)
|
||||
if (!sdk) throw new Error("cleanupSession requires sdk or directory")
|
||||
await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
|
||||
const current = await status(sdk, input.sessionID).catch(() => undefined)
|
||||
@@ -769,40 +723,6 @@ export async function seedSessionQuestion(
|
||||
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(
|
||||
sdk: ReturnType<typeof createSdk>,
|
||||
input: {
|
||||
@@ -861,36 +781,6 @@ export async function seedSessionTask(
|
||||
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) {
|
||||
const [questions, permissions] = await Promise.all([
|
||||
sdk.question.list().then((x) => x.data ?? []),
|
||||
@@ -980,30 +870,57 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
|
||||
}
|
||||
|
||||
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
|
||||
const current = await page
|
||||
.getByRole("button", { name: "New workspace" })
|
||||
.first()
|
||||
.isVisible()
|
||||
.then((x) => x)
|
||||
.catch(() => false)
|
||||
const current = () =>
|
||||
page
|
||||
.getByRole("button", { name: "New workspace" })
|
||||
.first()
|
||||
.isVisible()
|
||||
.then((x) => x)
|
||||
.catch(() => false)
|
||||
|
||||
if (current === enabled) return
|
||||
if ((await current()) === enabled) return
|
||||
|
||||
if (enabled) {
|
||||
await page.reload()
|
||||
await openSidebar(page)
|
||||
if ((await current()) === enabled) return
|
||||
}
|
||||
|
||||
const flip = async (timeout?: number) => {
|
||||
const menu = await openProjectMenu(page, projectSlug)
|
||||
const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
|
||||
await expect(toggle).toBeVisible()
|
||||
return toggle.click({ force: true, timeout })
|
||||
await expect(toggle).toBeEnabled({ timeout: 30_000 })
|
||||
const clicked = await toggle
|
||||
.click({ force: true, timeout })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (clicked) return
|
||||
await toggle.focus()
|
||||
await page.keyboard.press("Enter")
|
||||
}
|
||||
|
||||
const flipped = await flip(1500)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
for (const timeout of [1500, undefined, undefined]) {
|
||||
if ((await current()) === enabled) break
|
||||
await flip(timeout)
|
||||
.then(() => undefined)
|
||||
.catch(() => undefined)
|
||||
const matched = await expect
|
||||
.poll(current, { timeout: 5_000 })
|
||||
.toBe(enabled)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (matched) break
|
||||
}
|
||||
|
||||
if (!flipped) await flip()
|
||||
if ((await current()) !== enabled) {
|
||||
await page.reload()
|
||||
await openSidebar(page)
|
||||
}
|
||||
|
||||
const expected = enabled ? "New workspace" : "New session"
|
||||
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
|
||||
await expect.poll(current, { timeout: 60_000 }).toBe(enabled)
|
||||
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible({ timeout: 30_000 })
|
||||
}
|
||||
|
||||
export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
|
||||
@@ -1019,3 +936,13 @@ export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
|
||||
await expect(menu).toBeVisible()
|
||||
return menu
|
||||
}
|
||||
|
||||
export async function assistantText(sdk: ReturnType<typeof createSdk>, sessionID: string) {
|
||||
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||
return messages
|
||||
.filter((m) => m.info.role === "assistant")
|
||||
.flatMap((m) => m.parts)
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
137
packages/app/e2e/backend.ts
Normal file
137
packages/app/e2e/backend.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { spawn } from "node:child_process"
|
||||
import fs from "node:fs/promises"
|
||||
import net from "node:net"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
type Handle = {
|
||||
url: string
|
||||
stop: () => Promise<void>
|
||||
}
|
||||
|
||||
function freePort() {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
const server = net.createServer()
|
||||
server.once("error", reject)
|
||||
server.listen(0, () => {
|
||||
const address = server.address()
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Failed to acquire a free port")))
|
||||
return
|
||||
}
|
||||
server.close((err) => {
|
||||
if (err) reject(err)
|
||||
else resolve(address.port)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForHealth(url: string, probe = "/global/health") {
|
||||
const end = Date.now() + 120_000
|
||||
let last = ""
|
||||
while (Date.now() < end) {
|
||||
try {
|
||||
const res = await fetch(`${url}${probe}`)
|
||||
if (res.ok) return
|
||||
last = `status ${res.status}`
|
||||
} catch (err) {
|
||||
last = err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
}
|
||||
throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`)
|
||||
}
|
||||
|
||||
async function waitExit(proc: ReturnType<typeof spawn>, timeout = 10_000) {
|
||||
if (proc.exitCode !== null) return
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve) => proc.once("exit", () => resolve())),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, timeout)),
|
||||
])
|
||||
}
|
||||
|
||||
const LOG_CAP = 100
|
||||
|
||||
function cap(input: string[]) {
|
||||
if (input.length > LOG_CAP) input.splice(0, input.length - LOG_CAP)
|
||||
}
|
||||
|
||||
function tail(input: string[]) {
|
||||
return input.slice(-40).join("")
|
||||
}
|
||||
|
||||
export async function startBackend(label: string, input?: { llmUrl?: string }): Promise<Handle> {
|
||||
const port = await freePort()
|
||||
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), `opencode-e2e-${label}-`))
|
||||
const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
||||
const repoDir = path.resolve(appDir, "../..")
|
||||
const opencodeDir = path.join(repoDir, "packages", "opencode")
|
||||
const env = {
|
||||
...process.env,
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
|
||||
OPENCODE_TEST_HOME: path.join(sandbox, "home"),
|
||||
XDG_DATA_HOME: path.join(sandbox, "share"),
|
||||
XDG_CACHE_HOME: path.join(sandbox, "cache"),
|
||||
XDG_CONFIG_HOME: path.join(sandbox, "config"),
|
||||
XDG_STATE_HOME: path.join(sandbox, "state"),
|
||||
OPENCODE_CLIENT: "app",
|
||||
OPENCODE_STRICT_CONFIG_DEPS: "true",
|
||||
OPENCODE_E2E_LLM_URL: input?.llmUrl,
|
||||
} satisfies Record<string, string | undefined>
|
||||
const out: string[] = []
|
||||
const err: string[] = []
|
||||
const proc = spawn(
|
||||
"bun",
|
||||
["run", "--conditions=browser", "./src/index.ts", "serve", "--port", String(port), "--hostname", "127.0.0.1"],
|
||||
{
|
||||
cwd: opencodeDir,
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
)
|
||||
proc.stdout?.on("data", (chunk) => {
|
||||
out.push(String(chunk))
|
||||
cap(out)
|
||||
})
|
||||
proc.stderr?.on("data", (chunk) => {
|
||||
err.push(String(chunk))
|
||||
cap(err)
|
||||
})
|
||||
|
||||
const url = `http://127.0.0.1:${port}`
|
||||
try {
|
||||
await waitForHealth(url)
|
||||
} catch (error) {
|
||||
proc.kill("SIGTERM")
|
||||
await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
|
||||
throw new Error(
|
||||
[
|
||||
`Failed to start isolated e2e backend for ${label}`,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
tail(out),
|
||||
tail(err),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
async stop() {
|
||||
if (proc.exitCode === null) {
|
||||
proc.kill("SIGTERM")
|
||||
await waitExit(proc)
|
||||
}
|
||||
if (proc.exitCode === null) {
|
||||
proc.kill("SIGKILL")
|
||||
await waitExit(proc)
|
||||
}
|
||||
await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,251 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test"
|
||||
import { ManagedRuntime } from "effect"
|
||||
import type { E2EWindow } from "../src/testing/terminal"
|
||||
import type { Item, Reply, Usage } from "../../opencode/test/lib/llm-server"
|
||||
import { TestLLMServer } from "../../opencode/test/lib/llm-server"
|
||||
import { startBackend } from "./backend"
|
||||
import {
|
||||
healthPhase,
|
||||
cleanupSession,
|
||||
cleanupTestProject,
|
||||
createTestProject,
|
||||
setHealthPhase,
|
||||
seedProjects,
|
||||
sessionIDFromUrl,
|
||||
waitSlug,
|
||||
waitSession,
|
||||
waitSessionIdle,
|
||||
waitSessionSaved,
|
||||
waitSlug,
|
||||
} from "./actions"
|
||||
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
||||
import { promptSelector } from "./selectors"
|
||||
import { createSdk, dirSlug, getWorktree, serverUrl, sessionPath } from "./utils"
|
||||
|
||||
type LLMFixture = {
|
||||
url: string
|
||||
push: (...input: (Item | Reply)[]) => Promise<void>
|
||||
pushMatch: (
|
||||
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
|
||||
...input: (Item | Reply)[]
|
||||
) => Promise<void>
|
||||
textMatch: (
|
||||
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
|
||||
value: string,
|
||||
opts?: { usage?: Usage },
|
||||
) => Promise<void>
|
||||
toolMatch: (
|
||||
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
|
||||
name: string,
|
||||
input: unknown,
|
||||
) => Promise<void>
|
||||
text: (value: string, opts?: { usage?: Usage }) => Promise<void>
|
||||
tool: (name: string, input: unknown) => Promise<void>
|
||||
toolHang: (name: string, input: unknown) => Promise<void>
|
||||
reason: (value: string, opts?: { text?: string; usage?: Usage }) => Promise<void>
|
||||
fail: (message?: unknown) => Promise<void>
|
||||
error: (status: number, body: unknown) => Promise<void>
|
||||
hang: () => Promise<void>
|
||||
hold: (value: string, wait: PromiseLike<unknown>) => Promise<void>
|
||||
hits: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
|
||||
calls: () => Promise<number>
|
||||
wait: (count: number) => Promise<void>
|
||||
inputs: () => Promise<Record<string, unknown>[]>
|
||||
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"
|
||||
|
||||
const seedModel = (() => {
|
||||
const [providerID = "opencode", modelID = "big-pickle"] = (
|
||||
process.env.OPENCODE_E2E_MODEL ?? "opencode/big-pickle"
|
||||
).split("/")
|
||||
return {
|
||||
providerID: providerID || "opencode",
|
||||
modelID: modelID || "big-pickle",
|
||||
}
|
||||
})()
|
||||
|
||||
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 = {
|
||||
directory: string
|
||||
slug: string
|
||||
gotoSession: (sessionID?: string) => Promise<void>
|
||||
trackSession: (sessionID: string, directory?: string) => void
|
||||
trackDirectory: (directory: string) => void
|
||||
sdk: ReturnType<typeof createSdk>
|
||||
}
|
||||
|
||||
type ProjectOptions = {
|
||||
extra?: string[]
|
||||
model?: { providerID: string; modelID: string }
|
||||
setup?: (directory: string) => 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 = {
|
||||
llm: LLMFixture
|
||||
assistant: AssistantFixture
|
||||
project: ProjectFixture
|
||||
sdk: ReturnType<typeof createSdk>
|
||||
gotoSession: (sessionID?: string) => Promise<void>
|
||||
withProject: <T>(
|
||||
callback: (project: {
|
||||
directory: string
|
||||
slug: string
|
||||
gotoSession: (sessionID?: string) => Promise<void>
|
||||
trackSession: (sessionID: string, directory?: string) => void
|
||||
trackDirectory: (directory: string) => void
|
||||
}) => Promise<T>,
|
||||
options?: { extra?: string[] },
|
||||
) => Promise<T>
|
||||
}
|
||||
|
||||
type WorkerFixtures = {
|
||||
_llm: LLMWorker
|
||||
backend: {
|
||||
url: string
|
||||
sdk: (directory?: string) => ReturnType<typeof createSdk>
|
||||
}
|
||||
directory: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
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: [
|
||||
async ({ _llm }, use, workerInfo) => {
|
||||
const handle = await startBackend(`w${workerInfo.workerIndex}`, { llmUrl: _llm.url })
|
||||
try {
|
||||
await use({
|
||||
url: handle.url,
|
||||
sdk: (directory?: string) => createSdk(directory, handle.url),
|
||||
})
|
||||
} finally {
|
||||
await handle.stop()
|
||||
}
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
llm: async ({ _llm }, use) => {
|
||||
await _llm.reset()
|
||||
await use({
|
||||
url: _llm.url,
|
||||
push: _llm.push,
|
||||
pushMatch: _llm.pushMatch,
|
||||
textMatch: _llm.textMatch,
|
||||
toolMatch: _llm.toolMatch,
|
||||
text: _llm.text,
|
||||
tool: _llm.tool,
|
||||
toolHang: _llm.toolHang,
|
||||
reason: _llm.reason,
|
||||
fail: _llm.fail,
|
||||
error: _llm.error,
|
||||
hang: _llm.hang,
|
||||
hold: _llm.hold,
|
||||
hits: _llm.hits,
|
||||
calls: _llm.calls,
|
||||
wait: _llm.wait,
|
||||
inputs: _llm.inputs,
|
||||
pending: _llm.pending,
|
||||
misses: _llm.misses,
|
||||
})
|
||||
const pending = await _llm.pending()
|
||||
if (pending > 0) {
|
||||
throw new Error(`TestLLMServer still has ${pending} queued response(s) after the test finished`)
|
||||
}
|
||||
},
|
||||
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) => {
|
||||
let boundary: string | undefined
|
||||
setHealthPhase(page, "test")
|
||||
@@ -60,9 +270,8 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
if (boundary) throw new Error(boundary)
|
||||
},
|
||||
directory: [
|
||||
async ({}, use) => {
|
||||
const directory = await getWorktree()
|
||||
await use(directory)
|
||||
async ({ backend }, use) => {
|
||||
await use(await getWorktree(backend.url))
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
@@ -72,83 +281,324 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
sdk: async ({ directory }, use) => {
|
||||
await use(createSdk(directory))
|
||||
sdk: async ({ directory, backend }, use) => {
|
||||
await use(backend.sdk(directory))
|
||||
},
|
||||
gotoSession: async ({ page, directory }, use) => {
|
||||
await seedStorage(page, { directory })
|
||||
gotoSession: async ({ page, directory, backend }, use) => {
|
||||
await seedStorage(page, { directory, serverUrl: backend.url })
|
||||
|
||||
const gotoSession = async (sessionID?: string) => {
|
||||
await page.goto(sessionPath(directory, sessionID))
|
||||
await waitSession(page, { directory, sessionID })
|
||||
await visit(page, sessionPath(directory, sessionID))
|
||||
await waitSession(page, {
|
||||
directory,
|
||||
sessionID,
|
||||
serverUrl: backend.url,
|
||||
allowAnySession: !sessionID,
|
||||
})
|
||||
}
|
||||
await use(gotoSession)
|
||||
},
|
||||
withProject: async ({ page }, use) => {
|
||||
await use(async (callback, options) => {
|
||||
const root = await createTestProject()
|
||||
const sessions = new Map<string, string>()
|
||||
const dirs = new Set<string>()
|
||||
await seedStorage(page, { directory: root, extra: options?.extra })
|
||||
|
||||
const gotoSession = async (sessionID?: string) => {
|
||||
await page.goto(sessionPath(root, sessionID))
|
||||
await waitSession(page, { directory: root, sessionID })
|
||||
const current = sessionIDFromUrl(page.url())
|
||||
if (current) trackSession(current)
|
||||
}
|
||||
|
||||
const trackSession = (sessionID: string, directory?: string) => {
|
||||
sessions.set(sessionID, directory ?? root)
|
||||
}
|
||||
|
||||
const trackDirectory = (directory: string) => {
|
||||
if (directory !== root) dirs.add(directory)
|
||||
}
|
||||
|
||||
try {
|
||||
await gotoSession()
|
||||
const slug = await waitSlug(page)
|
||||
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
|
||||
} finally {
|
||||
setHealthPhase(page, "cleanup")
|
||||
await Promise.allSettled(
|
||||
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
|
||||
)
|
||||
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
|
||||
await cleanupTestProject(root)
|
||||
setHealthPhase(page, "test")
|
||||
}
|
||||
})
|
||||
project: async ({ page, llm, backend }, use) => {
|
||||
const item = makeProject(page, llm, backend)
|
||||
try {
|
||||
await use(item.project)
|
||||
} finally {
|
||||
await item.cleanup()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
|
||||
await seedProjects(page, input)
|
||||
await page.addInitScript(() => {
|
||||
const win = window as E2EWindow
|
||||
win.__opencode_e2e = {
|
||||
...win.__opencode_e2e,
|
||||
model: {
|
||||
enabled: true,
|
||||
},
|
||||
prompt: {
|
||||
enabled: true,
|
||||
},
|
||||
terminal: {
|
||||
enabled: true,
|
||||
terminals: {},
|
||||
},
|
||||
function makeProject(
|
||||
page: Page,
|
||||
llm: LLMFixture,
|
||||
backend: { url: string; sdk: (directory?: string) => ReturnType<typeof createSdk> },
|
||||
) {
|
||||
let state:
|
||||
| {
|
||||
directory: string
|
||||
slug: string
|
||||
sdk: ReturnType<typeof createSdk>
|
||||
sessions: Map<string, string>
|
||||
dirs: Set<string>
|
||||
}
|
||||
| undefined
|
||||
|
||||
const need = () => {
|
||||
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 cur = need()
|
||||
await visit(page, sessionPath(cur.directory, sessionID))
|
||||
await waitSession(page, {
|
||||
directory: cur.directory,
|
||||
sessionID,
|
||||
serverUrl: backend.url,
|
||||
allowAnySession: !sessionID,
|
||||
})
|
||||
const current = sessionIDFromUrl(page.url())
|
||||
if (current) trackSession(current)
|
||||
}
|
||||
|
||||
const open = async (options?: ProjectOptions) => {
|
||||
if (state) return
|
||||
const directory = await createTestProject({ serverUrl: backend.url })
|
||||
const sdk = backend.sdk(directory)
|
||||
await options?.setup?.(directory)
|
||||
await seedStorage(page, {
|
||||
directory,
|
||||
extra: options?.extra,
|
||||
model: options?.model,
|
||||
serverUrl: backend.url,
|
||||
})
|
||||
state = {
|
||||
directory,
|
||||
slug: "",
|
||||
sdk,
|
||||
sessions: new Map(),
|
||||
dirs: new Set(),
|
||||
}
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:model",
|
||||
JSON.stringify({
|
||||
recent: [{ providerID: "opencode", modelID: "big-pickle" }],
|
||||
user: [],
|
||||
variant: {},
|
||||
}),
|
||||
await options?.beforeGoto?.({ directory, sdk })
|
||||
await gotoSession()
|
||||
need().slug = await waitSlug(page)
|
||||
}
|
||||
|
||||
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")
|
||||
await Promise.allSettled(
|
||||
Array.from(cur.sessions, ([sessionID, directory]) =>
|
||||
cleanupSession({ sessionID, directory, serverUrl: backend.url }),
|
||||
),
|
||||
)
|
||||
})
|
||||
await Promise.allSettled(Array.from(cur.dirs, (directory) => cleanupTestProject(directory)))
|
||||
await cleanupTestProject(cur.directory)
|
||||
state = undefined
|
||||
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(
|
||||
page: Page,
|
||||
input: {
|
||||
directory: string
|
||||
extra?: string[]
|
||||
model?: { providerID: string; modelID: string }
|
||||
serverUrl?: string
|
||||
},
|
||||
) {
|
||||
const origin = input.serverUrl ?? serverUrl
|
||||
await page.addInitScript(
|
||||
(args: {
|
||||
directory: string
|
||||
serverUrl: string
|
||||
extra: string[]
|
||||
model: { providerID: string; modelID: string }
|
||||
}) => {
|
||||
const key = "opencode.global.dat:server"
|
||||
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 next = { ...(projects as Record<string, unknown>) }
|
||||
const nextList = list.includes(args.serverUrl) ? list : [args.serverUrl, ...list]
|
||||
|
||||
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 }
|
||||
|
||||
@@ -2,7 +2,7 @@ import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { clickListItem } from "../actions"
|
||||
|
||||
test("smoke model selection updates prompt footer", async ({ page, gotoSession }) => {
|
||||
test.fixme("smoke model selection updates prompt footer", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
await page.locator(promptSelector).click()
|
||||
|
||||
@@ -1,43 +1,49 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { clickMenuItem, openProjectMenu, openSidebar } from "../actions"
|
||||
|
||||
test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
|
||||
test("dialog edit project updates name and startup script", async ({ page, project }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
await withProject(async ({ slug }) => {
|
||||
await openSidebar(page)
|
||||
await project.open()
|
||||
await openSidebar(page)
|
||||
|
||||
const open = async () => {
|
||||
const menu = await openProjectMenu(page, slug)
|
||||
await clickMenuItem(menu, /^Edit$/i, { force: true })
|
||||
const open = async () => {
|
||||
const menu = await openProjectMenu(page, project.slug)
|
||||
await clickMenuItem(menu, /^Edit$/i, { force: true })
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
|
||||
return dialog
|
||||
}
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
|
||||
return dialog
|
||||
}
|
||||
|
||||
const name = `e2e project ${Date.now()}`
|
||||
const startup = `echo e2e_${Date.now()}`
|
||||
const name = `e2e project ${Date.now()}`
|
||||
const startup = `echo e2e_${Date.now()}`
|
||||
|
||||
const dialog = await open()
|
||||
const dialog = await open()
|
||||
|
||||
const nameInput = dialog.getByLabel("Name")
|
||||
await nameInput.fill(name)
|
||||
const nameInput = dialog.getByLabel("Name")
|
||||
await nameInput.fill(name)
|
||||
|
||||
const startupInput = dialog.getByLabel("Workspace startup script")
|
||||
await startupInput.fill(startup)
|
||||
const startupInput = dialog.getByLabel("Workspace startup script")
|
||||
await startupInput.fill(startup)
|
||||
|
||||
await dialog.getByRole("button", { name: "Save" }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await dialog.getByRole("button", { name: "Save" }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
|
||||
const header = page.locator(".group\\/project").first()
|
||||
await expect(header).toContainText(name)
|
||||
|
||||
const reopened = await open()
|
||||
await expect(reopened.getByLabel("Name")).toHaveValue(name)
|
||||
await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup)
|
||||
await reopened.getByRole("button", { name: "Cancel" }).click()
|
||||
await expect(reopened).toHaveCount(0)
|
||||
})
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await page.reload()
|
||||
await openSidebar(page)
|
||||
const reopened = await open()
|
||||
const value = await reopened.getByLabel("Name").inputValue()
|
||||
const next = await reopened.getByLabel("Workspace startup script").inputValue()
|
||||
await reopened.getByRole("button", { name: "Cancel" }).click()
|
||||
await expect(reopened).toHaveCount(0)
|
||||
return `${value}\n${next}`
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(`${name}\n${startup}`)
|
||||
})
|
||||
|
||||
@@ -3,51 +3,46 @@ import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, open
|
||||
import { projectSwitchSelector } from "../selectors"
|
||||
import { dirSlug } from "../utils"
|
||||
|
||||
test("closing active project navigates to another open project", async ({ page, withProject }) => {
|
||||
test("closing active project navigates to another open project", async ({ page, project }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const other = await createTestProject()
|
||||
const otherSlug = dirSlug(other)
|
||||
|
||||
try {
|
||||
await withProject(
|
||||
async ({ slug }) => {
|
||||
await openSidebar(page)
|
||||
await project.open({ extra: [other] })
|
||||
await openSidebar(page)
|
||||
|
||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||
await expect(otherButton).toBeVisible()
|
||||
await otherButton.click()
|
||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||
await expect(otherButton).toBeVisible()
|
||||
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 clickMenuItem(menu, /^Close$/i, { force: true })
|
||||
await expect
|
||||
.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
|
||||
.poll(
|
||||
() => {
|
||||
const pathname = new URL(page.url()).pathname
|
||||
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
|
||||
if (pathname === "/") return "home"
|
||||
return ""
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.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] },
|
||||
)
|
||||
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)
|
||||
} finally {
|
||||
await cleanupTestProject(other)
|
||||
}
|
||||
|
||||
@@ -5,111 +5,89 @@ import {
|
||||
createTestProject,
|
||||
cleanupTestProject,
|
||||
openSidebar,
|
||||
sessionIDFromUrl,
|
||||
setWorkspacesEnabled,
|
||||
waitSession,
|
||||
waitSessionSaved,
|
||||
waitSlug,
|
||||
} from "../actions"
|
||||
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||
import { projectSwitchSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||
import { dirSlug, resolveDirectory } from "../utils"
|
||||
|
||||
test("can switch between projects from sidebar", async ({ page, withProject }) => {
|
||||
test("can switch between projects from sidebar", async ({ page, project }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const other = await createTestProject()
|
||||
const otherSlug = dirSlug(other)
|
||||
|
||||
try {
|
||||
await withProject(
|
||||
async ({ directory }) => {
|
||||
await defocus(page)
|
||||
await project.open({ extra: [other] })
|
||||
await defocus(page)
|
||||
|
||||
const currentSlug = dirSlug(directory)
|
||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||
await expect(otherButton).toBeVisible()
|
||||
await otherButton.click()
|
||||
const currentSlug = dirSlug(project.directory)
|
||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||
await expect(otherButton).toBeVisible()
|
||||
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()
|
||||
await expect(currentButton).toBeVisible()
|
||||
await currentButton.click()
|
||||
const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
|
||||
await expect(currentButton).toBeVisible()
|
||||
await currentButton.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
|
||||
},
|
||||
{ extra: [other] },
|
||||
)
|
||||
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
|
||||
} finally {
|
||||
await cleanupTestProject(other)
|
||||
}
|
||||
})
|
||||
|
||||
test("switching back to a project opens the latest workspace session", async ({ page, withProject }) => {
|
||||
test("switching back to a project opens the latest workspace session", async ({ page, project }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const other = await createTestProject()
|
||||
const otherSlug = dirSlug(other)
|
||||
try {
|
||||
await withProject(
|
||||
async ({ directory, slug, trackSession, trackDirectory }) => {
|
||||
await defocus(page)
|
||||
await setWorkspacesEnabled(page, slug, true)
|
||||
await openSidebar(page)
|
||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||
await project.open({ extra: [other] })
|
||||
await defocus(page)
|
||||
await setWorkspacesEnabled(page, project.slug, true)
|
||||
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, [slug])
|
||||
const dir = base64Decode(raw)
|
||||
if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`)
|
||||
const space = await resolveDirectory(dir)
|
||||
const next = dirSlug(space)
|
||||
trackDirectory(space)
|
||||
await openSidebar(page)
|
||||
const raw = await waitSlug(page, [project.slug])
|
||||
const dir = base64Decode(raw)
|
||||
if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`)
|
||||
const space = await resolveDirectory(dir)
|
||||
const next = dirSlug(space)
|
||||
project.trackDirectory(space)
|
||||
await openSidebar(page)
|
||||
|
||||
const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first()
|
||||
await expect(item).toBeVisible()
|
||||
await item.hover()
|
||||
const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first()
|
||||
await expect(item).toBeVisible()
|
||||
await item.hover()
|
||||
|
||||
const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first()
|
||||
await expect(btn).toBeVisible()
|
||||
await btn.click({ force: true })
|
||||
const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first()
|
||||
await expect(btn).toBeVisible()
|
||||
await btn.click({ force: true })
|
||||
|
||||
await waitSession(page, { directory: space })
|
||||
await waitSession(page, { directory: space })
|
||||
|
||||
// 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")
|
||||
const created = await project.user("test")
|
||||
|
||||
// Wait for the URL to update with the new session ID
|
||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
|
||||
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
|
||||
|
||||
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)
|
||||
await openSidebar(page)
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
|
||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||
await expect(otherButton).toBeVisible()
|
||||
await otherButton.click({ force: true })
|
||||
await waitSession(page, { directory: other })
|
||||
|
||||
await openSidebar(page)
|
||||
const rootButton = page.locator(projectSwitchSelector(project.slug)).first()
|
||||
await expect(rootButton).toBeVisible()
|
||||
await rootButton.click({ force: true })
|
||||
|
||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||
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] },
|
||||
)
|
||||
await waitSession(page, { directory: space, sessionID: created })
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
|
||||
} finally {
|
||||
await cleanupTestProject(other)
|
||||
}
|
||||
|
||||
@@ -7,11 +7,9 @@ import {
|
||||
setWorkspacesEnabled,
|
||||
waitDir,
|
||||
waitSession,
|
||||
waitSessionSaved,
|
||||
waitSlug,
|
||||
} from "../actions"
|
||||
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||
import { createSdk } from "../utils"
|
||||
import { workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||
|
||||
function item(space: { slug: string; raw: string }) {
|
||||
return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
|
||||
@@ -50,45 +48,31 @@ async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: s
|
||||
}
|
||||
|
||||
async function createSessionFromWorkspace(
|
||||
project: Parameters<typeof test>[0]["project"],
|
||||
page: Page,
|
||||
space: { slug: string; raw: string; directory: string },
|
||||
text: string,
|
||||
) {
|
||||
await openWorkspaceNewSession(page, space)
|
||||
|
||||
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
|
||||
return project.user(text)
|
||||
}
|
||||
|
||||
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
|
||||
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, project }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
await withProject(async ({ slug: root, trackDirectory, trackSession }) => {
|
||||
await openSidebar(page)
|
||||
await setWorkspacesEnabled(page, root, true)
|
||||
await project.open()
|
||||
await openSidebar(page)
|
||||
await setWorkspacesEnabled(page, project.slug, true)
|
||||
|
||||
const first = await createWorkspace(page, root, [])
|
||||
trackDirectory(first.directory)
|
||||
await waitWorkspaceReady(page, first)
|
||||
const first = await createWorkspace(page, project.slug, [])
|
||||
project.trackDirectory(first.directory)
|
||||
await waitWorkspaceReady(page, first)
|
||||
|
||||
const second = await createWorkspace(page, root, [first.slug])
|
||||
trackDirectory(second.directory)
|
||||
await waitWorkspaceReady(page, second)
|
||||
const second = await createWorkspace(page, project.slug, [first.slug])
|
||||
project.trackDirectory(second.directory)
|
||||
await waitWorkspaceReady(page, second)
|
||||
|
||||
trackSession(await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`), first.directory)
|
||||
trackSession(await createSessionFromWorkspace(page, second, `workspace two ${Date.now()}`), second.directory)
|
||||
trackSession(await createSessionFromWorkspace(page, first, `workspace one again ${Date.now()}`), first.directory)
|
||||
})
|
||||
await createSessionFromWorkspace(project, page, first, `workspace one ${Date.now()}`)
|
||||
await createSessionFromWorkspace(project, page, second, `workspace two ${Date.now()}`)
|
||||
await createSessionFromWorkspace(project, page, first, `workspace one again ${Date.now()}`)
|
||||
})
|
||||
|
||||
@@ -19,10 +19,10 @@ import {
|
||||
waitDir,
|
||||
waitSlug,
|
||||
} from "../actions"
|
||||
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
|
||||
import { createSdk, dirSlug } from "../utils"
|
||||
import { inlineInputSelector, workspaceItemSelector } from "../selectors"
|
||||
import { dirSlug } from "../utils"
|
||||
|
||||
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
||||
async function setupWorkspaceTest(page: Page, project: { slug: string; trackDirectory: (directory: string) => void }) {
|
||||
const rootSlug = project.slug
|
||||
await openSidebar(page)
|
||||
|
||||
@@ -31,6 +31,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
const next = await resolveSlug(await waitSlug(page, [rootSlug]))
|
||||
await waitDir(page, next.directory)
|
||||
project.trackDirectory(next.directory)
|
||||
|
||||
await openSidebar(page)
|
||||
|
||||
@@ -52,44 +53,192 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
||||
return { rootSlug, slug: next.slug, directory: next.directory }
|
||||
}
|
||||
|
||||
test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
|
||||
test("can enable and disable workspaces from project menu", async ({ page, project }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
await project.open()
|
||||
|
||||
await withProject(async ({ slug }) => {
|
||||
await openSidebar(page)
|
||||
await openSidebar(page)
|
||||
|
||||
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 session" }).first()).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
||||
|
||||
await setWorkspacesEnabled(page, slug, true)
|
||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||
await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible()
|
||||
await setWorkspacesEnabled(page, project.slug, true)
|
||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||
await expect(page.locator(workspaceItemSelector(project.slug)).first()).toBeVisible()
|
||||
|
||||
await setWorkspacesEnabled(page, slug, false)
|
||||
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
||||
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
|
||||
})
|
||||
await setWorkspacesEnabled(page, project.slug, false)
|
||||
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
||||
await expect(page.locator(workspaceItemSelector(project.slug))).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("can create a workspace", async ({ page, withProject }) => {
|
||||
test("can create a workspace", async ({ page, project }) => {
|
||||
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 withProject(async ({ slug }) => {
|
||||
await openSidebar(page)
|
||||
await setWorkspacesEnabled(page, slug, true)
|
||||
const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-"))
|
||||
const nonGitSlug = dirSlug(nonGit)
|
||||
|
||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||
await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
|
||||
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
const next = await resolveSlug(await waitSlug(page, [slug]))
|
||||
await waitDir(page, next.directory)
|
||||
try {
|
||||
await project.open({ extra: [nonGit] })
|
||||
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)
|
||||
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
|
||||
.poll(
|
||||
async () => {
|
||||
const item = page.locator(workspaceItemSelector(next.slug)).first()
|
||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||
try {
|
||||
await item.hover({ timeout: 500 })
|
||||
return true
|
||||
@@ -100,276 +249,120 @@ test("can create a workspace", async ({ page, withProject }) => {
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.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)
|
||||
}
|
||||
})
|
||||
|
||||
test("can rename a workspace", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
const drag = async (from: string, to: string) => {
|
||||
const src = page.locator(workspaceItemSelector(from)).first()
|
||||
const dst = page.locator(workspaceItemSelector(to)).first()
|
||||
|
||||
await withProject(async (project) => {
|
||||
const { slug } = await setupWorkspaceTest(page, project)
|
||||
const a = await src.boundingBox()
|
||||
const b = await dst.boundingBox()
|
||||
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
|
||||
|
||||
const rename = `e2e workspace ${Date.now()}`
|
||||
const menu = await openWorkspaceMenu(page, slug)
|
||||
await clickMenuItem(menu, /^Rename$/i, { force: true })
|
||||
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
|
||||
await page.mouse.up()
|
||||
}
|
||||
|
||||
await expect(menu).toHaveCount(0)
|
||||
await openSidebar(page)
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
await setWorkspacesEnabled(page, rootSlug, true)
|
||||
|
||||
test("can reset a workspace", async ({ page, sdk, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
await withProject(async (project) => {
|
||||
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 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()
|
||||
const workspaces = [] as { directory: string; slug: string }[]
|
||||
for (const _ of [0, 1]) {
|
||||
const prev = slugFromUrl(page.url())
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
|
||||
await waitDir(page, next.directory)
|
||||
project.trackDirectory(next.directory)
|
||||
workspaces.push(next)
|
||||
|
||||
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 reorder workspaces by drag and drop", async ({ page, withProject }) => {
|
||||
test("can delete a workspace", async ({ page, project }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
await withProject(async ({ slug: rootSlug }) => {
|
||||
const workspaces = [] as { directory: string; slug: string }[]
|
||||
await project.open()
|
||||
|
||||
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 rootSlug = project.slug
|
||||
await openSidebar(page)
|
||||
await setWorkspacesEnabled(page, rootSlug, true)
|
||||
|
||||
const waitReady = async (slug: string) => {
|
||||
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 created = await project.sdk.worktree.create({ directory: project.directory }).then((res) => res.data)
|
||||
if (!created?.directory) throw new Error("Failed to create workspace for delete test")
|
||||
|
||||
const drag = async (from: string, to: string) => {
|
||||
const src = page.locator(workspaceItemSelector(from)).first()
|
||||
const dst = page.locator(workspaceItemSelector(to)).first()
|
||||
const directory = created.directory
|
||||
const slug = dirSlug(directory)
|
||||
project.trackDirectory(directory)
|
||||
|
||||
const a = await src.boundingBox()
|
||||
const b = await dst.boundingBox()
|
||||
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
|
||||
await page.reload()
|
||||
await openSidebar(page)
|
||||
await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible({ timeout: 60_000 })
|
||||
|
||||
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
|
||||
await page.mouse.up()
|
||||
}
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const worktrees = await project.sdk.worktree
|
||||
.list()
|
||||
.then((r) => r.data ?? [])
|
||||
.catch(() => [] as string[])
|
||||
return worktrees.includes(directory)
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
try {
|
||||
await openSidebar(page)
|
||||
const menu = await openWorkspaceMenu(page, slug)
|
||||
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
||||
await confirmDialog(page, /^Delete workspace$/i)
|
||||
|
||||
await setWorkspacesEnabled(page, rootSlug, true)
|
||||
await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
|
||||
|
||||
for (const _ of [0, 1]) {
|
||||
const prev = slugFromUrl(page.url())
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
|
||||
await waitDir(page, next.directory)
|
||||
workspaces.push(next)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const worktrees = await project.sdk.worktree
|
||||
.list()
|
||||
.then((r) => r.data ?? [])
|
||||
.catch(() => [] as string[])
|
||||
return worktrees.includes(directory)
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(false)
|
||||
|
||||
await openSidebar(page)
|
||||
}
|
||||
|
||||
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)))
|
||||
}
|
||||
})
|
||||
await openSidebar(page)
|
||||
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
|
||||
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
|
||||
})
|
||||
|
||||
15
packages/app/e2e/prompt/mock.ts
Normal file
15
packages/app/e2e/prompt/mock.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
type Hit = { body: Record<string, unknown> }
|
||||
|
||||
export function bodyText(hit: Hit) {
|
||||
return JSON.stringify(hit.body)
|
||||
}
|
||||
|
||||
/**
|
||||
* Match requests whose body contains the exact serialized tool input.
|
||||
* 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 inputMatch(input: unknown) {
|
||||
const escaped = JSON.stringify(JSON.stringify(input)).slice(1, -1)
|
||||
return (hit: Hit) => bodyText(hit).includes(escaped)
|
||||
}
|
||||
@@ -1,47 +1,25 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
|
||||
import { assistantText, withSession } from "../actions"
|
||||
|
||||
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||
|
||||
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
|
||||
// the connection open while the agent works, causing "Failed to fetch" over
|
||||
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
|
||||
test("prompt succeeds when sync message endpoint is unreachable", async ({ page, sdk, gotoSession }) => {
|
||||
test("prompt succeeds when sync message endpoint is unreachable", async ({ page, project, assistant }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
// Simulate Tailscale/VPN killing the long-lived sync connection
|
||||
await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
|
||||
|
||||
await gotoSession()
|
||||
|
||||
const token = `E2E_ASYNC_${Date.now()}`
|
||||
await page.locator(promptSelector).click()
|
||||
await page.keyboard.type(`Reply with exactly: ${token}`)
|
||||
await page.keyboard.press("Enter")
|
||||
await project.open()
|
||||
await assistant.reply(token)
|
||||
const sessionID = await project.prompt(`Reply with exactly: ${token}`)
|
||||
|
||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||
const sessionID = sessionIDFromUrl(page.url())!
|
||||
|
||||
try {
|
||||
// Agent response arrives via SSE despite sync endpoint being dead
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||
return messages
|
||||
.filter((m) => m.info.role === "assistant")
|
||||
.flatMap((m) => m.parts)
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("\n")
|
||||
},
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
.toContain(token)
|
||||
} finally {
|
||||
await cleanupSession({ sdk, sessionID })
|
||||
}
|
||||
await expect.poll(() => assistant.calls()).toBeGreaterThanOrEqual(1)
|
||||
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 90_000 }).toContain(token)
|
||||
})
|
||||
|
||||
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
|
||||
|
||||
88
packages/app/e2e/prompt/prompt-footer-focus.spec.ts
Normal file
88
packages/app/e2e/prompt/prompt-footer-focus.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
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,10 +1,12 @@
|
||||
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { withSession } from "../actions"
|
||||
import { assistantText } from "../actions"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { createSdk } from "../utils"
|
||||
|
||||
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||
type Sdk = ReturnType<typeof createSdk>
|
||||
|
||||
const isBash = (part: unknown): part is ToolPart => {
|
||||
if (!part || typeof part !== "object") return false
|
||||
@@ -13,54 +15,15 @@ const isBash = (part: unknown): part is ToolPart => {
|
||||
return "state" in part
|
||||
}
|
||||
|
||||
async function edge(page: Page, pos: "start" | "end") {
|
||||
await page.locator(promptSelector).evaluate((el: HTMLDivElement, pos: "start" | "end") => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection) return
|
||||
|
||||
const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
|
||||
const nodes: Text[] = []
|
||||
for (let node = walk.nextNode(); node; node = walk.nextNode()) {
|
||||
nodes.push(node as Text)
|
||||
}
|
||||
|
||||
if (nodes.length === 0) {
|
||||
const node = document.createTextNode("")
|
||||
el.appendChild(node)
|
||||
nodes.push(node)
|
||||
}
|
||||
|
||||
const node = pos === "start" ? nodes[0]! : nodes[nodes.length - 1]!
|
||||
const range = document.createRange()
|
||||
range.setStart(node, pos === "start" ? 0 : (node.textContent ?? "").length)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}, pos)
|
||||
}
|
||||
|
||||
async function wait(page: Page, value: string) {
|
||||
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
|
||||
}
|
||||
|
||||
async function reply(sdk: Parameters<typeof withSession>[0], sessionID: string, token: string) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||
return messages
|
||||
.filter((item) => item.info.role === "assistant")
|
||||
.flatMap((item) => item.parts)
|
||||
.filter((item) => item.type === "text")
|
||||
.map((item) => item.text)
|
||||
.join("\n")
|
||||
},
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
.toContain(token)
|
||||
async function reply(sdk: Sdk, sessionID: string, token: string) {
|
||||
await expect.poll(() => assistantText(sdk, sessionID), { timeout: 90_000 }).toContain(token)
|
||||
}
|
||||
|
||||
async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string, cmd: string, token: string) {
|
||||
async function shell(sdk: Sdk, sessionID: string, cmd: string, token: string) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
@@ -79,106 +42,105 @@ async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string,
|
||||
.toContain(token)
|
||||
}
|
||||
|
||||
test("prompt history restores unsent draft with arrow navigation", async ({ page, sdk, gotoSession }) => {
|
||||
test("prompt history restores unsent draft with arrow navigation", async ({ page, project, assistant }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
await withSession(sdk, `e2e prompt history ${Date.now()}`, async (session) => {
|
||||
await gotoSession(session.id)
|
||||
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()}`
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
|
||||
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
|
||||
const first = `Reply with exactly: ${firstToken}`
|
||||
const second = `Reply with exactly: ${secondToken}`
|
||||
const draft = `draft ${Date.now()}`
|
||||
await project.open()
|
||||
await assistant.reply(firstToken)
|
||||
const sessionID = await project.prompt(first)
|
||||
await wait(page, "")
|
||||
await reply(project.sdk, sessionID, firstToken)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type(first)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await reply(sdk, session.id, firstToken)
|
||||
await assistant.reply(secondToken)
|
||||
await project.prompt(second)
|
||||
await wait(page, "")
|
||||
await reply(project.sdk, sessionID, secondToken)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type(second)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await reply(sdk, session.id, secondToken)
|
||||
const prompt = page.locator(promptSelector)
|
||||
await prompt.click()
|
||||
await page.keyboard.type(draft)
|
||||
await wait(page, draft)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type(draft)
|
||||
await wait(page, draft)
|
||||
await prompt.fill("")
|
||||
await wait(page, "")
|
||||
|
||||
// Clear the draft before navigating history (ArrowUp only works when prompt is empty)
|
||||
await prompt.fill("")
|
||||
await wait(page, "")
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, second)
|
||||
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, second)
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, first)
|
||||
|
||||
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, second)
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, "")
|
||||
})
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, "")
|
||||
})
|
||||
|
||||
test("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
|
||||
test.fixme("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
await withSession(sdk, `e2e shell history ${Date.now()}`, async (session) => {
|
||||
await gotoSession(session.id)
|
||||
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
|
||||
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
|
||||
const normalToken = `E2E_NORMAL_${Date.now()}`
|
||||
const first = `echo ${firstToken}`
|
||||
const second = `echo ${secondToken}`
|
||||
const normal = `Reply with exactly: ${normalToken}`
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
|
||||
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
|
||||
const normalToken = `E2E_NORMAL_${Date.now()}`
|
||||
const first = `echo ${firstToken}`
|
||||
const second = `echo ${secondToken}`
|
||||
const normal = `Reply with exactly: ${normalToken}`
|
||||
await gotoSession()
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type("!")
|
||||
await page.keyboard.type(first)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await shell(sdk, session.id, first, firstToken)
|
||||
const prompt = page.locator(promptSelector)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type("!")
|
||||
await page.keyboard.type(second)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await shell(sdk, session.id, second, secondToken)
|
||||
await prompt.click()
|
||||
await page.keyboard.type("!")
|
||||
await page.keyboard.type(first)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type("!")
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, second)
|
||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||
const sessionID = sessionIDFromUrl(page.url())!
|
||||
await shell(sdk, sessionID, first, firstToken)
|
||||
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, first)
|
||||
await prompt.click()
|
||||
await page.keyboard.type("!")
|
||||
await page.keyboard.type(second)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await shell(sdk, sessionID, second, secondToken)
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, second)
|
||||
await page.keyboard.press("Escape")
|
||||
await wait(page, "")
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, "")
|
||||
await prompt.click()
|
||||
await page.keyboard.type("!")
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, second)
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await wait(page, "")
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, first)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type(normal)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await reply(sdk, session.id, normalToken)
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, second)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, normal)
|
||||
})
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, "")
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await wait(page, "")
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type(normal)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await reply(sdk, sessionID, normalToken)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, normal)
|
||||
})
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { sessionIDFromUrl } from "../actions"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { createSdk } from "../utils"
|
||||
import { withSession } from "../actions"
|
||||
import { promptModelSelector, promptSelector, promptVariantSelector } from "../selectors"
|
||||
|
||||
const isBash = (part: unknown): part is ToolPart => {
|
||||
if (!part || typeof part !== "object") return false
|
||||
@@ -11,34 +10,31 @@ const isBash = (part: unknown): part is ToolPart => {
|
||||
return "state" in part
|
||||
}
|
||||
|
||||
test("shell mode runs a command in the project directory", async ({ page, withProject }) => {
|
||||
test("shell mode runs a command in the project directory", async ({ page, project }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
await withProject(async ({ directory, gotoSession, trackSession }) => {
|
||||
const sdk = createSdk(directory)
|
||||
const prompt = page.locator(promptSelector)
|
||||
const cmd = process.platform === "win32" ? "dir" : "ls"
|
||||
await project.open()
|
||||
const cmd = process.platform === "win32" ? "dir" : "command ls"
|
||||
|
||||
await gotoSession()
|
||||
await prompt.click()
|
||||
await page.keyboard.type("!")
|
||||
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
|
||||
|
||||
await page.keyboard.type(cmd)
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||
|
||||
const id = sessionIDFromUrl(page.url())
|
||||
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||
trackSession(id, directory)
|
||||
await withSession(project.sdk, `e2e shell ${Date.now()}`, async (session) => {
|
||||
project.trackSession(session.id)
|
||||
await project.gotoSession(session.id)
|
||||
const button = page.locator('[data-action="prompt-permissions"]').first()
|
||||
await expect(button).toBeVisible()
|
||||
if ((await button.getAttribute("aria-pressed")) !== "true") {
|
||||
await button.click()
|
||||
await expect(button).toHaveAttribute("aria-pressed", "true")
|
||||
}
|
||||
await project.shell(cmd)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const list = await sdk.session.messages({ sessionID: id, limit: 50 }).then((x) => x.data ?? [])
|
||||
const list = await project.sdk.session
|
||||
.messages({ sessionID: session.id, limit: 50 })
|
||||
.then((x) => x.data ?? [])
|
||||
const msg = list.findLast(
|
||||
(item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === directory,
|
||||
(item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === project.directory,
|
||||
)
|
||||
if (!msg) return
|
||||
|
||||
@@ -51,12 +47,25 @@ test("shell mode runs a command in the project directory", async ({ page, withPr
|
||||
typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
|
||||
if (!output.includes("README.md")) return
|
||||
|
||||
return { cwd: directory, output }
|
||||
return { cwd: project.directory, output }
|
||||
},
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
.toEqual(expect.objectContaining({ cwd: directory, output: expect.stringContaining("README.md") }))
|
||||
|
||||
await expect(prompt).toHaveText("")
|
||||
.toEqual(expect.objectContaining({ cwd: project.directory, output: expect.stringContaining("README.md") }))
|
||||
})
|
||||
})
|
||||
|
||||
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,14 +22,16 @@ async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
|
||||
.toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
test("/share and /unshare update session share state", async ({ page, sdk, gotoSession }) => {
|
||||
test("/share and /unshare update session share state", async ({ page, project }) => {
|
||||
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
||||
|
||||
await withSession(sdk, `e2e slash share ${Date.now()}`, async (session) => {
|
||||
await project.open()
|
||||
await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => {
|
||||
project.trackSession(session.id)
|
||||
const prompt = page.locator(promptSelector)
|
||||
|
||||
await seed(sdk, session.id)
|
||||
await gotoSession(session.id)
|
||||
await seed(project.sdk, session.id)
|
||||
await project.gotoSession(session.id)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type("/share")
|
||||
@@ -39,7 +41,7 @@ test("/share and /unshare update session share state", async ({ page, sdk, gotoS
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await 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
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
@@ -54,7 +56,7 @@ test("/share and /unshare update session share state", async ({ page, sdk, gotoS
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await 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
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
|
||||
import { assistantText } from "../actions"
|
||||
|
||||
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
|
||||
test("can send a prompt and receive a reply", async ({ page, project, assistant }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
const pageErrors: string[] = []
|
||||
@@ -11,42 +10,16 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession })
|
||||
}
|
||||
page.on("pageerror", onPageError)
|
||||
|
||||
await gotoSession()
|
||||
|
||||
const token = `E2E_OK_${Date.now()}`
|
||||
|
||||
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
|
||||
})()
|
||||
|
||||
try {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||
return messages
|
||||
.filter((m) => m.info.role === "assistant")
|
||||
.flatMap((m) => m.parts)
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("\n")
|
||||
},
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
const token = `E2E_OK_${Date.now()}`
|
||||
await project.open()
|
||||
await assistant.reply(token)
|
||||
const sessionID = await project.prompt(`Reply with exactly: ${token}`)
|
||||
|
||||
.toContain(token)
|
||||
await expect.poll(() => assistant.calls()).toBeGreaterThanOrEqual(1)
|
||||
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 30_000 }).toContain(token)
|
||||
} finally {
|
||||
page.off("pageerror", onPageError)
|
||||
await cleanupSession({ sdk, sessionID })
|
||||
}
|
||||
|
||||
if (pageErrors.length > 0) {
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
export const promptSelector = '[data-component="prompt-input"]'
|
||||
export const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]'
|
||||
const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]'
|
||||
export const terminalSelector = `${terminalPanelSelector} [data-component="terminal"]`
|
||||
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
|
||||
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
|
||||
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
|
||||
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 sessionTodoListSelector = '[data-slot="session-todo-list"]'
|
||||
|
||||
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
|
||||
export const promptAgentSelector = '[data-component="prompt-agent-control"]'
|
||||
@@ -30,7 +24,7 @@ export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-error
|
||||
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
|
||||
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
|
||||
|
||||
export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
|
||||
const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
|
||||
|
||||
export const projectSwitchSelector = (slug: string) =>
|
||||
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
|
||||
@@ -40,9 +34,6 @@ export const projectMenuTriggerSelector = (slug: string) =>
|
||||
|
||||
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) =>
|
||||
`[data-action="project-workspaces-toggle"][data-project="${slug}"]`
|
||||
|
||||
@@ -50,8 +41,6 @@ export const titlebarRightSelector = "#opencode-titlebar-right"
|
||||
|
||||
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 inlineInputSelector = '[data-component="inline-input"]'
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { seedSessionTask, withSession } from "../actions"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { inputMatch } from "../prompt/mock"
|
||||
import { promptSelector } from "../selectors"
|
||||
|
||||
test("task tool child-session link does not trigger stale show errors", async ({ page, sdk, gotoSession }) => {
|
||||
test("task tool child-session link does not trigger stale show errors", async ({ page, llm, project }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
const errs: string[] = []
|
||||
@@ -10,15 +12,23 @@ test("task tool child-session link does not trigger stale show errors", async ({
|
||||
}
|
||||
page.on("pageerror", onError)
|
||||
|
||||
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
|
||||
const child = await seedSessionTask(sdk, {
|
||||
sessionID: session.id,
|
||||
description: "Open child session",
|
||||
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
|
||||
})
|
||||
try {
|
||||
await project.open()
|
||||
await withSession(project.sdk, `e2e child nav ${Date.now()}`, async (session) => {
|
||||
const taskInput = {
|
||||
description: "Open child session",
|
||||
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)
|
||||
|
||||
try {
|
||||
await gotoSession(session.id)
|
||||
await project.gotoSession(session.id)
|
||||
|
||||
const link = page
|
||||
.locator("a.subagent-link")
|
||||
@@ -28,10 +38,10 @@ test("task tool child-session link does not trigger stale show errors", async ({
|
||||
await link.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
|
||||
await page.waitForTimeout(1000)
|
||||
expect(errs).toEqual([])
|
||||
} finally {
|
||||
page.off("pageerror", onError)
|
||||
}
|
||||
})
|
||||
await expect(page.locator(promptSelector)).toBeVisible({ timeout: 30_000 })
|
||||
await expect.poll(() => errs, { timeout: 5_000 }).toEqual([])
|
||||
})
|
||||
} finally {
|
||||
page.off("pageerror", onError)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
sessionComposerDockSelector,
|
||||
sessionTodoToggleButtonSelector,
|
||||
} from "../selectors"
|
||||
import { modKey } from "../utils"
|
||||
import { inputMatch } from "../prompt/mock"
|
||||
|
||||
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
|
||||
type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
|
||||
@@ -21,12 +23,13 @@ async function withDockSession<T>(
|
||||
sdk: Sdk,
|
||||
title: string,
|
||||
fn: (session: { id: string; title: string }) => Promise<T>,
|
||||
opts?: { permission?: PermissionRule[] },
|
||||
opts?: { permission?: PermissionRule[]; trackSession?: (sessionID: string) => void },
|
||||
) {
|
||||
const session = await sdk.session
|
||||
.create(opts?.permission ? { title, permission: opts.permission } : { title })
|
||||
.then((r) => r.data)
|
||||
if (!session?.id) throw new Error("Session create did not return an id")
|
||||
opts?.trackSession?.(session.id)
|
||||
try {
|
||||
return await fn(session)
|
||||
} finally {
|
||||
@@ -34,6 +37,17 @@ 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)
|
||||
|
||||
async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>) {
|
||||
@@ -228,9 +242,7 @@ async function withMockPermission<T>(
|
||||
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)
|
||||
await route.fulfill({
|
||||
status: res.status(),
|
||||
headers: res.headers(),
|
||||
contentType: "application/json",
|
||||
response: res,
|
||||
body: JSON.stringify(json),
|
||||
})
|
||||
}
|
||||
@@ -255,22 +267,28 @@ async function withMockPermission<T>(
|
||||
}
|
||||
}
|
||||
|
||||
test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock default", async (session) => {
|
||||
await gotoSession(session.id)
|
||||
test("default dock shows prompt input", async ({ page, project }) => {
|
||||
await project.open()
|
||||
await withDockSession(
|
||||
project.sdk,
|
||||
"e2e composer dock default",
|
||||
async (session) => {
|
||||
await project.gotoSession(session.id)
|
||||
|
||||
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await expect(page.locator(questionDockSelector)).toHaveCount(0)
|
||||
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
|
||||
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await expect(page.locator(questionDockSelector)).toHaveCount(0)
|
||||
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
|
||||
|
||||
await page.locator(promptSelector).click()
|
||||
await expect(page.locator(promptSelector)).toBeFocused()
|
||||
})
|
||||
await page.locator(promptSelector).click()
|
||||
await expect(page.locator(promptSelector)).toBeFocused()
|
||||
},
|
||||
{ trackSession: project.trackSession },
|
||||
)
|
||||
})
|
||||
|
||||
test("auto-accept toggle works before first submit", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
test("auto-accept toggle works before first submit", async ({ page, project }) => {
|
||||
await project.open()
|
||||
|
||||
const button = page.locator('[data-action="prompt-permissions"]').first()
|
||||
await expect(button).toBeVisible()
|
||||
@@ -280,146 +298,19 @@ test("auto-accept toggle works before first submit", async ({ page, gotoSession
|
||||
await setAutoAccept(page, false)
|
||||
})
|
||||
|
||||
test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock question", async (session) => {
|
||||
await withDockSeed(sdk, session.id, async () => {
|
||||
await gotoSession(session.id)
|
||||
test("blocked question flow unblocks after submit", async ({ page, llm, project }) => {
|
||||
await project.open()
|
||||
await withDockSession(
|
||||
project.sdk,
|
||||
"e2e composer dock question",
|
||||
async (session) => {
|
||||
await withDockSeed(project.sdk, session.id, async () => {
|
||||
await project.gotoSession(session.id)
|
||||
|
||||
await seedSessionQuestion(sdk, {
|
||||
sessionID: session.id,
|
||||
questions: [
|
||||
{
|
||||
header: "Need input",
|
||||
question: "Pick one option",
|
||||
options: [
|
||||
{ label: "Continue", description: "Continue now" },
|
||||
{ label: "Stop", description: "Stop here" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const dock = page.locator(questionDockSelector)
|
||||
await expectQuestionBlocked(page)
|
||||
|
||||
await dock.locator('[data-slot="question-option"]').first().click()
|
||||
await dock.getByRole("button", { name: /submit/i }).click()
|
||||
|
||||
await expectQuestionOpen(page)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
|
||||
await gotoSession(session.id)
|
||||
await setAutoAccept(page, false)
|
||||
await withMockPermission(
|
||||
page,
|
||||
{
|
||||
id: "per_e2e_once",
|
||||
sessionID: session.id,
|
||||
permission: "bash",
|
||||
patterns: ["/tmp/opencode-e2e-perm-once"],
|
||||
metadata: { description: "Need permission for command" },
|
||||
},
|
||||
undefined,
|
||||
async (state) => {
|
||||
await page.goto(page.url())
|
||||
await expectPermissionBlocked(page)
|
||||
|
||||
await clearPermissionDock(page, /allow once/i)
|
||||
await state.resolved()
|
||||
await page.goto(page.url())
|
||||
await expectPermissionOpen(page)
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
|
||||
await gotoSession(session.id)
|
||||
await setAutoAccept(page, false)
|
||||
await withMockPermission(
|
||||
page,
|
||||
{
|
||||
id: "per_e2e_reject",
|
||||
sessionID: session.id,
|
||||
permission: "bash",
|
||||
patterns: ["/tmp/opencode-e2e-perm-reject"],
|
||||
},
|
||||
undefined,
|
||||
async (state) => {
|
||||
await page.goto(page.url())
|
||||
await expectPermissionBlocked(page)
|
||||
|
||||
await clearPermissionDock(page, /deny/i)
|
||||
await state.resolved()
|
||||
await page.goto(page.url())
|
||||
await expectPermissionOpen(page)
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
|
||||
await gotoSession(session.id)
|
||||
await setAutoAccept(page, false)
|
||||
await withMockPermission(
|
||||
page,
|
||||
{
|
||||
id: "per_e2e_always",
|
||||
sessionID: session.id,
|
||||
permission: "bash",
|
||||
patterns: ["/tmp/opencode-e2e-perm-always"],
|
||||
metadata: { description: "Need permission for command" },
|
||||
},
|
||||
undefined,
|
||||
async (state) => {
|
||||
await page.goto(page.url())
|
||||
await expectPermissionBlocked(page)
|
||||
|
||||
await clearPermissionDock(page, /allow always/i)
|
||||
await state.resolved()
|
||||
await page.goto(page.url())
|
||||
await expectPermissionOpen(page)
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test("child session question request blocks parent dock and unblocks after submit", async ({
|
||||
page,
|
||||
sdk,
|
||||
gotoSession,
|
||||
}) => {
|
||||
await withDockSession(sdk, "e2e composer dock child question parent", async (session) => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
const child = await sdk.session
|
||||
.create({
|
||||
title: "e2e composer dock child question",
|
||||
parentID: session.id,
|
||||
})
|
||||
.then((r) => r.data)
|
||||
if (!child?.id) throw new Error("Child session create did not return an id")
|
||||
|
||||
try {
|
||||
await withDockSeed(sdk, child.id, async () => {
|
||||
await seedSessionQuestion(sdk, {
|
||||
sessionID: child.id,
|
||||
questions: [
|
||||
{
|
||||
header: "Child input",
|
||||
question: "Pick one child option",
|
||||
options: [
|
||||
{ label: "Continue", description: "Continue child" },
|
||||
{ label: "Stop", description: "Stop child" },
|
||||
],
|
||||
},
|
||||
],
|
||||
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
|
||||
await seedSessionQuestion(project.sdk, {
|
||||
sessionID: session.id,
|
||||
questions: defaultQuestions,
|
||||
})
|
||||
|
||||
const dock = page.locator(questionDockSelector)
|
||||
@@ -430,40 +321,92 @@ test("child session question request blocks parent dock and unblocks after submi
|
||||
|
||||
await expectQuestionOpen(page)
|
||||
})
|
||||
} finally {
|
||||
await cleanupSession({ sdk, sessionID: child.id })
|
||||
}
|
||||
})
|
||||
},
|
||||
{ trackSession: project.trackSession },
|
||||
)
|
||||
})
|
||||
|
||||
test("child session permission request blocks parent dock and supports allow once", async ({
|
||||
page,
|
||||
sdk,
|
||||
gotoSession,
|
||||
}) => {
|
||||
await withDockSession(sdk, "e2e composer dock child permission parent", async (session) => {
|
||||
await gotoSession(session.id)
|
||||
await setAutoAccept(page, false)
|
||||
test("blocked question flow supports keyboard shortcuts", async ({ page, llm, project }) => {
|
||||
await project.open()
|
||||
await withDockSession(
|
||||
project.sdk,
|
||||
"e2e composer dock question keyboard",
|
||||
async (session) => {
|
||||
await withDockSeed(project.sdk, session.id, async () => {
|
||||
await project.gotoSession(session.id)
|
||||
|
||||
const child = await sdk.session
|
||||
.create({
|
||||
title: "e2e composer dock child permission",
|
||||
parentID: session.id,
|
||||
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
|
||||
await seedSessionQuestion(project.sdk, {
|
||||
sessionID: session.id,
|
||||
questions: defaultQuestions,
|
||||
})
|
||||
|
||||
const dock = page.locator(questionDockSelector)
|
||||
const first = dock.locator('[data-slot="question-option"]').first()
|
||||
const second = dock.locator('[data-slot="question-option"]').nth(1)
|
||||
|
||||
await expectQuestionBlocked(page)
|
||||
await expect(first).toBeFocused()
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await expect(second).toBeFocused()
|
||||
|
||||
await page.keyboard.press("Space")
|
||||
await page.keyboard.press(`${modKey}+Enter`)
|
||||
await expectQuestionOpen(page)
|
||||
})
|
||||
.then((r) => r.data)
|
||||
if (!child?.id) throw new Error("Child session create did not return an id")
|
||||
},
|
||||
{ trackSession: project.trackSession },
|
||||
)
|
||||
})
|
||||
|
||||
try {
|
||||
test("blocked question flow supports escape dismiss", async ({ page, llm, project }) => {
|
||||
await project.open()
|
||||
await withDockSession(
|
||||
project.sdk,
|
||||
"e2e composer dock question escape",
|
||||
async (session) => {
|
||||
await withDockSeed(project.sdk, session.id, async () => {
|
||||
await project.gotoSession(session.id)
|
||||
|
||||
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
|
||||
await seedSessionQuestion(project.sdk, {
|
||||
sessionID: session.id,
|
||||
questions: defaultQuestions,
|
||||
})
|
||||
|
||||
const dock = page.locator(questionDockSelector)
|
||||
const first = dock.locator('[data-slot="question-option"]').first()
|
||||
|
||||
await expectQuestionBlocked(page)
|
||||
await expect(first).toBeFocused()
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expectQuestionOpen(page)
|
||||
})
|
||||
},
|
||||
{ trackSession: project.trackSession },
|
||||
)
|
||||
})
|
||||
|
||||
test("blocked permission flow supports allow once", async ({ page, project }) => {
|
||||
await project.open()
|
||||
await withDockSession(
|
||||
project.sdk,
|
||||
"e2e composer dock permission once",
|
||||
async (session) => {
|
||||
await project.gotoSession(session.id)
|
||||
await setAutoAccept(page, false)
|
||||
await withMockPermission(
|
||||
page,
|
||||
{
|
||||
id: "per_e2e_child",
|
||||
sessionID: child.id,
|
||||
id: "per_e2e_once",
|
||||
sessionID: session.id,
|
||||
permission: "bash",
|
||||
patterns: ["/tmp/opencode-e2e-perm-child"],
|
||||
metadata: { description: "Need child permission" },
|
||||
patterns: ["/tmp/opencode-e2e-perm-once"],
|
||||
metadata: { description: "Need permission for command" },
|
||||
},
|
||||
{ child },
|
||||
undefined,
|
||||
async (state) => {
|
||||
await page.goto(page.url())
|
||||
await expectPermissionBlocked(page)
|
||||
@@ -471,67 +414,243 @@ test("child session permission request blocks parent dock and supports allow onc
|
||||
await clearPermissionDock(page, /allow once/i)
|
||||
await state.resolved()
|
||||
await page.goto(page.url())
|
||||
|
||||
await expectPermissionOpen(page)
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
await cleanupSession({ sdk, sessionID: child.id })
|
||||
}
|
||||
})
|
||||
},
|
||||
{ trackSession: project.trackSession },
|
||||
)
|
||||
})
|
||||
|
||||
test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock todo", async (session) => {
|
||||
const dock = await todoDock(page, session.id)
|
||||
await gotoSession(session.id)
|
||||
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
||||
test("blocked permission flow supports reject", async ({ page, project }) => {
|
||||
await project.open()
|
||||
await withDockSession(
|
||||
project.sdk,
|
||||
"e2e composer dock permission reject",
|
||||
async (session) => {
|
||||
await project.gotoSession(session.id)
|
||||
await setAutoAccept(page, false)
|
||||
await withMockPermission(
|
||||
page,
|
||||
{
|
||||
id: "per_e2e_reject",
|
||||
sessionID: session.id,
|
||||
permission: "bash",
|
||||
patterns: ["/tmp/opencode-e2e-perm-reject"],
|
||||
},
|
||||
undefined,
|
||||
async (state) => {
|
||||
await page.goto(page.url())
|
||||
await expectPermissionBlocked(page)
|
||||
|
||||
try {
|
||||
await dock.open([
|
||||
{ content: "first task", status: "pending", priority: "high" },
|
||||
{ content: "second task", status: "in_progress", priority: "medium" },
|
||||
])
|
||||
await dock.expectOpen(["pending", "in_progress"])
|
||||
|
||||
await dock.collapse()
|
||||
await dock.expectCollapsed(["pending", "in_progress"])
|
||||
|
||||
await dock.expand()
|
||||
await dock.expectOpen(["pending", "in_progress"])
|
||||
|
||||
await dock.finish([
|
||||
{ content: "first task", status: "completed", priority: "high" },
|
||||
{ content: "second task", status: "cancelled", priority: "medium" },
|
||||
])
|
||||
await dock.expectClosed()
|
||||
} finally {
|
||||
await dock.clear()
|
||||
}
|
||||
})
|
||||
await clearPermissionDock(page, /deny/i)
|
||||
await state.resolved()
|
||||
await page.goto(page.url())
|
||||
await expectPermissionOpen(page)
|
||||
},
|
||||
)
|
||||
},
|
||||
{ trackSession: project.trackSession },
|
||||
)
|
||||
})
|
||||
|
||||
test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock keyboard", async (session) => {
|
||||
await withDockSeed(sdk, session.id, async () => {
|
||||
await gotoSession(session.id)
|
||||
test("blocked permission flow supports allow always", async ({ page, project }) => {
|
||||
await project.open()
|
||||
await withDockSession(
|
||||
project.sdk,
|
||||
"e2e composer dock permission always",
|
||||
async (session) => {
|
||||
await project.gotoSession(session.id)
|
||||
await setAutoAccept(page, false)
|
||||
await withMockPermission(
|
||||
page,
|
||||
{
|
||||
id: "per_e2e_always",
|
||||
sessionID: session.id,
|
||||
permission: "bash",
|
||||
patterns: ["/tmp/opencode-e2e-perm-always"],
|
||||
metadata: { description: "Need permission for command" },
|
||||
},
|
||||
undefined,
|
||||
async (state) => {
|
||||
await page.goto(page.url())
|
||||
await expectPermissionBlocked(page)
|
||||
|
||||
await seedSessionQuestion(sdk, {
|
||||
sessionID: session.id,
|
||||
questions: [
|
||||
await clearPermissionDock(page, /allow always/i)
|
||||
await state.resolved()
|
||||
await page.goto(page.url())
|
||||
await expectPermissionOpen(page)
|
||||
},
|
||||
)
|
||||
},
|
||||
{ trackSession: project.trackSession },
|
||||
)
|
||||
})
|
||||
|
||||
test("child session question request blocks parent dock and unblocks after submit", async ({ page, llm, project }) => {
|
||||
const questions = [
|
||||
{
|
||||
header: "Child input",
|
||||
question: "Pick one child option",
|
||||
options: [
|
||||
{ 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)
|
||||
|
||||
const child = await project.sdk.session
|
||||
.create({
|
||||
title: "e2e composer dock child question",
|
||||
parentID: session.id,
|
||||
})
|
||||
.then((r) => r.data)
|
||||
if (!child?.id) throw new Error("Child session create did not return an id")
|
||||
project.trackSession(child.id)
|
||||
|
||||
try {
|
||||
await withDockSeed(project.sdk, child.id, async () => {
|
||||
await llm.toolMatch(inputMatch({ questions }), "question", { questions })
|
||||
await seedSessionQuestion(project.sdk, {
|
||||
sessionID: child.id,
|
||||
questions,
|
||||
})
|
||||
|
||||
const dock = page.locator(questionDockSelector)
|
||||
await expectQuestionBlocked(page)
|
||||
|
||||
await dock.locator('[data-slot="question-option"]').first().click()
|
||||
await dock.getByRole("button", { name: /submit/i }).click()
|
||||
|
||||
await expectQuestionOpen(page)
|
||||
})
|
||||
} finally {
|
||||
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 }) => {
|
||||
await project.open()
|
||||
await withDockSession(
|
||||
project.sdk,
|
||||
"e2e composer dock child permission parent",
|
||||
async (session) => {
|
||||
await project.gotoSession(session.id)
|
||||
await setAutoAccept(page, false)
|
||||
|
||||
const child = await project.sdk.session
|
||||
.create({
|
||||
title: "e2e composer dock child permission",
|
||||
parentID: session.id,
|
||||
})
|
||||
.then((r) => r.data)
|
||||
if (!child?.id) throw new Error("Child session create did not return an id")
|
||||
project.trackSession(child.id)
|
||||
|
||||
try {
|
||||
await withMockPermission(
|
||||
page,
|
||||
{
|
||||
header: "Need input",
|
||||
question: "Pick one option",
|
||||
options: [{ label: "Continue", description: "Continue now" }],
|
||||
id: "per_e2e_child",
|
||||
sessionID: child.id,
|
||||
permission: "bash",
|
||||
patterns: ["/tmp/opencode-e2e-perm-child"],
|
||||
metadata: { description: "Need child permission" },
|
||||
},
|
||||
],
|
||||
})
|
||||
{ child },
|
||||
async (state) => {
|
||||
await page.goto(page.url())
|
||||
await expectPermissionBlocked(page)
|
||||
|
||||
await expectQuestionBlocked(page)
|
||||
await clearPermissionDock(page, /allow once/i)
|
||||
await state.resolved()
|
||||
await page.goto(page.url())
|
||||
|
||||
await page.locator("main").click({ position: { x: 5, y: 5 } })
|
||||
await page.keyboard.type("abc")
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
await expectPermissionOpen(page)
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
await cleanupSession({ sdk: project.sdk, sessionID: child.id })
|
||||
}
|
||||
},
|
||||
{ trackSession: project.trackSession },
|
||||
)
|
||||
})
|
||||
|
||||
test("todo dock transitions and collapse behavior", async ({ page, project }) => {
|
||||
await project.open()
|
||||
await withDockSession(
|
||||
project.sdk,
|
||||
"e2e composer dock todo",
|
||||
async (session) => {
|
||||
const dock = await todoDock(page, session.id)
|
||||
await project.gotoSession(session.id)
|
||||
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
||||
|
||||
try {
|
||||
await dock.open([
|
||||
{ content: "first task", status: "pending", priority: "high" },
|
||||
{ content: "second task", status: "in_progress", priority: "medium" },
|
||||
])
|
||||
await dock.expectOpen(["pending", "in_progress"])
|
||||
|
||||
await dock.collapse()
|
||||
await dock.expectCollapsed(["pending", "in_progress"])
|
||||
|
||||
await dock.expand()
|
||||
await dock.expectOpen(["pending", "in_progress"])
|
||||
|
||||
await dock.finish([
|
||||
{ content: "first task", status: "completed", priority: "high" },
|
||||
{ content: "second task", status: "cancelled", priority: "medium" },
|
||||
])
|
||||
await dock.expectClosed()
|
||||
} finally {
|
||||
await dock.clear()
|
||||
}
|
||||
},
|
||||
{ trackSession: project.trackSession },
|
||||
)
|
||||
})
|
||||
|
||||
test("keyboard focus stays off prompt while blocked", async ({ page, llm, project }) => {
|
||||
const questions = [
|
||||
{
|
||||
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 project.gotoSession(session.id)
|
||||
|
||||
await llm.toolMatch(inputMatch({ questions }), "question", { questions })
|
||||
await seedSessionQuestion(project.sdk, {
|
||||
sessionID: session.id,
|
||||
questions,
|
||||
})
|
||||
|
||||
await expectQuestionBlocked(page)
|
||||
|
||||
await page.locator("main").click({ position: { x: 5, y: 5 } })
|
||||
await page.keyboard.type("abc")
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
})
|
||||
},
|
||||
{ trackSession: project.trackSession },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import type { Locator, Page } from "@playwright/test"
|
||||
import { test, expect } from "../fixtures"
|
||||
import {
|
||||
openSidebar,
|
||||
resolveSlug,
|
||||
sessionIDFromUrl,
|
||||
setWorkspacesEnabled,
|
||||
waitSession,
|
||||
waitSessionIdle,
|
||||
waitSlug,
|
||||
} from "../actions"
|
||||
import { openSidebar, resolveSlug, setWorkspacesEnabled, waitSession, waitSlug } from "../actions"
|
||||
import {
|
||||
promptAgentSelector,
|
||||
promptModelSelector,
|
||||
promptSelector,
|
||||
promptVariantSelector,
|
||||
workspaceItemSelector,
|
||||
workspaceNewSessionSelector,
|
||||
@@ -230,32 +221,8 @@ async function goto(page: Page, directory: string, sessionID?: string) {
|
||||
await waitSession(page, { directory, sessionID })
|
||||
}
|
||||
|
||||
async function submit(page: Page, value: string) {
|
||||
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 submit(project: Parameters<typeof test>[0]["project"], value: string) {
|
||||
return project.prompt(value)
|
||||
}
|
||||
|
||||
async function createWorkspace(page: Page, root: string, seen: string[]) {
|
||||
@@ -298,108 +265,98 @@ async function newWorkspaceSession(page: Page, slug: string) {
|
||||
return waitSession(page, { directory: next.directory }).then((item) => item.directory)
|
||||
}
|
||||
|
||||
test("session model restore per session without leaking into new sessions", async ({ page, withProject }) => {
|
||||
test("session model restore per session without leaking into new sessions", async ({ page, project }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
|
||||
await withProject(async ({ directory, gotoSession, trackSession }) => {
|
||||
await gotoSession()
|
||||
await project.open()
|
||||
await project.gotoSession()
|
||||
|
||||
const firstState = await chooseOtherModel(page)
|
||||
const firstKey = await currentModel(page)
|
||||
const first = await submit(page, `session variant ${Date.now()}`)
|
||||
trackSession(first)
|
||||
await waitUser(directory, first)
|
||||
const firstState = await chooseOtherModel(page)
|
||||
const firstKey = await currentModel(page)
|
||||
const first = await submit(project, `session variant ${Date.now()}`)
|
||||
|
||||
await page.reload()
|
||||
await waitSession(page, { directory, sessionID: first })
|
||||
await waitFooter(page, firstState)
|
||||
await page.reload()
|
||||
await waitSession(page, { directory: project.directory, sessionID: first })
|
||||
await waitFooter(page, firstState)
|
||||
|
||||
await gotoSession()
|
||||
const fresh = await read(page)
|
||||
expect(fresh.model).not.toBe(firstState.model)
|
||||
await project.gotoSession()
|
||||
const fresh = await read(page)
|
||||
expect(fresh.model).not.toBe(firstState.model)
|
||||
|
||||
const secondState = await chooseOtherModel(page, [firstKey])
|
||||
const second = await submit(page, `session model ${Date.now()}`)
|
||||
trackSession(second)
|
||||
await waitUser(directory, second)
|
||||
const secondState = await chooseOtherModel(page, [firstKey])
|
||||
const second = await submit(project, `session model ${Date.now()}`)
|
||||
|
||||
await goto(page, directory, first)
|
||||
await waitFooter(page, firstState)
|
||||
await goto(page, project.directory, first)
|
||||
await waitFooter(page, firstState)
|
||||
|
||||
await goto(page, directory, second)
|
||||
await waitFooter(page, secondState)
|
||||
await goto(page, project.directory, second)
|
||||
await waitFooter(page, secondState)
|
||||
|
||||
await gotoSession()
|
||||
await waitFooter(page, fresh)
|
||||
})
|
||||
await project.gotoSession()
|
||||
await page.reload()
|
||||
await waitSession(page, { directory: project.directory })
|
||||
await waitFooter(page, fresh)
|
||||
})
|
||||
|
||||
test("session model restore across workspaces", async ({ page, withProject }) => {
|
||||
test("session model restore across workspaces", async ({ page, project }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
|
||||
await withProject(async ({ directory: root, slug, gotoSession, trackDirectory, trackSession }) => {
|
||||
await gotoSession()
|
||||
await project.open()
|
||||
const root = project.directory
|
||||
await project.gotoSession()
|
||||
|
||||
const firstState = await chooseOtherModel(page)
|
||||
const firstKey = await currentModel(page)
|
||||
const first = await submit(page, `root session ${Date.now()}`)
|
||||
trackSession(first, root)
|
||||
await waitUser(root, first)
|
||||
const firstState = await chooseOtherModel(page)
|
||||
const firstKey = await currentModel(page)
|
||||
const first = await submit(project, `root session ${Date.now()}`)
|
||||
|
||||
await openSidebar(page)
|
||||
await setWorkspacesEnabled(page, slug, true)
|
||||
await openSidebar(page)
|
||||
await setWorkspacesEnabled(page, project.slug, true)
|
||||
|
||||
const one = await createWorkspace(page, slug, [])
|
||||
const oneDir = await newWorkspaceSession(page, one.slug)
|
||||
trackDirectory(oneDir)
|
||||
const one = await createWorkspace(page, project.slug, [])
|
||||
const oneDir = await newWorkspaceSession(page, one.slug)
|
||||
project.trackDirectory(oneDir)
|
||||
|
||||
const secondState = await chooseOtherModel(page, [firstKey])
|
||||
const secondKey = await currentModel(page)
|
||||
const second = await submit(page, `workspace one ${Date.now()}`)
|
||||
trackSession(second, oneDir)
|
||||
await waitUser(oneDir, second)
|
||||
const secondState = await chooseOtherModel(page, [firstKey])
|
||||
const secondKey = await currentModel(page)
|
||||
const second = await submit(project, `workspace one ${Date.now()}`)
|
||||
|
||||
const two = await createWorkspace(page, slug, [one.slug])
|
||||
const twoDir = await newWorkspaceSession(page, two.slug)
|
||||
trackDirectory(twoDir)
|
||||
const two = await createWorkspace(page, project.slug, [one.slug])
|
||||
const twoDir = await newWorkspaceSession(page, two.slug)
|
||||
project.trackDirectory(twoDir)
|
||||
|
||||
const thirdState = await chooseOtherModel(page, [firstKey, secondKey])
|
||||
const third = await submit(page, `workspace two ${Date.now()}`)
|
||||
trackSession(third, twoDir)
|
||||
await waitUser(twoDir, third)
|
||||
const thirdState = await chooseOtherModel(page, [firstKey, secondKey])
|
||||
const third = await submit(project, `workspace two ${Date.now()}`)
|
||||
|
||||
await goto(page, root, first)
|
||||
await waitFooter(page, firstState)
|
||||
await goto(page, root, first)
|
||||
await waitFooter(page, firstState)
|
||||
|
||||
await goto(page, oneDir, second)
|
||||
await waitFooter(page, secondState)
|
||||
await goto(page, oneDir, second)
|
||||
await waitFooter(page, secondState)
|
||||
|
||||
await goto(page, twoDir, third)
|
||||
await waitFooter(page, thirdState)
|
||||
await goto(page, twoDir, third)
|
||||
await waitFooter(page, thirdState)
|
||||
|
||||
await goto(page, root, first)
|
||||
await waitFooter(page, firstState)
|
||||
})
|
||||
await goto(page, root, first)
|
||||
await waitFooter(page, firstState)
|
||||
})
|
||||
|
||||
test("variant preserved when switching agent modes", async ({ page, withProject }) => {
|
||||
test("variant preserved when switching agent modes", async ({ page, project }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
|
||||
await withProject(async ({ directory, gotoSession }) => {
|
||||
await gotoSession()
|
||||
await project.open()
|
||||
await project.gotoSession()
|
||||
|
||||
await ensureVariant(page, directory)
|
||||
const updated = await chooseDifferentVariant(page)
|
||||
await ensureVariant(page, project.directory)
|
||||
const updated = await chooseDifferentVariant(page)
|
||||
|
||||
const available = await agents(page)
|
||||
const other = available.find((name) => name !== updated.agent)
|
||||
test.skip(!other, "only one agent available")
|
||||
if (!other) return
|
||||
const available = await agents(page)
|
||||
const other = available.find((name) => name !== updated.agent)
|
||||
test.skip(!other, "only one agent available")
|
||||
if (!other) return
|
||||
|
||||
await choose(page, promptAgentSelector, other)
|
||||
await waitFooter(page, { agent: other, variant: updated.variant })
|
||||
await choose(page, promptAgentSelector, other)
|
||||
await waitFooter(page, { agent: other, variant: updated.variant })
|
||||
|
||||
await choose(page, promptAgentSelector, updated.agent)
|
||||
await waitFooter(page, { agent: updated.agent, variant: updated.variant })
|
||||
})
|
||||
await choose(page, promptAgentSelector, updated.agent)
|
||||
await waitFooter(page, { agent: updated.agent, variant: updated.variant })
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { waitSessionIdle, withSession } from "../actions"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { createSdk } from "../utils"
|
||||
import { bodyText } from "../prompt/mock"
|
||||
|
||||
const count = 14
|
||||
|
||||
@@ -40,8 +40,19 @@ function edit(file: string, prev: string, next: string) {
|
||||
)
|
||||
}
|
||||
|
||||
async function patch(sdk: ReturnType<typeof createSdk>, sessionID: string, patchText: string) {
|
||||
await sdk.session.promptAsync({
|
||||
async function patchWithMock(
|
||||
llm: Parameters<typeof test>[0]["llm"],
|
||||
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,
|
||||
agent: "build",
|
||||
system: [
|
||||
@@ -54,7 +65,16 @@ async function patch(sdk: ReturnType<typeof createSdk>, sessionID: string, patch
|
||||
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
|
||||
})
|
||||
|
||||
await waitSessionIdle(sdk, sessionID, 120_000)
|
||||
await expect.poll(() => llm.calls().then((c) => c > callsBefore), { timeout: 30_000 }).toBe(true)
|
||||
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"]) {
|
||||
@@ -233,7 +253,7 @@ async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
|
||||
}
|
||||
}
|
||||
|
||||
test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
|
||||
test("review applies inline comment clicks without horizontal overflow", async ({ page, llm, project }) => {
|
||||
test.setTimeout(180_000)
|
||||
|
||||
const tag = `review-comment-${Date.now()}`
|
||||
@@ -242,47 +262,45 @@ test("review applies inline comment clicks without horizontal overflow", async (
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
|
||||
await withProject(async (project) => {
|
||||
const sdk = createSdk(project.directory)
|
||||
await project.open()
|
||||
await withSession(project.sdk, `e2e review comment ${tag}`, async (session) => {
|
||||
project.trackSession(session.id)
|
||||
await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
|
||||
|
||||
await withSession(sdk, `e2e review comment ${tag}`, async (session) => {
|
||||
await patch(sdk, session.id, seed([{ file, mark: tag }]))
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||
return diff.length
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(1)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||
return diff.length
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(1)
|
||||
await project.gotoSession(session.id)
|
||||
await show(page)
|
||||
|
||||
await project.gotoSession(session.id)
|
||||
await show(page)
|
||||
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
||||
await expect(tab).toBeVisible()
|
||||
await tab.click()
|
||||
|
||||
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
||||
await expect(tab).toBeVisible()
|
||||
await tab.click()
|
||||
await expand(page)
|
||||
await waitMark(page, file, tag)
|
||||
await comment(page, file, note)
|
||||
|
||||
await expand(page)
|
||||
await waitMark(page, file, tag)
|
||||
await comment(page, file, note)
|
||||
|
||||
await expect
|
||||
.poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||
.toBeLessThanOrEqual(1)
|
||||
await expect
|
||||
.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)
|
||||
})
|
||||
await expect
|
||||
.poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||
.toBeLessThanOrEqual(1)
|
||||
await expect
|
||||
.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, withProject }) => {
|
||||
test("review file comments submit on click without clipping actions", async ({ page, llm, project }) => {
|
||||
test.setTimeout(180_000)
|
||||
|
||||
const tag = `review-file-comment-${Date.now()}`
|
||||
@@ -291,49 +309,46 @@ test("review file comments submit on click without clipping actions", async ({ p
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
|
||||
await withProject(async (project) => {
|
||||
const sdk = createSdk(project.directory)
|
||||
await project.open()
|
||||
await withSession(project.sdk, `e2e review file comment ${tag}`, async (session) => {
|
||||
project.trackSession(session.id)
|
||||
await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
|
||||
|
||||
await withSession(sdk, `e2e review file comment ${tag}`, async (session) => {
|
||||
await patch(sdk, session.id, seed([{ file, mark: tag }]))
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||
return diff.length
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(1)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||
return diff.length
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(1)
|
||||
await project.gotoSession(session.id)
|
||||
await show(page)
|
||||
|
||||
await project.gotoSession(session.id)
|
||||
await show(page)
|
||||
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
||||
await expect(tab).toBeVisible()
|
||||
await tab.click()
|
||||
|
||||
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
||||
await expect(tab).toBeVisible()
|
||||
await tab.click()
|
||||
await expand(page)
|
||||
await waitMark(page, file, tag)
|
||||
await openReviewFile(page, file)
|
||||
await fileComment(page, note)
|
||||
|
||||
await expand(page)
|
||||
await waitMark(page, file, tag)
|
||||
await openReviewFile(page, file)
|
||||
await fileComment(page, note)
|
||||
|
||||
await expect
|
||||
.poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||
.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)
|
||||
})
|
||||
await expect
|
||||
.poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||
.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("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
|
||||
test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
|
||||
test.fixme("review keeps scroll position after a live diff update", async ({ page, llm, project }) => {
|
||||
test.setTimeout(180_000)
|
||||
|
||||
const tag = `review-${Date.now()}`
|
||||
@@ -343,84 +358,83 @@ test("review keeps scroll position after a live diff update", async ({ page, wit
|
||||
|
||||
await page.setViewportSize({ width: 1600, height: 1000 })
|
||||
|
||||
await withProject(async (project) => {
|
||||
const sdk = createSdk(project.directory)
|
||||
await project.open()
|
||||
await withSession(project.sdk, `e2e review ${tag}`, async (session) => {
|
||||
project.trackSession(session.id)
|
||||
await patchWithMock(llm, project.sdk, session.id, seed(list))
|
||||
|
||||
await withSession(sdk, `e2e review ${tag}`, async (session) => {
|
||||
await patch(sdk, session.id, seed(list))
|
||||
await expect
|
||||
.poll(
|
||||
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
|
||||
.poll(
|
||||
async () => {
|
||||
const info = await sdk.session.get({ sessionID: session.id }).then((res) => res.data)
|
||||
return info?.summary?.files ?? 0
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(list.length)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||
return diff.length
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(list.length)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||
return diff.length
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(list.length)
|
||||
await project.gotoSession(session.id)
|
||||
await show(page)
|
||||
|
||||
await project.gotoSession(session.id)
|
||||
await show(page)
|
||||
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
||||
await expect(tab).toBeVisible()
|
||||
await tab.click()
|
||||
|
||||
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
||||
await expect(tab).toBeVisible()
|
||||
await tab.click()
|
||||
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
|
||||
await expect(view).toBeVisible()
|
||||
const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
|
||||
await expect(heads).toHaveCount(list.length, { timeout: 60_000 })
|
||||
|
||||
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
|
||||
await expect(view).toBeVisible()
|
||||
const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
|
||||
await expect(heads).toHaveCount(list.length, {
|
||||
timeout: 60_000,
|
||||
await expand(page)
|
||||
await waitMark(page, hit.file, hit.mark)
|
||||
|
||||
const row = page
|
||||
.getByRole("heading", {
|
||||
level: 3,
|
||||
name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
|
||||
})
|
||||
.first()
|
||||
await expect(row).toBeVisible()
|
||||
await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
|
||||
|
||||
await expand(page)
|
||||
await waitMark(page, hit.file, hit.mark)
|
||||
await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200)
|
||||
const prev = await spot(page, hit.file)
|
||||
if (!prev) throw new Error(`missing review row for ${hit.file}`)
|
||||
|
||||
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 patchWithMock(llm, project.sdk, session.id, edit(hit.file, hit.mark, next))
|
||||
|
||||
await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200)
|
||||
const prev = await spot(page, hit.file)
|
||||
if (!prev) throw new Error(`missing review row for ${hit.file}`)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
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 patch(sdk, session.id, edit(hit.file, hit.mark, next))
|
||||
await waitMark(page, hit.file, next)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||
const item = diff.find((item) => item.file === hit.file)
|
||||
return typeof item?.after === "string" ? item.after : ""
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toContain(`mark ${next}`)
|
||||
|
||||
await waitMark(page, hit.file, next)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const next = await spot(page, hit.file)
|
||||
if (!next) return Number.POSITIVE_INFINITY
|
||||
return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y))
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBeLessThanOrEqual(32)
|
||||
})
|
||||
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 }
|
||||
}
|
||||
|
||||
test("slash undo sets revert and restores prior prompt", async ({ page, withProject }) => {
|
||||
test("slash undo sets revert and restores prior prompt", async ({ page, project }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
const token = `undo_${Date.now()}`
|
||||
|
||||
await withProject(async (project) => {
|
||||
const sdk = createSdk(project.directory)
|
||||
await project.open()
|
||||
const sdk = project.sdk
|
||||
|
||||
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
|
||||
await project.gotoSession(session.id)
|
||||
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
|
||||
project.trackSession(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 page.keyboard.type("/undo")
|
||||
await seeded.prompt.click()
|
||||
await page.keyboard.type("/undo")
|
||||
|
||||
const undo = page.locator('[data-slash-id="session.undo"]').first()
|
||||
await expect(undo).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
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(seeded.userMessageID)
|
||||
await expect
|
||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBe(seeded.userMessageID)
|
||||
|
||||
await expect(seeded.prompt).toContainText(token)
|
||||
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0)
|
||||
})
|
||||
await expect(seeded.prompt).toContainText(token)
|
||||
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test("slash redo clears revert and restores latest state", async ({ page, withProject }) => {
|
||||
test("slash redo clears revert and restores latest state", async ({ page, project }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
const token = `redo_${Date.now()}`
|
||||
|
||||
await withProject(async (project) => {
|
||||
const sdk = createSdk(project.directory)
|
||||
await project.open()
|
||||
const sdk = project.sdk
|
||||
|
||||
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
|
||||
await project.gotoSession(session.id)
|
||||
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
|
||||
project.trackSession(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 page.keyboard.type("/undo")
|
||||
await seeded.prompt.click()
|
||||
await page.keyboard.type("/undo")
|
||||
|
||||
const undo = page.locator('[data-slash-id="session.undo"]').first()
|
||||
await expect(undo).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
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(seeded.userMessageID)
|
||||
await expect
|
||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBe(seeded.userMessageID)
|
||||
|
||||
await seeded.prompt.click()
|
||||
await page.keyboard.press(`${modKey}+A`)
|
||||
await page.keyboard.press("Backspace")
|
||||
await page.keyboard.type("/redo")
|
||||
await seeded.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")
|
||||
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,
|
||||
})
|
||||
.toBeUndefined()
|
||||
await expect
|
||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBeUndefined()
|
||||
|
||||
await expect(seeded.prompt).not.toContainText(token)
|
||||
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1)
|
||||
})
|
||||
await expect(seeded.prompt).not.toContainText(token)
|
||||
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
|
||||
test("slash undo/redo traverses multi-step revert stack", async ({ page, withProject }) => {
|
||||
test("slash undo/redo traverses multi-step revert stack", async ({ page, project }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
const firstToken = `undo_redo_first_${Date.now()}`
|
||||
const secondToken = `undo_redo_second_${Date.now()}`
|
||||
|
||||
await withProject(async (project) => {
|
||||
const sdk = createSdk(project.directory)
|
||||
await project.open()
|
||||
const sdk = project.sdk
|
||||
|
||||
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
|
||||
await project.gotoSession(session.id)
|
||||
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
|
||||
project.trackSession(session.id)
|
||||
await project.gotoSession(session.id)
|
||||
|
||||
const first = await seedConversation({
|
||||
page,
|
||||
sdk,
|
||||
sessionID: session.id,
|
||||
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 first = await seedConversation({
|
||||
page,
|
||||
sdk,
|
||||
sessionID: session.id,
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,14 +31,16 @@ async function seedMessage(sdk: Sdk, sessionID: string) {
|
||||
.toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => {
|
||||
test("session can be renamed via header menu", async ({ page, project }) => {
|
||||
const stamp = Date.now()
|
||||
const originalTitle = `e2e rename test ${stamp}`
|
||||
const renamedTitle = `e2e renamed ${stamp}`
|
||||
|
||||
await withSession(sdk, originalTitle, async (session) => {
|
||||
await seedMessage(sdk, session.id)
|
||||
await gotoSession(session.id)
|
||||
await project.open()
|
||||
await withSession(project.sdk, originalTitle, async (session) => {
|
||||
project.trackSession(session.id)
|
||||
await seedMessage(project.sdk, session.id)
|
||||
await project.gotoSession(session.id)
|
||||
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
|
||||
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
@@ -54,7 +56,7 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await 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
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
@@ -65,20 +67,22 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
|
||||
})
|
||||
})
|
||||
|
||||
test("session can be archived via header menu", async ({ page, sdk, gotoSession }) => {
|
||||
test("session can be archived via header menu", async ({ page, project }) => {
|
||||
const stamp = Date.now()
|
||||
const title = `e2e archive test ${stamp}`
|
||||
|
||||
await withSession(sdk, title, async (session) => {
|
||||
await seedMessage(sdk, session.id)
|
||||
await gotoSession(session.id)
|
||||
await project.open()
|
||||
await withSession(project.sdk, title, async (session) => {
|
||||
project.trackSession(session.id)
|
||||
await seedMessage(project.sdk, session.id)
|
||||
await project.gotoSession(session.id)
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
await clickMenuItem(menu, /archive/i)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await 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
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
@@ -90,13 +94,15 @@ test("session can be archived via header menu", async ({ page, sdk, gotoSession
|
||||
})
|
||||
})
|
||||
|
||||
test("session can be deleted via header menu", async ({ page, sdk, gotoSession }) => {
|
||||
test("session can be deleted via header menu", async ({ page, project }) => {
|
||||
const stamp = Date.now()
|
||||
const title = `e2e delete test ${stamp}`
|
||||
|
||||
await withSession(sdk, title, async (session) => {
|
||||
await seedMessage(sdk, session.id)
|
||||
await gotoSession(session.id)
|
||||
await project.open()
|
||||
await withSession(project.sdk, title, async (session) => {
|
||||
project.trackSession(session.id)
|
||||
await seedMessage(project.sdk, session.id)
|
||||
await project.gotoSession(session.id)
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
await clickMenuItem(menu, /delete/i)
|
||||
await confirmDialog(page, /delete/i)
|
||||
@@ -104,7 +110,7 @@ test("session can be deleted via header menu", async ({ page, sdk, gotoSession }
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session
|
||||
const data = await project.sdk.session
|
||||
.get({ sessionID: session.id })
|
||||
.then((r) => r.data)
|
||||
.catch(() => undefined)
|
||||
@@ -119,15 +125,17 @@ test("session can be deleted via header menu", async ({ page, sdk, gotoSession }
|
||||
})
|
||||
})
|
||||
|
||||
test("session can be shared and unshared via header button", async ({ page, sdk, gotoSession }) => {
|
||||
test("session can be shared and unshared via header button", async ({ page, project }) => {
|
||||
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
||||
|
||||
const stamp = Date.now()
|
||||
const title = `e2e share test ${stamp}`
|
||||
|
||||
await withSession(sdk, title, async (session) => {
|
||||
await seedMessage(sdk, session.id)
|
||||
await gotoSession(session.id)
|
||||
await project.open()
|
||||
await withSession(project.sdk, title, async (session) => {
|
||||
project.trackSession(session.id)
|
||||
await project.gotoSession(session.id)
|
||||
await project.prompt(`share seed ${stamp}`)
|
||||
|
||||
const shared = await openSharePopover(page)
|
||||
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
|
||||
@@ -141,7 +149,7 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await 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
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
@@ -159,7 +167,7 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await 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
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
|
||||
@@ -88,10 +88,20 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
|
||||
return document.documentElement.getAttribute("data-theme")
|
||||
})
|
||||
const currentTheme = (await select.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
|
||||
|
||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||
|
||||
const trigger = select.locator('[data-slot="select-select-trigger"]')
|
||||
const items = page.locator('[data-slot="select-select-item"]')
|
||||
|
||||
await trigger.click()
|
||||
const open = await expect
|
||||
.poll(async () => (await items.count()) > 0, { timeout: 5_000 })
|
||||
.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()
|
||||
expect(count).toBeGreaterThan(1)
|
||||
|
||||
|
||||
@@ -48,70 +48,61 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
|
||||
}
|
||||
})
|
||||
|
||||
test("open sidebar project popover stays closed after clicking avatar", async ({ page, withProject }) => {
|
||||
test("open sidebar project popover stays closed after clicking avatar", async ({ page, project }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const other = await createTestProject()
|
||||
const slug = dirSlug(other)
|
||||
|
||||
try {
|
||||
await withProject(
|
||||
async () => {
|
||||
await openSidebar(page)
|
||||
await project.open({ extra: [other] })
|
||||
await openSidebar(page)
|
||||
|
||||
const project = page.locator(projectSwitchSelector(slug)).first()
|
||||
const card = page.locator('[data-component="hover-card-content"]')
|
||||
const projectButton = page.locator(projectSwitchSelector(slug)).first()
|
||||
const card = page.locator('[data-component="hover-card-content"]')
|
||||
|
||||
await expect(project).toBeVisible()
|
||||
await project.hover()
|
||||
await expect(card.getByText(/recent sessions/i)).toBeVisible()
|
||||
await expect(projectButton).toBeVisible()
|
||||
await projectButton.hover()
|
||||
await expect(card.getByText(/recent sessions/i)).toBeVisible()
|
||||
|
||||
await page.mouse.down()
|
||||
await expect(card).toHaveCount(0)
|
||||
await page.mouse.up()
|
||||
await projectButton.click()
|
||||
await expect(card).toHaveCount(0)
|
||||
|
||||
await waitSession(page, { directory: other })
|
||||
await expect(card).toHaveCount(0)
|
||||
},
|
||||
{ extra: [other] },
|
||||
)
|
||||
await waitSession(page, { directory: other })
|
||||
await expect(card).toHaveCount(0)
|
||||
} finally {
|
||||
await cleanupTestProject(other)
|
||||
}
|
||||
})
|
||||
|
||||
test("open sidebar project switch activates on first tabbed enter", async ({ page, withProject }) => {
|
||||
test("open sidebar project switch activates on first tabbed enter", async ({ page, project }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const other = await createTestProject()
|
||||
const slug = dirSlug(other)
|
||||
|
||||
try {
|
||||
await withProject(
|
||||
async () => {
|
||||
await openSidebar(page)
|
||||
await defocus(page)
|
||||
await project.open({ extra: [other] })
|
||||
await openSidebar(page)
|
||||
await defocus(page)
|
||||
|
||||
const project = page.locator(projectSwitchSelector(slug)).first()
|
||||
const projectButton = page.locator(projectSwitchSelector(slug)).first()
|
||||
|
||||
await expect(project).toBeVisible()
|
||||
await expect(projectButton).toBeVisible()
|
||||
|
||||
let hit = false
|
||||
for (let i = 0; i < 20; i++) {
|
||||
hit = await project.evaluate((el) => {
|
||||
return el.matches(":focus") || !!el.parentElement?.matches(":focus")
|
||||
})
|
||||
if (hit) break
|
||||
await page.keyboard.press("Tab")
|
||||
}
|
||||
let hit = false
|
||||
for (let i = 0; i < 20; i++) {
|
||||
hit = await projectButton.evaluate((el) => {
|
||||
return el.matches(":focus") || !!el.parentElement?.matches(":focus")
|
||||
})
|
||||
if (hit) break
|
||||
await page.keyboard.press("Tab")
|
||||
}
|
||||
|
||||
expect(hit).toBe(true)
|
||||
expect(hit).toBe(true)
|
||||
|
||||
await page.keyboard.press("Enter")
|
||||
await waitSession(page, { directory: other })
|
||||
},
|
||||
{ extra: [other] },
|
||||
)
|
||||
await page.keyboard.press("Enter")
|
||||
await waitSession(page, { directory: other })
|
||||
} finally {
|
||||
await cleanupTestProject(other)
|
||||
}
|
||||
|
||||
@@ -12,35 +12,34 @@ async function open(page: Page) {
|
||||
return term
|
||||
}
|
||||
|
||||
test("terminal reconnects without replacing the pty", async ({ page, withProject }) => {
|
||||
await withProject(async ({ gotoSession }) => {
|
||||
const name = `OPENCODE_E2E_RECONNECT_${Date.now()}`
|
||||
const token = `E2E_RECONNECT_${Date.now()}`
|
||||
test("terminal reconnects without replacing the pty", async ({ page, project }) => {
|
||||
await project.open()
|
||||
const name = `OPENCODE_E2E_RECONNECT_${Date.now()}`
|
||||
const token = `E2E_RECONNECT_${Date.now()}`
|
||||
|
||||
await gotoSession()
|
||||
await project.gotoSession()
|
||||
|
||||
const term = await open(page)
|
||||
const id = await term.getAttribute("data-pty-id")
|
||||
if (!id) throw new Error("Active terminal missing data-pty-id")
|
||||
const term = await open(page)
|
||||
const id = await term.getAttribute("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, {
|
||||
term,
|
||||
cmd: `export ${name}=${token}; echo ${token}`,
|
||||
token,
|
||||
})
|
||||
await runTerminal(page, {
|
||||
term,
|
||||
cmd: `export ${name}=${token}; echo ${token}`,
|
||||
token,
|
||||
})
|
||||
|
||||
await disconnectTerminal(page, { term })
|
||||
await disconnectTerminal(page, { term })
|
||||
|
||||
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(() => terminalConnects(page, { term }), { timeout: 15_000 }).toBeGreaterThan(prev)
|
||||
await expect.poll(() => term.getAttribute("data-pty-id"), { timeout: 5_000 }).toBe(id)
|
||||
|
||||
await runTerminal(page, {
|
||||
term,
|
||||
cmd: `echo $${name}`,
|
||||
token,
|
||||
timeout: 15_000,
|
||||
})
|
||||
await runTerminal(page, {
|
||||
term,
|
||||
cmd: `echo $${name}`,
|
||||
token,
|
||||
timeout: 15_000,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,133 +36,130 @@ async function store(page: Page, key: string) {
|
||||
}, key)
|
||||
}
|
||||
|
||||
test("inactive terminal tab buffers persist across tab switches", async ({ page, withProject }) => {
|
||||
await withProject(async ({ directory, gotoSession }) => {
|
||||
const key = workspacePersistKey(directory, "terminal")
|
||||
const one = `E2E_TERM_ONE_${Date.now()}`
|
||||
const two = `E2E_TERM_TWO_${Date.now()}`
|
||||
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
||||
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
||||
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
||||
test("inactive terminal tab buffers persist across tab switches", async ({ page, project }) => {
|
||||
await project.open()
|
||||
const key = workspacePersistKey(project.directory, "terminal")
|
||||
const one = `E2E_TERM_ONE_${Date.now()}`
|
||||
const two = `E2E_TERM_TWO_${Date.now()}`
|
||||
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
||||
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
||||
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
||||
|
||||
await gotoSession()
|
||||
await open(page)
|
||||
await project.gotoSession()
|
||||
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 expect(tabs).toHaveCount(2)
|
||||
await page.getByRole("button", { name: /new terminal/i }).click()
|
||||
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 expect(first).toHaveAttribute("aria-selected", "true")
|
||||
await first.click()
|
||||
await expect(first).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const state = await store(page, key)
|
||||
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
||||
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
|
||||
return {
|
||||
first: first.includes(one),
|
||||
second: second.includes(two),
|
||||
}
|
||||
},
|
||||
{ timeout: 5_000 },
|
||||
)
|
||||
.toEqual({ first: false, second: true })
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const state = await store(page, key)
|
||||
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
||||
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
|
||||
return {
|
||||
first: first.includes(one),
|
||||
second: second.includes(two),
|
||||
}
|
||||
},
|
||||
{ timeout: 5_000 },
|
||||
)
|
||||
.toEqual({ first: false, second: true })
|
||||
|
||||
await second.click()
|
||||
await expect(second).toHaveAttribute("aria-selected", "true")
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const state = await store(page, key)
|
||||
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
||||
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
|
||||
return {
|
||||
first: first.includes(one),
|
||||
second: second.includes(two),
|
||||
}
|
||||
},
|
||||
{ timeout: 5_000 },
|
||||
)
|
||||
.toEqual({ first: true, second: false })
|
||||
})
|
||||
await second.click()
|
||||
await expect(second).toHaveAttribute("aria-selected", "true")
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const state = await store(page, key)
|
||||
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
||||
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
|
||||
return {
|
||||
first: first.includes(one),
|
||||
second: second.includes(two),
|
||||
}
|
||||
},
|
||||
{ timeout: 5_000 },
|
||||
)
|
||||
.toEqual({ first: true, second: false })
|
||||
})
|
||||
|
||||
test("closing the active terminal tab falls back to the previous tab", async ({ page, withProject }) => {
|
||||
await withProject(async ({ directory, gotoSession }) => {
|
||||
const key = workspacePersistKey(directory, "terminal")
|
||||
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
||||
test("closing the active terminal tab falls back to the previous tab", async ({ page, project }) => {
|
||||
await project.open()
|
||||
const key = workspacePersistKey(project.directory, "terminal")
|
||||
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
||||
|
||||
await gotoSession()
|
||||
await open(page)
|
||||
await project.gotoSession()
|
||||
await open(page)
|
||||
|
||||
await page.getByRole("button", { name: /new terminal/i }).click()
|
||||
await expect(tabs).toHaveCount(2)
|
||||
await page.getByRole("button", { name: /new terminal/i }).click()
|
||||
await expect(tabs).toHaveCount(2)
|
||||
|
||||
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
||||
await second.click()
|
||||
await expect(second).toHaveAttribute("aria-selected", "true")
|
||||
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
||||
await second.click()
|
||||
await expect(second).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
await second.hover()
|
||||
await page
|
||||
.getByRole("button", { name: /close terminal/i })
|
||||
.nth(1)
|
||||
.click({ force: true })
|
||||
await second.hover()
|
||||
await page
|
||||
.getByRole("button", { name: /close terminal/i })
|
||||
.nth(1)
|
||||
.click({ force: true })
|
||||
|
||||
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
||||
await expect(tabs).toHaveCount(1)
|
||||
await expect(first).toHaveAttribute("aria-selected", "true")
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const state = await store(page, key)
|
||||
return {
|
||||
count: state?.all.length ?? 0,
|
||||
first: state?.all.some((item) => item.titleNumber === 1) ?? false,
|
||||
}
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toEqual({ count: 1, first: true })
|
||||
})
|
||||
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
||||
await expect(tabs).toHaveCount(1)
|
||||
await expect(first).toHaveAttribute("aria-selected", "true")
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const state = await store(page, key)
|
||||
return {
|
||||
count: state?.all.length ?? 0,
|
||||
first: state?.all.some((item) => item.titleNumber === 1) ?? false,
|
||||
}
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toEqual({ count: 1, first: true })
|
||||
})
|
||||
|
||||
test("terminal tab can be renamed from the context menu", async ({ page, withProject }) => {
|
||||
await withProject(async ({ directory, gotoSession }) => {
|
||||
const key = workspacePersistKey(directory, "terminal")
|
||||
const rename = `E2E term ${Date.now()}`
|
||||
const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first()
|
||||
test("terminal tab can be renamed from the context menu", async ({ page, project }) => {
|
||||
await project.open()
|
||||
const key = workspacePersistKey(project.directory, "terminal")
|
||||
const rename = `E2E term ${Date.now()}`
|
||||
const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first()
|
||||
|
||||
await gotoSession()
|
||||
await open(page)
|
||||
await project.gotoSession()
|
||||
await open(page)
|
||||
|
||||
await expect(tab).toContainText(/Terminal 1/)
|
||||
await tab.click({ button: "right" })
|
||||
await expect(tab).toContainText(/Terminal 1/)
|
||||
await tab.click({ button: "right" })
|
||||
|
||||
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||
await expect(menu).toBeVisible()
|
||||
await menu.getByRole("menuitem", { name: /^Rename$/i }).click()
|
||||
await expect(menu).toHaveCount(0)
|
||||
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||
await expect(menu).toBeVisible()
|
||||
await menu.getByRole("menuitem", { name: /^Rename$/i }).click()
|
||||
await expect(menu).toHaveCount(0)
|
||||
|
||||
const input = page.locator('#terminal-panel input[type="text"]').first()
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill(rename)
|
||||
await input.press("Enter")
|
||||
const input = page.locator('#terminal-panel input[type="text"]').first()
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill(rename)
|
||||
await input.press("Enter")
|
||||
|
||||
await expect(input).toHaveCount(0)
|
||||
await expect(tab).toContainText(rename)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const state = await store(page, key)
|
||||
return state?.all[0]?.title
|
||||
},
|
||||
{ timeout: 5_000 },
|
||||
)
|
||||
.toBe(rename)
|
||||
})
|
||||
await expect(input).toHaveCount(0)
|
||||
await expect(tab).toContainText(rename)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const state = await store(page, key)
|
||||
return state?.all[0]?.title
|
||||
},
|
||||
{ timeout: 5_000 },
|
||||
)
|
||||
.toBe(rename)
|
||||
})
|
||||
|
||||
@@ -26,21 +26,21 @@ export const serverNamePattern = new RegExp(`(?:${serverNames.map(escape).join("
|
||||
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
|
||||
export const terminalToggleKey = "Control+Backquote"
|
||||
|
||||
export function createSdk(directory?: string) {
|
||||
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
|
||||
export function createSdk(directory?: string, baseUrl = serverUrl) {
|
||||
return createOpencodeClient({ baseUrl, directory, throwOnError: true })
|
||||
}
|
||||
|
||||
export async function resolveDirectory(directory: string) {
|
||||
return createSdk(directory)
|
||||
export async function resolveDirectory(directory: string, baseUrl = serverUrl) {
|
||||
return createSdk(directory, baseUrl)
|
||||
.path.get()
|
||||
.then((x) => x.data?.directory ?? directory)
|
||||
}
|
||||
|
||||
export async function getWorktree() {
|
||||
const sdk = createSdk()
|
||||
export async function getWorktree(baseUrl = serverUrl) {
|
||||
const sdk = createSdk(undefined, baseUrl)
|
||||
const result = await sdk.path.get()
|
||||
const data = result.data
|
||||
if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${serverUrl}/path`)
|
||||
if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${baseUrl}/path`)
|
||||
return data.worktree
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.13",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -46,9 +46,10 @@
|
||||
"@solid-primitives/active-element": "2.1.3",
|
||||
"@solid-primitives/audio": "1.4.2",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"@solid-primitives/event-listener": "2.4.5",
|
||||
"@solid-primitives/i18n": "2.2.1",
|
||||
"@solid-primitives/media": "2.3.3",
|
||||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
"@solid-primitives/resize-observer": "2.1.5",
|
||||
"@solid-primitives/scroll": "2.1.3",
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@solid-primitives/timer": "1.4.4",
|
||||
|
||||
@@ -7,6 +7,11 @@ const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
|
||||
const reuse = !process.env.CI
|
||||
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({
|
||||
testDir: "./e2e",
|
||||
@@ -19,7 +24,7 @@ export default defineConfig({
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers,
|
||||
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
|
||||
reporter,
|
||||
webServer: {
|
||||
command,
|
||||
url: baseURL,
|
||||
|
||||
@@ -71,7 +71,7 @@ const serverEnv = {
|
||||
OPENCODE_E2E_PROJECT_DIR: repoDir,
|
||||
OPENCODE_E2E_SESSION_TITLE: "E2E Session",
|
||||
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e",
|
||||
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano",
|
||||
OPENCODE_E2E_MODEL: process.env.OPENCODE_E2E_MODEL ?? "opencode/gpt-5-nano",
|
||||
OPENCODE_CLIENT: "app",
|
||||
OPENCODE_STRICT_CONFIG_DEPS: "true",
|
||||
} satisfies Record<string, string>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useIsRouting, useLocation } from "@solidjs/router"
|
||||
import { batch, createEffect, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
@@ -349,13 +350,12 @@ export function DebugBar() {
|
||||
|
||||
syncHeap()
|
||||
start()
|
||||
document.addEventListener("visibilitychange", vis)
|
||||
makeEventListener(document, "visibilitychange", vis)
|
||||
|
||||
onCleanup(() => {
|
||||
if (one !== 0) cancelAnimationFrame(one)
|
||||
if (two !== 0) cancelAnimationFrame(two)
|
||||
stop()
|
||||
document.removeEventListener("visibilitychange", vis)
|
||||
for (const ob of obs) ob.disconnect()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -86,6 +86,7 @@ const ModelList: Component<{
|
||||
}
|
||||
|
||||
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
|
||||
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
|
||||
|
||||
export function ModelSelectorPopover(props: {
|
||||
provider?: string
|
||||
@@ -93,25 +94,31 @@ export function ModelSelectorPopover(props: {
|
||||
children?: JSX.Element
|
||||
triggerAs?: ValidComponent
|
||||
triggerProps?: ModelSelectorTriggerProps
|
||||
onClose?: (cause: "escape" | "select") => void
|
||||
}) {
|
||||
const [store, setStore] = createStore<{
|
||||
open: boolean
|
||||
dismiss: "escape" | "outside" | null
|
||||
dismiss: Dismiss | null
|
||||
}>({
|
||||
open: false,
|
||||
dismiss: null,
|
||||
})
|
||||
const dialog = useDialog()
|
||||
|
||||
const handleManage = () => {
|
||||
const close = (dismiss: Dismiss) => {
|
||||
setStore("dismiss", dismiss)
|
||||
setStore("open", false)
|
||||
}
|
||||
|
||||
const handleManage = () => {
|
||||
close("manage")
|
||||
void import("./dialog-manage-models").then((x) => {
|
||||
dialog.show(() => <x.DialogManageModels />)
|
||||
})
|
||||
}
|
||||
|
||||
const handleConnectProvider = () => {
|
||||
setStore("open", false)
|
||||
close("provider")
|
||||
void import("./dialog-select-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectProvider />)
|
||||
})
|
||||
@@ -136,21 +143,19 @@ export function ModelSelectorPopover(props: {
|
||||
<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"
|
||||
onEscapeKeyDown={(event) => {
|
||||
setStore("dismiss", "escape")
|
||||
setStore("open", false)
|
||||
close("escape")
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onPointerDownOutside={() => {
|
||||
setStore("dismiss", "outside")
|
||||
setStore("open", false)
|
||||
}}
|
||||
onFocusOutside={() => {
|
||||
setStore("dismiss", "outside")
|
||||
setStore("open", false)
|
||||
}}
|
||||
onPointerDownOutside={() => close("outside")}
|
||||
onFocusOutside={() => close("outside")}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (store.dismiss === "outside") event.preventDefault()
|
||||
const dismiss = store.dismiss
|
||||
if (dismiss === "outside") event.preventDefault()
|
||||
if (dismiss === "escape" || dismiss === "select") {
|
||||
event.preventDefault()
|
||||
props.onClose?.(dismiss)
|
||||
}
|
||||
setStore("dismiss", null)
|
||||
}}
|
||||
>
|
||||
@@ -158,7 +163,7 @@ export function ModelSelectorPopover(props: {
|
||||
<ModelList
|
||||
provider={props.provider}
|
||||
model={props.model}
|
||||
onSelect={() => setStore("open", false)}
|
||||
onSelect={() => close("select")}
|
||||
class="p-1"
|
||||
action={
|
||||
<div class="flex items-center gap-1">
|
||||
|
||||
@@ -243,23 +243,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
},
|
||||
)
|
||||
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(() =>
|
||||
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||
)
|
||||
@@ -297,6 +280,31 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
if (store.mode === "shell") return 0
|
||||
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 items = prompt.context.items()
|
||||
@@ -502,6 +510,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
return getCursorPosition(editorRef)
|
||||
}
|
||||
|
||||
const restoreFocus = () => {
|
||||
requestAnimationFrame(() => {
|
||||
const cursor = prompt.cursor() ?? promptLength(prompt.current())
|
||||
editorRef.focus()
|
||||
setCursorPosition(editorRef, cursor)
|
||||
queueScroll()
|
||||
})
|
||||
}
|
||||
|
||||
const renderEditorWithCursor = (parts: Prompt) => {
|
||||
const cursor = currentCursor()
|
||||
renderEditor(parts)
|
||||
@@ -624,17 +641,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
if (!cmd) return
|
||||
promptProbe.select(cmd.id)
|
||||
closePopover()
|
||||
const images = imageAttachments()
|
||||
|
||||
if (cmd.type === "custom") {
|
||||
const text = `/${cmd.trigger} `
|
||||
setEditorText(text)
|
||||
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
|
||||
prompt.set([{ type: "text", content: text, start: 0, end: text.length }, ...images], text.length)
|
||||
focusEditorEnd()
|
||||
return
|
||||
}
|
||||
|
||||
clearEditor()
|
||||
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
|
||||
prompt.set([...DEFAULT_PROMPT, ...images], 0)
|
||||
command.trigger(cmd.id, "slash")
|
||||
}
|
||||
|
||||
@@ -1343,6 +1361,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
|
||||
autocorrect={store.mode === "normal" ? "on" : "off"}
|
||||
spellcheck={store.mode === "normal"}
|
||||
inputMode="text"
|
||||
// @ts-expect-error
|
||||
autocomplete="off"
|
||||
onInput={handleInput}
|
||||
onPaste={handlePaste}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
@@ -1394,17 +1415,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-1 pointer-events-auto">
|
||||
<Tooltip placement="top" inactive={!prompt.dirty() && !working()} value={tip()}>
|
||||
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
|
||||
<IconButton
|
||||
data-action="prompt-submit"
|
||||
type="submit"
|
||||
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
|
||||
disabled={store.mode !== "normal" || (!working() && blank())}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
icon={working() ? "stop" : "arrow-up"}
|
||||
icon={stopping() ? "stop" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="size-8"
|
||||
style={buttons()}
|
||||
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -1467,7 +1488,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
size="normal"
|
||||
options={agentNames()}
|
||||
current={local.agent.current()?.name ?? ""}
|
||||
onSelect={local.agent.set}
|
||||
onSelect={(value) => {
|
||||
local.agent.set(value)
|
||||
restoreFocus()
|
||||
}}
|
||||
class="capitalize max-w-[160px] text-text-base"
|
||||
valueClass="truncate text-13-regular text-text-base"
|
||||
triggerStyle={control()}
|
||||
@@ -1476,28 +1500,62 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<div data-component="prompt-model-control">
|
||||
<Show
|
||||
when={providers.paid().length > 0}
|
||||
fallback={
|
||||
<Show when={store.mode !== "shell"}>
|
||||
<div data-component="prompt-model-control">
|
||||
<Show
|
||||
when={providers.paid().length > 0}
|
||||
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
|
||||
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} />)
|
||||
})
|
||||
<ModelSelectorPopover
|
||||
model={local.model}
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
size: "normal",
|
||||
style: control(),
|
||||
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
|
||||
"data-action": "prompt-model",
|
||||
}}
|
||||
onClose={restoreFocus}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
@@ -1510,63 +1568,35 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||
</Button>
|
||||
</ModelSelectorPopover>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
>
|
||||
</Show>
|
||||
</div>
|
||||
<div data-component="prompt-variant-control">
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.model.choose")}
|
||||
keybind={command.keybind("model.choose")}
|
||||
title={language.t("command.model.variant.cycle")}
|
||||
keybind={command.keybind("model.variant.cycle")}
|
||||
>
|
||||
<ModelSelectorPopover
|
||||
model={local.model}
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
size: "normal",
|
||||
style: control(),
|
||||
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
|
||||
"data-action": "prompt-model",
|
||||
<Select
|
||||
size="normal"
|
||||
options={variants()}
|
||||
current={local.model.variant.current() ?? "default"}
|
||||
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||
onSelect={(value) => {
|
||||
local.model.variant.set(value === "default" ? undefined : value)
|
||||
restoreFocus()
|
||||
}}
|
||||
>
|
||||
<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" />
|
||||
</ModelSelectorPopover>
|
||||
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>
|
||||
</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>
|
||||
</div>
|
||||
</Show>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={8}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { onMount } from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -181,15 +182,9 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("dragover", handleGlobalDragOver)
|
||||
document.addEventListener("dragleave", handleGlobalDragLeave)
|
||||
document.addEventListener("drop", handleGlobalDrop)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("dragover", handleGlobalDragOver)
|
||||
document.removeEventListener("dragleave", handleGlobalDragLeave)
|
||||
document.removeEventListener("drop", handleGlobalDrop)
|
||||
makeEventListener(document, "dragover", handleGlobalDragOver)
|
||||
makeEventListener(document, "dragleave", handleGlobalDragLeave)
|
||||
makeEventListener(document, "drop", handleGlobalDrop)
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -100,6 +100,30 @@ describe("buildRequestParts", () => {
|
||||
expect(synthetic).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("adds file parts for @mentions inside comment text", () => {
|
||||
const result = buildRequestParts({
|
||||
prompt: [{ type: "text", content: "look", start: 0, end: 4 }],
|
||||
context: [
|
||||
{
|
||||
key: "ctx:comment-mention",
|
||||
type: "file",
|
||||
path: "src/review.ts",
|
||||
comment: "Compare with @src/shared.ts and @src/review.ts.",
|
||||
},
|
||||
],
|
||||
images: [],
|
||||
text: "look",
|
||||
messageID: "msg_comment_mentions",
|
||||
sessionID: "ses_comment_mentions",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
|
||||
const files = result.requestParts.filter((part) => part.type === "file")
|
||||
expect(files).toHaveLength(2)
|
||||
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/review.ts")).toBe(true)
|
||||
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/shared.ts")).toBe(true)
|
||||
})
|
||||
|
||||
test("handles Windows paths correctly (simulated on macOS)", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
|
||||
|
||||
|
||||
@@ -39,6 +39,16 @@ const absolute = (directory: string, path: string) => {
|
||||
const fileQuery = (selection: FileSelection | undefined) =>
|
||||
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
|
||||
|
||||
const mention = /(^|[\s([{"'])@(\S+)/g
|
||||
|
||||
const parseCommentMentions = (comment: string) => {
|
||||
return Array.from(comment.matchAll(mention)).flatMap((match) => {
|
||||
const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "")
|
||||
if (!path) return []
|
||||
return [path]
|
||||
})
|
||||
}
|
||||
|
||||
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
|
||||
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
|
||||
|
||||
@@ -138,6 +148,21 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
||||
|
||||
if (!comment) return [filePart]
|
||||
|
||||
const mentions = parseCommentMentions(comment).flatMap((path) => {
|
||||
const url = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
|
||||
if (used.has(url)) return []
|
||||
used.add(url)
|
||||
return [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
url,
|
||||
filename: getFilename(path),
|
||||
} satisfies PromptRequestPart,
|
||||
]
|
||||
})
|
||||
|
||||
return [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
@@ -153,6 +178,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
||||
}),
|
||||
} satisfies PromptRequestPart,
|
||||
filePart,
|
||||
...mentions,
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { usePermission } from "@/context/permission"
|
||||
import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { promptProbe } from "@/testing/prompt"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||
import { buildRequestParts } from "./build-request-parts"
|
||||
@@ -307,6 +308,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
|
||||
input.addToHistory(currentPrompt, mode)
|
||||
input.resetHistoryNavigation()
|
||||
promptProbe.start()
|
||||
|
||||
const projectDirectory = sdk.directory
|
||||
const isNewSession = !params.id
|
||||
@@ -426,6 +428,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
return
|
||||
}
|
||||
|
||||
promptProbe.submit({ sessionID: session.id, directory: sessionDirectory })
|
||||
input.onSubmit?.()
|
||||
|
||||
if (mode === "shell") {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import {
|
||||
children,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
type JSXElement,
|
||||
onCleanup,
|
||||
onMount,
|
||||
type ParentProps,
|
||||
Show,
|
||||
@@ -46,12 +46,9 @@ export function ServerRow(props: ServerRowProps) {
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
check()
|
||||
if (typeof ResizeObserver !== "function") return
|
||||
const observer = new ResizeObserver(check)
|
||||
if (nameRef) observer.observe(nameRef)
|
||||
if (versionRef) observer.observe(versionRef)
|
||||
onCleanup(() => observer.disconnect())
|
||||
createResizeObserver([nameRef, versionRef], check)
|
||||
check()
|
||||
})
|
||||
|
||||
const tooltipValue = () => (
|
||||
|
||||
@@ -139,11 +139,6 @@ export const SettingsGeneral: Component = () => {
|
||||
{ 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(() =>
|
||||
language.locales.map((locale) => ({
|
||||
value: locale,
|
||||
@@ -241,24 +236,6 @@ export const SettingsGeneral: Component = () => {
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component, For, Show, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
@@ -250,8 +251,7 @@ function useKeyCapture(input: {
|
||||
input.stop()
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handle, true)
|
||||
onCleanup(() => document.removeEventListener("keydown", handle, true))
|
||||
makeEventListener(document, "keydown", handle, { capture: true })
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { dict as en } from "@/i18n/en"
|
||||
@@ -378,11 +379,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
makeEventListener(document, "keydown", handleKeyDown)
|
||||
})
|
||||
|
||||
function register(cb: () => CommandOption[]): void
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { batch, onCleanup } from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { batch, onCleanup, onMount } from "solid-js"
|
||||
import z from "zod"
|
||||
import { createSdkForServer } from "@/utils/server"
|
||||
import { useLanguage } from "./language"
|
||||
@@ -206,21 +207,16 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
clearHeartbeat()
|
||||
}
|
||||
|
||||
const onVisibility = () => {
|
||||
if (typeof document === "undefined") return
|
||||
if (document.visibilityState !== "visible") return
|
||||
if (!started) return
|
||||
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
|
||||
attempt?.abort()
|
||||
}
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("visibilitychange", onVisibility)
|
||||
}
|
||||
onMount(() => {
|
||||
makeEventListener(document, "visibilitychange", () => {
|
||||
if (document.visibilityState !== "visible") return
|
||||
if (!started) return
|
||||
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
|
||||
attempt?.abort()
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (typeof document !== "undefined") {
|
||||
document.removeEventListener("visibilitychange", onVisibility)
|
||||
}
|
||||
stop()
|
||||
abort.abort()
|
||||
flush()
|
||||
|
||||
@@ -248,7 +248,7 @@ export async function bootstrapDirectory(input: {
|
||||
input.sdk.vcs.get().then((x) => {
|
||||
const next = x.data ?? input.store.vcs
|
||||
input.setStore("vcs", next)
|
||||
if (next?.branch) input.vcsCache.setStore("value", next)
|
||||
if (next) input.vcsCache.setStore("value", next)
|
||||
}),
|
||||
),
|
||||
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
|
||||
|
||||
@@ -494,8 +494,10 @@ describe("applyDirectoryEvent", () => {
|
||||
})
|
||||
|
||||
test("updates vcs branch in store and cache", () => {
|
||||
const [store, setStore] = createStore(baseState())
|
||||
const [cacheStore, setCacheStore] = createStore({ value: undefined as State["vcs"] })
|
||||
const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } }))
|
||||
const [cacheStore, setCacheStore] = createStore({
|
||||
value: { branch: "main", default_branch: "main" } as State["vcs"],
|
||||
})
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } },
|
||||
@@ -511,8 +513,8 @@ describe("applyDirectoryEvent", () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(store.vcs).toEqual({ branch: "feature/test" })
|
||||
expect(cacheStore.value).toEqual({ branch: "feature/test" })
|
||||
expect(store.vcs).toEqual({ branch: "feature/test", default_branch: "main" })
|
||||
expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" })
|
||||
})
|
||||
|
||||
test("routes disposal and lsp events to side-effect handlers", () => {
|
||||
|
||||
@@ -271,9 +271,9 @@ export function applyDirectoryEvent(input: {
|
||||
break
|
||||
}
|
||||
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
|
||||
const next = { branch: props.branch }
|
||||
const next = { ...input.store.vcs, branch: props.branch }
|
||||
input.setStore("vcs", next)
|
||||
if (input.vcsCache) input.vcsCache.setStore("value", next)
|
||||
break
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
import { useServer } from "./server"
|
||||
@@ -366,12 +367,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
flush()
|
||||
}
|
||||
|
||||
window.addEventListener("pagehide", flush)
|
||||
document.addEventListener("visibilitychange", handleVisibility)
|
||||
makeEventListener(window, "pagehide", flush)
|
||||
makeEventListener(document, "visibilitychange", handleVisibility)
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("pagehide", flush)
|
||||
document.removeEventListener("visibilitychange", handleVisibility)
|
||||
scroll.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -136,6 +136,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (store.general?.followup !== "queue") return
|
||||
setStore("general", "followup", "steer")
|
||||
})
|
||||
|
||||
return {
|
||||
ready,
|
||||
get current() {
|
||||
@@ -150,9 +155,12 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setReleaseNotes(value: boolean) {
|
||||
setStore("general", "releaseNotes", value)
|
||||
},
|
||||
followup: withFallback(() => store.general?.followup, defaultSettings.general.followup),
|
||||
followup: withFallback(
|
||||
() => (store.general?.followup === "queue" ? "steer" : store.general?.followup),
|
||||
defaultSettings.general.followup,
|
||||
),
|
||||
setFollowup(value: "queue" | "steer") {
|
||||
setStore("general", "followup", value)
|
||||
setStore("general", "followup", value === "queue" ? "steer" : value)
|
||||
},
|
||||
showReasoningSummaries: withFallback(
|
||||
() => store.general?.showReasoningSummaries,
|
||||
|
||||
@@ -535,6 +535,8 @@ export const dict = {
|
||||
"session.review.noVcs.createGit.action": "Create Git repository",
|
||||
"session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
|
||||
"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.all": "All files",
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
untrack,
|
||||
type Accessor,
|
||||
} from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { useLayout, LocalProject } from "@/context/layout"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
@@ -215,18 +216,11 @@ export default function Layout(props: ParentProps) {
|
||||
if (document.visibilityState !== "hidden") return
|
||||
reset()
|
||||
}
|
||||
window.addEventListener("pointerup", stop)
|
||||
window.addEventListener("pointercancel", stop)
|
||||
window.addEventListener("blur", stop)
|
||||
window.addEventListener("blur", blur)
|
||||
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)
|
||||
})
|
||||
makeEventListener(window, "pointerup", stop)
|
||||
makeEventListener(window, "pointercancel", stop)
|
||||
makeEventListener(window, "blur", stop)
|
||||
makeEventListener(window, "blur", blur)
|
||||
makeEventListener(document, "visibilitychange", hide)
|
||||
})
|
||||
|
||||
const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
|
||||
@@ -1394,8 +1388,7 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
|
||||
handleDeepLinks(drainPendingDeepLinks(window))
|
||||
window.addEventListener(deepLinkEvent, handler as EventListener)
|
||||
onCleanup(() => window.removeEventListener(deepLinkEvent, handler as EventListener))
|
||||
makeEventListener(window, deepLinkEvent, handler as EventListener)
|
||||
})
|
||||
|
||||
async function renameProject(project: LocalProject, next: string) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
|
||||
import { hasProjectPermissions } from "./helpers"
|
||||
|
||||
@@ -101,42 +102,46 @@ const SessionRow = (props: {
|
||||
warmPress: () => 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)" }}
|
||||
}) => {
|
||||
const title = () => sessionTitle(props.session.title)
|
||||
|
||||
return (
|
||||
<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()
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
<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">{title()}</span>
|
||||
</A>
|
||||
)
|
||||
}
|
||||
|
||||
const SessionHoverPreview = (props: {
|
||||
mobile?: boolean
|
||||
@@ -319,7 +324,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
fallback={
|
||||
<Tooltip
|
||||
placement={props.mobile ? "bottom" : "right"}
|
||||
value={props.session.title}
|
||||
value={sessionTitle(props.session.title)}
|
||||
gutter={10}
|
||||
class="min-w-0 w-full"
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import type { FileDiff, Project, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import {
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
onMount,
|
||||
untrack,
|
||||
} from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { useLocal } from "@/context/local"
|
||||
@@ -67,6 +68,9 @@ type FollowupItem = FollowupDraft & { id: string }
|
||||
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
|
||||
const emptyFollowups: FollowupItem[] = []
|
||||
|
||||
type ChangeMode = "git" | "branch" | "session" | "turn"
|
||||
type VcsMode = "git" | "branch"
|
||||
|
||||
type SessionHistoryWindowInput = {
|
||||
sessionID: () => string | undefined
|
||||
messagesReady: () => boolean
|
||||
@@ -329,10 +333,9 @@ export default function Page() {
|
||||
const { params, sessionKey, tabs, view } = useSessionLayout()
|
||||
|
||||
createEffect(() => {
|
||||
if (!untrack(() => prompt.ready())) return
|
||||
prompt.ready()
|
||||
if (!prompt.ready()) return
|
||||
untrack(() => {
|
||||
if (params.id || !prompt.ready()) return
|
||||
if (params.id) return
|
||||
const text = searchParams.prompt
|
||||
if (!text) return
|
||||
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
|
||||
@@ -347,6 +350,7 @@ export default function Page() {
|
||||
scroll: {
|
||||
overflow: false,
|
||||
bottom: true,
|
||||
jump: false,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -426,15 +430,16 @@ export default function Page() {
|
||||
|
||||
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 sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
||||
const hasSessionReview = createMemo(() => sessionCount() > 0)
|
||||
const canReview = createMemo(() => !!sync.project)
|
||||
const reviewTab = createMemo(() => isDesktop())
|
||||
const tabState = createSessionTabs({
|
||||
tabs,
|
||||
pathFromTab: file.pathFromTab,
|
||||
normalizeTab,
|
||||
review: reviewTab,
|
||||
hasReview,
|
||||
hasReview: canReview,
|
||||
})
|
||||
const contextOpen = tabState.contextOpen
|
||||
const openedTabs = tabState.openedTabs
|
||||
@@ -457,6 +462,12 @@ export default function Page() {
|
||||
if (!id) return false
|
||||
return sync.session.history.loading(id)
|
||||
})
|
||||
const diffsReady = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return true
|
||||
if (!hasSessionReview()) return true
|
||||
return sync.data.session_diff[id] !== undefined
|
||||
})
|
||||
|
||||
const userMessages = createMemo(
|
||||
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
||||
@@ -510,11 +521,22 @@ export default function Page() {
|
||||
const [store, setStore] = createStore({
|
||||
messageId: undefined as string | undefined,
|
||||
mobileTab: "session" as "session" | "changes",
|
||||
changes: "session" as "session" | "turn",
|
||||
changes: "git" as ChangeMode,
|
||||
newSessionWorktree: "main",
|
||||
deferRender: false,
|
||||
})
|
||||
|
||||
const [vcs, setVcs] = createStore({
|
||||
diff: {
|
||||
git: [] as FileDiff[],
|
||||
branch: [] as FileDiff[],
|
||||
},
|
||||
ready: {
|
||||
git: false,
|
||||
branch: false,
|
||||
},
|
||||
})
|
||||
|
||||
const [followup, setFollowup] = persisted(
|
||||
Persist.workspace(sdk.directory, "followup", ["followup.v1"]),
|
||||
createStore<{
|
||||
@@ -548,6 +570,68 @@ export default function Page() {
|
||||
let todoTimer: number | undefined
|
||||
let diffFrame: 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, 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) => {
|
||||
const open = desktopReviewOpen()
|
||||
@@ -563,7 +647,42 @@ export default function Page() {
|
||||
}, desktopReviewOpen())
|
||||
|
||||
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
|
||||
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("session", "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 vcs.diff.git
|
||||
if (store.changes === "branch") return vcs.diff.branch
|
||||
if (store.changes === "session") return diffs()
|
||||
return turnDiffs()
|
||||
})
|
||||
const reviewCount = createMemo(() => {
|
||||
if (store.changes === "git") return vcs.diff.git.length
|
||||
if (store.changes === "branch") return vcs.diff.branch.length
|
||||
if (store.changes === "session") return sessionCount()
|
||||
return turnDiffs().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
|
||||
if (store.changes === "session") return !hasSessionReview() || diffsReady()
|
||||
return true
|
||||
})
|
||||
|
||||
const newSessionWorktree = createMemo(() => {
|
||||
if (store.newSessionWorktree === "create") return "create"
|
||||
@@ -629,13 +748,7 @@ export default function Page() {
|
||||
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 sessionEmptyKey = createMemo(() => {
|
||||
const project = sync.project
|
||||
if (project && !project.vcs) return "session.review.noVcs"
|
||||
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
|
||||
@@ -789,13 +902,46 @@ export default function Page() {
|
||||
sessionKey,
|
||||
() => {
|
||||
setStore("messageId", undefined)
|
||||
setStore("changes", "session")
|
||||
setStore("changes", "git")
|
||||
setUi("pendingMessage", undefined)
|
||||
},
|
||||
{ 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(
|
||||
on(
|
||||
() => params.dir,
|
||||
@@ -918,6 +1064,40 @@ export default function Page() {
|
||||
}
|
||||
|
||||
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 setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
|
||||
@@ -964,21 +1144,23 @@ export default function Page() {
|
||||
loadFile: file.load,
|
||||
})
|
||||
|
||||
const changesOptions = ["session", "turn"] as const
|
||||
const changesOptionsList = [...changesOptions]
|
||||
|
||||
const changesTitle = () => {
|
||||
if (!hasReview()) {
|
||||
if (!canReview()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const label = (option: ChangeMode) => {
|
||||
if (option === "git") return language.t("ui.sessionReview.title.git")
|
||||
if (option === "branch") return language.t("ui.sessionReview.title.branch")
|
||||
if (option === "session") return language.t("ui.sessionReview.title")
|
||||
return language.t("ui.sessionReview.title.lastTurn")
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
options={changesOptionsList}
|
||||
options={changesOptions()}
|
||||
current={store.changes}
|
||||
label={(option) =>
|
||||
option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
|
||||
}
|
||||
label={label}
|
||||
onSelect={(option) => option && setStore("changes", option)}
|
||||
variant="ghost"
|
||||
size="small"
|
||||
@@ -987,20 +1169,34 @@ export default function Page() {
|
||||
)
|
||||
}
|
||||
|
||||
const emptyTurn = () => (
|
||||
const empty = (text: string) => (
|
||||
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
|
||||
<div class="text-14-regular text-text-weak max-w-56">{text}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
|
||||
if (store.changes === "turn") return emptyTurn()
|
||||
const reviewEmptyText = createMemo(() => {
|
||||
if (store.changes === "git") return language.t("session.review.noUncommittedChanges")
|
||||
if (store.changes === "branch") return language.t("session.review.noBranchChanges")
|
||||
if (store.changes === "turn") return language.t("session.review.noChanges")
|
||||
return language.t(sessionEmptyKey())
|
||||
})
|
||||
|
||||
if (hasReview() && !diffsReady()) {
|
||||
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
|
||||
if (store.changes === "git" || store.changes === "branch") {
|
||||
if (!reviewReady()) return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
|
||||
return empty(reviewEmptyText())
|
||||
}
|
||||
|
||||
if (store.changes === "turn") {
|
||||
return empty(reviewEmptyText())
|
||||
}
|
||||
|
||||
if (hasSessionReview() && !diffsReady()) {
|
||||
return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
|
||||
}
|
||||
|
||||
if (reviewEmptyKey() === "session.review.noVcs") {
|
||||
if (sessionEmptyKey() === "session.review.noVcs") {
|
||||
return (
|
||||
<div class={input.emptyClass}>
|
||||
<div class="flex flex-col gap-3">
|
||||
@@ -1020,7 +1216,7 @@ export default function Page() {
|
||||
|
||||
return (
|
||||
<div class={input.emptyClass}>
|
||||
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
|
||||
<div class="text-14-regular text-text-weak max-w-56">{reviewEmptyText()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1046,6 +1242,9 @@ export default function Page() {
|
||||
onLineCommentUpdate={updateCommentInContext}
|
||||
onLineCommentDelete={removeCommentFromContext}
|
||||
lineCommentActions={reviewCommentActions()}
|
||||
commentMentions={{
|
||||
items: file.searchFilesAndDirectories,
|
||||
}}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
@@ -1124,7 +1323,7 @@ export default function Page() {
|
||||
const pending = tree.pendingDiff
|
||||
if (!pending) return
|
||||
if (!tree.reviewScroll) return
|
||||
if (!diffsReady()) return
|
||||
if (!reviewReady()) return
|
||||
|
||||
const attempt = (count: number) => {
|
||||
if (tree.pendingDiff !== pending) return
|
||||
@@ -1165,10 +1364,7 @@ export default function Page() {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
|
||||
const wants = isDesktop()
|
||||
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
|
||||
: store.mobileTab === "changes"
|
||||
if (!wants) return
|
||||
if (!wantsReview()) return
|
||||
if (sync.data.session_diff[id] !== undefined) return
|
||||
if (sync.status === "loading") return
|
||||
|
||||
@@ -1177,13 +1373,7 @@ export default function Page() {
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() =>
|
||||
[
|
||||
sessionKey(),
|
||||
isDesktop()
|
||||
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
|
||||
: store.mobileTab === "changes",
|
||||
] as const,
|
||||
() => [sessionKey(), wantsReview()] as const,
|
||||
([key, wants]) => {
|
||||
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
|
||||
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
|
||||
@@ -1244,13 +1434,17 @@ export default function Page() {
|
||||
let scrollStateTarget: HTMLDivElement | undefined
|
||||
let fillFrame: number | undefined
|
||||
|
||||
const jumpThreshold = (el: HTMLDivElement) => Math.max(400, el.clientHeight)
|
||||
|
||||
const updateScrollState = (el: HTMLDivElement) => {
|
||||
const max = el.scrollHeight - el.clientHeight
|
||||
const distance = max - el.scrollTop
|
||||
const overflow = max > 1
|
||||
const bottom = !overflow || el.scrollTop >= max - 2
|
||||
const bottom = !overflow || distance <= 2
|
||||
const jump = overflow && distance > jumpThreshold(el)
|
||||
|
||||
if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return
|
||||
setUi("scroll", { overflow, bottom })
|
||||
if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom && ui.scroll.jump === jump) return
|
||||
setUi("scroll", { overflow, bottom, jump })
|
||||
}
|
||||
|
||||
const scheduleScrollState = (el: HTMLDivElement) => {
|
||||
@@ -1685,11 +1879,10 @@ export default function Page() {
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
makeEventListener(document, "keydown", handleKeyDown)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
|
||||
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
|
||||
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
|
||||
@@ -1860,6 +2053,12 @@ export default function Page() {
|
||||
</div>
|
||||
|
||||
<SessionSidePanel
|
||||
canReview={canReview}
|
||||
diffs={reviewDiffs}
|
||||
diffsReady={reviewReady}
|
||||
empty={reviewEmptyText}
|
||||
hasReview={hasReview}
|
||||
reviewCount={reviewCount}
|
||||
reviewPanel={reviewPanel}
|
||||
activeDiff={tree.activeDiff}
|
||||
focusReviewDiff={focusReviewDiff}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
|
||||
import type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
|
||||
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
|
||||
import type { FollowupDraft } from "@/components/prompt-input/submit"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
|
||||
export function SessionComposerRegion(props: {
|
||||
state: SessionComposerState
|
||||
@@ -115,13 +116,9 @@ export function SessionComposerRegion(props: {
|
||||
createEffect(() => {
|
||||
const el = store.body
|
||||
if (!el) return
|
||||
const update = () => {
|
||||
setStore("height", el.getBoundingClientRect().height)
|
||||
}
|
||||
const update = () => setStore("height", el.getBoundingClientRect().height)
|
||||
createResizeObserver(store.body, update)
|
||||
update()
|
||||
const observer = new ResizeObserver(update)
|
||||
observer.observe(el)
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
@@ -86,8 +87,7 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
||||
pull()
|
||||
}
|
||||
|
||||
window.addEventListener(composerEvent, onEvent)
|
||||
onCleanup(() => window.removeEventListener(composerEvent, onEvent))
|
||||
makeEventListener(window, composerEvent, onEvent)
|
||||
})
|
||||
|
||||
const todos = createMemo((): Todo[] => {
|
||||
|
||||
@@ -8,9 +8,56 @@ import { showToast } from "@opencode-ai/ui/toast"
|
||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
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[] }>()
|
||||
|
||||
function Mark(props: { multi: boolean; picked: boolean; onClick?: (event: MouseEvent) => void }) {
|
||||
return (
|
||||
<span data-slot="question-option-check" aria-hidden="true" onClick={props.onClick}>
|
||||
<span data-slot="question-option-box" data-type={props.multi ? "checkbox" : "radio"} data-picked={props.picked}>
|
||||
<Show when={props.multi} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Option(props: {
|
||||
multi: boolean
|
||||
picked: boolean
|
||||
label: string
|
||||
description?: string
|
||||
disabled: boolean
|
||||
ref?: (el: HTMLButtonElement) => void
|
||||
onFocus?: VoidFunction
|
||||
onClick: VoidFunction
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
ref={props.ref}
|
||||
data-slot="question-option"
|
||||
data-picked={props.picked}
|
||||
role={props.multi ? "checkbox" : "radio"}
|
||||
aria-checked={props.picked}
|
||||
disabled={props.disabled}
|
||||
onFocus={props.onFocus}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<Mark multi={props.multi} picked={props.picked} />
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{props.label}</span>
|
||||
<Show when={props.description}>
|
||||
<span data-slot="option-description">{props.description}</span>
|
||||
</Show>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => {
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
@@ -25,22 +72,30 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
custom: cached?.custom ?? ([] as string[]),
|
||||
customOn: cached?.customOn ?? ([] as boolean[]),
|
||||
editing: false,
|
||||
focus: 0,
|
||||
})
|
||||
|
||||
let root: HTMLDivElement | undefined
|
||||
let customRef: HTMLButtonElement | undefined
|
||||
let optsRef: HTMLButtonElement[] = []
|
||||
let replied = false
|
||||
let focusFrame: number | undefined
|
||||
|
||||
const question = createMemo(() => questions()[store.tab])
|
||||
const options = createMemo(() => question()?.options ?? [])
|
||||
const input = createMemo(() => store.custom[store.tab] ?? "")
|
||||
const on = createMemo(() => store.customOn[store.tab] === true)
|
||||
const multi = createMemo(() => question()?.multiple === true)
|
||||
const count = createMemo(() => options().length + 1)
|
||||
|
||||
const summary = createMemo(() => {
|
||||
const n = Math.min(store.tab + 1, total())
|
||||
return language.t("session.question.progress", { current: n, total: total() })
|
||||
})
|
||||
|
||||
const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer")
|
||||
const customPlaceholder = () => language.t("ui.question.custom.placeholder")
|
||||
|
||||
const last = createMemo(() => store.tab >= total() - 1)
|
||||
|
||||
const customUpdate = (value: string, selected: boolean = on()) => {
|
||||
@@ -85,6 +140,29 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
root.style.setProperty("--question-prompt-max-height", `${max}px`)
|
||||
}
|
||||
|
||||
const clamp = (i: number) => Math.max(0, Math.min(count() - 1, i))
|
||||
|
||||
const pickFocus = (tab: number = store.tab) => {
|
||||
const list = questions()[tab]?.options ?? []
|
||||
if (store.customOn[tab] === true) return list.length
|
||||
return Math.max(
|
||||
0,
|
||||
list.findIndex((item) => store.answers[tab]?.includes(item.label) ?? false),
|
||||
)
|
||||
}
|
||||
|
||||
const focus = (i: number) => {
|
||||
const next = clamp(i)
|
||||
setStore("focus", next)
|
||||
if (store.editing) return
|
||||
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
|
||||
focusFrame = requestAnimationFrame(() => {
|
||||
focusFrame = undefined
|
||||
const el = next === options().length ? customRef : optsRef[next]
|
||||
el?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let raf: number | undefined
|
||||
const update = () => {
|
||||
@@ -96,22 +174,22 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
}
|
||||
|
||||
update()
|
||||
window.addEventListener("resize", update)
|
||||
|
||||
makeEventListener(window, "resize", update)
|
||||
|
||||
const dock = root?.closest('[data-component="session-prompt-dock"]')
|
||||
const scroller = document.querySelector(".scroll-view__viewport")
|
||||
const observer = new ResizeObserver(update)
|
||||
if (dock instanceof HTMLElement) observer.observe(dock)
|
||||
if (scroller instanceof HTMLElement) observer.observe(scroller)
|
||||
createResizeObserver([dock, scroller], update)
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("resize", update)
|
||||
observer.disconnect()
|
||||
if (raf !== undefined) cancelAnimationFrame(raf)
|
||||
})
|
||||
|
||||
focus(pickFocus())
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
|
||||
if (replied) return
|
||||
cache.set(props.request.id, {
|
||||
tab: store.tab,
|
||||
@@ -164,6 +242,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
|
||||
const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
|
||||
|
||||
const answered = (i: number) => {
|
||||
if ((store.answers[i]?.length ?? 0) > 0) return true
|
||||
return store.customOn[i] === true && (store.custom[i] ?? "").trim().length > 0
|
||||
}
|
||||
|
||||
const picked = (answer: string) => store.answers[store.tab]?.includes(answer) ?? false
|
||||
|
||||
const pick = (answer: string, custom: boolean = false) => {
|
||||
setStore("answers", store.tab, [answer])
|
||||
if (custom) setStore("custom", store.tab, answer)
|
||||
@@ -180,6 +265,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
|
||||
const customToggle = () => {
|
||||
if (sending()) return
|
||||
setStore("focus", options().length)
|
||||
|
||||
if (!multi()) {
|
||||
setStore("customOn", store.tab, true)
|
||||
@@ -199,15 +285,68 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
const value = input().trim()
|
||||
if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value))
|
||||
setStore("editing", false)
|
||||
focus(options().length)
|
||||
}
|
||||
|
||||
const customOpen = () => {
|
||||
if (sending()) return
|
||||
setStore("focus", options().length)
|
||||
if (!on()) setStore("customOn", store.tab, true)
|
||||
setStore("editing", true)
|
||||
customUpdate(input(), true)
|
||||
}
|
||||
|
||||
const move = (step: number) => {
|
||||
if (store.editing || sending()) return
|
||||
focus(store.focus + step)
|
||||
}
|
||||
|
||||
const nav = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented) return
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
void reject()
|
||||
return
|
||||
}
|
||||
|
||||
const mod = (event.metaKey || event.ctrlKey) && !event.altKey
|
||||
if (mod && event.key === "Enter") {
|
||||
if (event.repeat) return
|
||||
event.preventDefault()
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
const target =
|
||||
event.target instanceof HTMLElement ? event.target.closest('[data-slot="question-options"]') : undefined
|
||||
if (store.editing) return
|
||||
if (!(target instanceof HTMLElement)) return
|
||||
if (event.altKey || event.ctrlKey || event.metaKey) return
|
||||
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
move(1)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
|
||||
event.preventDefault()
|
||||
move(-1)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "Home") {
|
||||
event.preventDefault()
|
||||
focus(0)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key !== "End") return
|
||||
event.preventDefault()
|
||||
focus(count() - 1)
|
||||
}
|
||||
|
||||
const selectOption = (optIndex: number) => {
|
||||
if (sending()) return
|
||||
|
||||
@@ -219,6 +358,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
const opt = options()[optIndex]
|
||||
if (!opt) return
|
||||
if (multi()) {
|
||||
setStore("editing", false)
|
||||
toggle(opt.label)
|
||||
return
|
||||
}
|
||||
@@ -228,6 +368,25 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
const commitCustom = () => {
|
||||
setStore("editing", false)
|
||||
customUpdate(input())
|
||||
focus(options().length)
|
||||
}
|
||||
|
||||
const resizeInput = (el: HTMLTextAreaElement) => {
|
||||
el.style.height = "0px"
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}
|
||||
|
||||
const focusCustom = (el: HTMLTextAreaElement) => {
|
||||
setTimeout(() => {
|
||||
el.focus()
|
||||
resizeInput(el)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const toggleCustomMark = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
customToggle()
|
||||
}
|
||||
|
||||
const next = () => {
|
||||
@@ -239,27 +398,33 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
return
|
||||
}
|
||||
|
||||
setStore("tab", store.tab + 1)
|
||||
const tab = store.tab + 1
|
||||
setStore("tab", tab)
|
||||
setStore("editing", false)
|
||||
focus(pickFocus(tab))
|
||||
}
|
||||
|
||||
const back = () => {
|
||||
if (sending()) return
|
||||
if (store.tab <= 0) return
|
||||
setStore("tab", store.tab - 1)
|
||||
const tab = store.tab - 1
|
||||
setStore("tab", tab)
|
||||
setStore("editing", false)
|
||||
focus(pickFocus(tab))
|
||||
}
|
||||
|
||||
const jump = (tab: number) => {
|
||||
if (sending()) return
|
||||
setStore("tab", tab)
|
||||
setStore("editing", false)
|
||||
focus(pickFocus(tab))
|
||||
}
|
||||
|
||||
return (
|
||||
<DockPrompt
|
||||
kind="question"
|
||||
ref={(el) => (root = el)}
|
||||
onKeyDown={nav}
|
||||
header={
|
||||
<>
|
||||
<div data-slot="question-header-title">{summary()}</div>
|
||||
@@ -270,10 +435,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
type="button"
|
||||
data-slot="question-progress-segment"
|
||||
data-active={i() === store.tab}
|
||||
data-answered={
|
||||
(store.answers[i()]?.length ?? 0) > 0 ||
|
||||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
|
||||
}
|
||||
data-answered={answered(i())}
|
||||
disabled={sending()}
|
||||
onClick={() => jump(i())}
|
||||
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
||||
@@ -285,7 +447,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" size="large" disabled={sending()} onClick={reject}>
|
||||
<Button variant="ghost" size="large" disabled={sending()} onClick={reject} aria-keyshortcuts="Escape">
|
||||
{language.t("ui.common.dismiss")}
|
||||
</Button>
|
||||
<div data-slot="question-footer-actions">
|
||||
@@ -294,7 +456,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
{language.t("ui.common.back")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={sending()} onClick={next}>
|
||||
<Button
|
||||
variant={last() ? "primary" : "secondary"}
|
||||
size="large"
|
||||
disabled={sending()}
|
||||
onClick={next}
|
||||
aria-keyshortcuts="Meta+Enter Control+Enter"
|
||||
>
|
||||
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -307,69 +475,39 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
</Show>
|
||||
<div data-slot="question-options">
|
||||
<For each={options()}>
|
||||
{(opt, i) => {
|
||||
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
|
||||
return (
|
||||
<button
|
||||
data-slot="question-option"
|
||||
data-picked={picked()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={picked()}
|
||||
disabled={sending()}
|
||||
onClick={() => selectOption(i())}
|
||||
>
|
||||
<span data-slot="question-option-check" aria-hidden="true">
|
||||
<span
|
||||
data-slot="question-option-box"
|
||||
data-type={multi() ? "checkbox" : "radio"}
|
||||
data-picked={picked()}
|
||||
>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{opt.label}</span>
|
||||
<Show when={opt.description}>
|
||||
<span data-slot="option-description">{opt.description}</span>
|
||||
</Show>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
{(opt, i) => (
|
||||
<Option
|
||||
multi={multi()}
|
||||
picked={picked(opt.label)}
|
||||
label={opt.label}
|
||||
description={opt.description}
|
||||
disabled={sending()}
|
||||
ref={(el) => (optsRef[i()] = el)}
|
||||
onFocus={() => setStore("focus", i())}
|
||||
onClick={() => selectOption(i())}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
ref={customRef}
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
disabled={sending()}
|
||||
onFocus={() => setStore("focus", options().length)}
|
||||
onClick={customOpen}
|
||||
>
|
||||
<span
|
||||
data-slot="question-option-check"
|
||||
aria-hidden="true"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
customToggle()
|
||||
}}
|
||||
>
|
||||
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
|
||||
<span data-slot="option-label">{customLabel()}</span>
|
||||
<span data-slot="option-description">{input() || customPlaceholder()}</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
@@ -394,33 +532,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
commitCustom()
|
||||
}}
|
||||
>
|
||||
<span
|
||||
data-slot="question-option-check"
|
||||
aria-hidden="true"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
customToggle()
|
||||
}}
|
||||
>
|
||||
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<span data-slot="option-label">{customLabel()}</span>
|
||||
<textarea
|
||||
ref={(el) =>
|
||||
setTimeout(() => {
|
||||
el.focus()
|
||||
el.style.height = "0px"
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}, 0)
|
||||
}
|
||||
ref={focusCustom}
|
||||
data-slot="question-custom-input"
|
||||
placeholder={language.t("ui.question.custom.placeholder")}
|
||||
placeholder={customPlaceholder()}
|
||||
value={input()}
|
||||
rows={1}
|
||||
disabled={sending()}
|
||||
@@ -428,16 +546,17 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
focus(options().length)
|
||||
return
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
|
||||
if (e.key !== "Enter" || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
onInput={(e) => {
|
||||
customUpdate(e.currentTarget.value)
|
||||
e.currentTarget.style.height = "0px"
|
||||
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
|
||||
resizeInput(e.currentTarget)
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
|
||||
@@ -6,7 +6,8 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
|
||||
import { Index, createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Index, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { composerEnabled, composerProbe } from "@/testing/session-composer"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -91,9 +92,7 @@ export function SessionTodoDock(props: {
|
||||
setStore("height", el.getBoundingClientRect().height)
|
||||
}
|
||||
update()
|
||||
const observer = new ResizeObserver(update)
|
||||
observer.observe(el)
|
||||
onCleanup(() => observer.disconnect())
|
||||
createResizeObserver(el, update)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -211,76 +210,25 @@ export function SessionTodoDock(props: {
|
||||
opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
|
||||
}}
|
||||
>
|
||||
<TodoList todos={props.todos} open={!store.collapsed} />
|
||||
<TodoList todos={props.todos} />
|
||||
</div>
|
||||
</div>
|
||||
</DockTray>
|
||||
)
|
||||
}
|
||||
|
||||
function TodoList(props: { todos: Todo[]; open: boolean }) {
|
||||
function TodoList(props: { todos: Todo[] }) {
|
||||
const [store, setStore] = createStore({
|
||||
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 (
|
||||
<div class="relative">
|
||||
<div
|
||||
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" }}
|
||||
onScroll={(e) => {
|
||||
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}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createEffect, createMemo, Match, on, onCleanup, Switch } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import type { FileSearchHandle } from "@opencode-ai/ui/file"
|
||||
import { useFileComponent } from "@opencode-ai/ui/context/file"
|
||||
import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
|
||||
@@ -52,6 +53,124 @@ function FileCommentMenu(props: {
|
||||
)
|
||||
}
|
||||
|
||||
type ScrollPos = { x: number; y: number }
|
||||
|
||||
function createScrollSync(input: { tab: () => string; view: ReturnType<typeof useSessionLayout>["view"] }) {
|
||||
let scroll: HTMLDivElement | undefined
|
||||
let scrollFrame: number | undefined
|
||||
let restoreFrame: number | undefined
|
||||
let pending: ScrollPos | undefined
|
||||
const [code, setCode] = createSignal<HTMLElement[]>([])
|
||||
|
||||
const getCode = () => {
|
||||
const el = scroll
|
||||
if (!el) return []
|
||||
|
||||
const host = el.querySelector("diffs-container")
|
||||
if (!(host instanceof HTMLElement)) return []
|
||||
|
||||
const root = host.shadowRoot
|
||||
if (!root) return []
|
||||
|
||||
return Array.from(root.querySelectorAll("[data-code]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
|
||||
)
|
||||
}
|
||||
|
||||
const save = (next: ScrollPos) => {
|
||||
pending = next
|
||||
if (scrollFrame !== undefined) return
|
||||
|
||||
scrollFrame = requestAnimationFrame(() => {
|
||||
scrollFrame = undefined
|
||||
|
||||
const out = pending
|
||||
pending = undefined
|
||||
if (!out) return
|
||||
|
||||
input.view().setScroll(input.tab(), out)
|
||||
})
|
||||
}
|
||||
|
||||
const onCodeScroll = (event: Event) => {
|
||||
const el = scroll
|
||||
if (!el) return
|
||||
|
||||
const target = event.currentTarget
|
||||
if (!(target instanceof HTMLElement)) return
|
||||
|
||||
save({
|
||||
x: target.scrollLeft,
|
||||
y: el.scrollTop,
|
||||
})
|
||||
}
|
||||
|
||||
const sync = () => {
|
||||
const next = getCode()
|
||||
const current = code()
|
||||
if (next.length === current.length && next.every((el, i) => el === current[i])) return
|
||||
setCode(next)
|
||||
}
|
||||
|
||||
const restore = () => {
|
||||
const el = scroll
|
||||
if (!el) return
|
||||
|
||||
const pos = input.view().scroll(input.tab())
|
||||
if (!pos) return
|
||||
|
||||
sync()
|
||||
|
||||
if (code().length > 0) {
|
||||
for (const item of code()) {
|
||||
if (item.scrollLeft !== pos.x) item.scrollLeft = pos.x
|
||||
}
|
||||
}
|
||||
|
||||
if (el.scrollTop !== pos.y) el.scrollTop = pos.y
|
||||
if (code().length > 0) return
|
||||
if (el.scrollLeft !== pos.x) el.scrollLeft = pos.x
|
||||
}
|
||||
|
||||
const queueRestore = () => {
|
||||
if (restoreFrame !== undefined) return
|
||||
|
||||
restoreFrame = requestAnimationFrame(() => {
|
||||
restoreFrame = undefined
|
||||
restore()
|
||||
})
|
||||
}
|
||||
|
||||
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||
if (code().length === 0) sync()
|
||||
|
||||
save({
|
||||
x: code()[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
|
||||
y: event.currentTarget.scrollTop,
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
for (const item of code()) makeEventListener(item, "scroll", onCodeScroll)
|
||||
})
|
||||
|
||||
const setViewport = (el: HTMLDivElement) => {
|
||||
scroll = el
|
||||
restore()
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
|
||||
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
|
||||
})
|
||||
|
||||
return {
|
||||
handleScroll,
|
||||
queueRestore,
|
||||
setViewport,
|
||||
}
|
||||
}
|
||||
|
||||
export function FileTabContent(props: { tab: string }) {
|
||||
const file = useFile()
|
||||
const comments = useComments()
|
||||
@@ -65,11 +184,6 @@ export function FileTabContent(props: { tab: string }) {
|
||||
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||
}).activeFileTab
|
||||
|
||||
let scroll: HTMLDivElement | undefined
|
||||
let scrollFrame: number | undefined
|
||||
let restoreFrame: number | undefined
|
||||
let pending: { x: number; y: number } | undefined
|
||||
let codeScroll: HTMLElement[] = []
|
||||
let find: FileSearchHandle | null = null
|
||||
|
||||
const search = {
|
||||
@@ -92,6 +206,10 @@ export function FileTabContent(props: { tab: string }) {
|
||||
if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null
|
||||
return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null
|
||||
})
|
||||
const scrollSync = createScrollSync({
|
||||
tab: () => props.tab,
|
||||
view,
|
||||
})
|
||||
|
||||
const selectionPreview = (source: string, selection: FileSelection) => {
|
||||
return previewSelectedLines(source, {
|
||||
@@ -100,6 +218,12 @@ export function FileTabContent(props: { tab: string }) {
|
||||
})
|
||||
}
|
||||
|
||||
const buildPreview = (filePath: string, selection: FileSelection) => {
|
||||
const source = filePath === path() ? contents() : file.get(filePath)?.content?.content
|
||||
if (!source) return undefined
|
||||
return selectionPreview(source, selection)
|
||||
}
|
||||
|
||||
const addCommentToContext = (input: {
|
||||
file: string
|
||||
selection: SelectedLineRange
|
||||
@@ -108,14 +232,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||
origin?: "review" | "file"
|
||||
}) => {
|
||||
const selection = selectionFromLines(input.selection)
|
||||
const preview =
|
||||
input.preview ??
|
||||
(() => {
|
||||
if (input.file === path()) return selectionPreview(contents(), selection)
|
||||
const source = file.get(input.file)?.content?.content
|
||||
if (!source) return undefined
|
||||
return selectionPreview(source, selection)
|
||||
})()
|
||||
const preview = input.preview ?? buildPreview(input.file, selection)
|
||||
|
||||
const saved = comments.add({
|
||||
file: input.file,
|
||||
@@ -140,8 +257,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||
comment: string
|
||||
}) => {
|
||||
comments.update(input.file, input.id, input.comment)
|
||||
const preview =
|
||||
input.file === path() ? selectionPreview(contents(), selectionFromLines(input.selection)) : undefined
|
||||
const preview = input.file === path() ? buildPreview(input.file, selectionFromLines(input.selection)) : undefined
|
||||
prompt.context.updateComment(input.file, input.id, {
|
||||
comment: input.comment,
|
||||
...(preview ? { preview } : {}),
|
||||
@@ -179,6 +295,9 @@ export function FileTabContent(props: { tab: string }) {
|
||||
comments: fileComments,
|
||||
label: language.t("ui.lineComment.submit"),
|
||||
draftKey: () => path() ?? props.tab,
|
||||
mention: {
|
||||
items: file.searchFilesAndDirectories,
|
||||
},
|
||||
state: {
|
||||
opened: () => note.openedComment,
|
||||
setOpened: (id) => setNote("openedComment", id),
|
||||
@@ -232,8 +351,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||
find?.focus()
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown, { capture: true })
|
||||
onCleanup(() => window.removeEventListener("keydown", onKeyDown, { capture: true }))
|
||||
makeEventListener(window, "keydown", onKeyDown, { capture: true })
|
||||
})
|
||||
|
||||
createEffect(
|
||||
@@ -260,102 +378,6 @@ export function FileTabContent(props: { tab: string }) {
|
||||
requestAnimationFrame(() => comments.clearFocus())
|
||||
})
|
||||
|
||||
const getCodeScroll = () => {
|
||||
const el = scroll
|
||||
if (!el) return []
|
||||
|
||||
const host = el.querySelector("diffs-container")
|
||||
if (!(host instanceof HTMLElement)) return []
|
||||
|
||||
const root = host.shadowRoot
|
||||
if (!root) return []
|
||||
|
||||
return Array.from(root.querySelectorAll("[data-code]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
|
||||
)
|
||||
}
|
||||
|
||||
const queueScrollUpdate = (next: { x: number; y: number }) => {
|
||||
pending = next
|
||||
if (scrollFrame !== undefined) return
|
||||
|
||||
scrollFrame = requestAnimationFrame(() => {
|
||||
scrollFrame = undefined
|
||||
|
||||
const out = pending
|
||||
pending = undefined
|
||||
if (!out) return
|
||||
|
||||
view().setScroll(props.tab, out)
|
||||
})
|
||||
}
|
||||
|
||||
const handleCodeScroll = (event: Event) => {
|
||||
const el = scroll
|
||||
if (!el) return
|
||||
|
||||
const target = event.currentTarget
|
||||
if (!(target instanceof HTMLElement)) return
|
||||
|
||||
queueScrollUpdate({
|
||||
x: target.scrollLeft,
|
||||
y: el.scrollTop,
|
||||
})
|
||||
}
|
||||
|
||||
const syncCodeScroll = () => {
|
||||
const next = getCodeScroll()
|
||||
if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return
|
||||
|
||||
for (const item of codeScroll) {
|
||||
item.removeEventListener("scroll", handleCodeScroll)
|
||||
}
|
||||
|
||||
codeScroll = next
|
||||
|
||||
for (const item of codeScroll) {
|
||||
item.addEventListener("scroll", handleCodeScroll)
|
||||
}
|
||||
}
|
||||
|
||||
const restoreScroll = () => {
|
||||
const el = scroll
|
||||
if (!el) return
|
||||
|
||||
const s = view().scroll(props.tab)
|
||||
if (!s) return
|
||||
|
||||
syncCodeScroll()
|
||||
|
||||
if (codeScroll.length > 0) {
|
||||
for (const item of codeScroll) {
|
||||
if (item.scrollLeft !== s.x) item.scrollLeft = s.x
|
||||
}
|
||||
}
|
||||
|
||||
if (el.scrollTop !== s.y) el.scrollTop = s.y
|
||||
if (codeScroll.length > 0) return
|
||||
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
|
||||
}
|
||||
|
||||
const queueRestore = () => {
|
||||
if (restoreFrame !== undefined) return
|
||||
|
||||
restoreFrame = requestAnimationFrame(() => {
|
||||
restoreFrame = undefined
|
||||
restoreScroll()
|
||||
})
|
||||
}
|
||||
|
||||
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||
if (codeScroll.length === 0) syncCodeScroll()
|
||||
|
||||
queueScrollUpdate({
|
||||
x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
|
||||
y: event.currentTarget.scrollTop,
|
||||
})
|
||||
}
|
||||
|
||||
const cancelCommenting = () => {
|
||||
const p = path()
|
||||
if (p) file.setSelectedLines(p, null)
|
||||
@@ -375,16 +397,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||
const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active)
|
||||
prev = { loaded, ready, active }
|
||||
if (!restore) return
|
||||
queueRestore()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
for (const item of codeScroll) {
|
||||
item.removeEventListener("scroll", handleCodeScroll)
|
||||
}
|
||||
|
||||
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
|
||||
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
|
||||
scrollSync.queueRestore()
|
||||
})
|
||||
|
||||
const renderFile = (source: string) => (
|
||||
@@ -402,7 +415,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||
selectedLines={activeSelection()}
|
||||
commentedLines={commentedLines()}
|
||||
onRendered={() => {
|
||||
queueRestore()
|
||||
scrollSync.queueRestore()
|
||||
}}
|
||||
annotations={commentsUi.annotations()}
|
||||
renderAnnotation={commentsUi.renderAnnotation}
|
||||
@@ -420,7 +433,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||
mode: "auto",
|
||||
path: path(),
|
||||
current: state()?.content,
|
||||
onLoad: queueRestore,
|
||||
onLoad: scrollSync.queueRestore,
|
||||
onError: (args: { kind: "image" | "audio" | "svg" }) => {
|
||||
if (args.kind !== "svg") return
|
||||
showToast({
|
||||
@@ -435,14 +448,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||
|
||||
return (
|
||||
<Tabs.Content value={props.tab} class="mt-3 relative h-full">
|
||||
<ScrollView
|
||||
class="h-full"
|
||||
viewportRef={(el: HTMLDivElement) => {
|
||||
scroll = el
|
||||
restoreScroll()
|
||||
}}
|
||||
onScroll={handleScroll as any}
|
||||
>
|
||||
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll as any}>
|
||||
<Switch>
|
||||
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
|
||||
<Match when={state()?.loading}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { same } from "@/utils/same"
|
||||
|
||||
const emptyTabs: string[] = []
|
||||
@@ -171,14 +172,9 @@ export const createSizing = () => {
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener("pointerup", stop)
|
||||
window.addEventListener("pointercancel", stop)
|
||||
window.addEventListener("blur", stop)
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("pointerup", stop)
|
||||
window.removeEventListener("pointercancel", stop)
|
||||
window.removeEventListener("blur", stop)
|
||||
})
|
||||
makeEventListener(window, "pointerup", stop)
|
||||
makeEventListener(window, "pointercancel", stop)
|
||||
makeEventListener(window, "blur", stop)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useSettings } from "@/context/settings"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
|
||||
import { makeTimer } from "@solid-primitives/timer"
|
||||
|
||||
@@ -43,7 +44,6 @@ type MessageComment = {
|
||||
|
||||
const emptyMessages: MessageType[] = []
|
||||
const idle = { type: "idle" as const }
|
||||
|
||||
type UserActions = {
|
||||
fork?: (input: { sessionID: string; messageID: string }) => Promise<void> | void
|
||||
revert?: (input: { sessionID: string; messageID: string }) => Promise<void> | void
|
||||
@@ -200,7 +200,7 @@ export function MessageTimeline(props: {
|
||||
mobileChanges: boolean
|
||||
mobileFallback: JSX.Element
|
||||
actions?: UserActions
|
||||
scroll: { overflow: boolean; bottom: boolean }
|
||||
scroll: { overflow: boolean; bottom: boolean; jump: boolean }
|
||||
onResumeScroll: () => void
|
||||
setScrollRef: (el: HTMLDivElement | undefined) => void
|
||||
onScheduleScrollState: (el: HTMLDivElement) => void
|
||||
@@ -291,6 +291,7 @@ export function MessageTimeline(props: {
|
||||
return sync.session.get(id)
|
||||
})
|
||||
const titleValue = createMemo(() => info()?.title)
|
||||
const titleLabel = createMemo(() => sessionTitle(titleValue()))
|
||||
const shareUrl = createMemo(() => info()?.share?.url)
|
||||
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
||||
const parentID = createMemo(() => info()?.parentID)
|
||||
@@ -399,7 +400,7 @@ export function MessageTimeline(props: {
|
||||
|
||||
const openTitleEditor = () => {
|
||||
if (!sessionID()) return
|
||||
setTitle({ editing: true, draft: titleValue() ?? "" })
|
||||
setTitle({ editing: true, draft: titleLabel() ?? "" })
|
||||
requestAnimationFrame(() => {
|
||||
titleRef?.focus()
|
||||
titleRef?.select()
|
||||
@@ -417,7 +418,7 @@ export function MessageTimeline(props: {
|
||||
if (titleMutation.isPending) return
|
||||
|
||||
const next = title.draft.trim()
|
||||
if (!next || next === (titleValue() ?? "")) {
|
||||
if (!next || next === (titleLabel() ?? "")) {
|
||||
setTitle("editing", false)
|
||||
return
|
||||
}
|
||||
@@ -532,7 +533,9 @@ export function MessageTimeline(props: {
|
||||
}
|
||||
|
||||
function DialogDeleteSession(props: { sessionID: string }) {
|
||||
const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
|
||||
const name = createMemo(
|
||||
() => sessionTitle(sync.session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
|
||||
)
|
||||
const handleDelete = async () => {
|
||||
await deleteSession(props.sessionID)
|
||||
dialog.close()
|
||||
@@ -568,10 +571,9 @@ export function MessageTimeline(props: {
|
||||
<div
|
||||
class="absolute left-1/2 -translate-x-1/2 bottom-6 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
||||
classList={{
|
||||
"opacity-100 translate-y-0 scale-100":
|
||||
props.scroll.overflow && !props.scroll.bottom && !staging.isStaging(),
|
||||
"opacity-100 translate-y-0 scale-100": props.scroll.overflow && props.scroll.jump && !staging.isStaging(),
|
||||
"opacity-0 translate-y-2 scale-95 pointer-events-none":
|
||||
!props.scroll.overflow || props.scroll.bottom || staging.isStaging(),
|
||||
!props.scroll.overflow || !props.scroll.jump || staging.isStaging(),
|
||||
}}
|
||||
>
|
||||
<button
|
||||
@@ -674,7 +676,7 @@ export function MessageTimeline(props: {
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={titleValue() || title.editing}>
|
||||
<Show when={titleLabel() || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
fallback={
|
||||
@@ -682,7 +684,7 @@ export function MessageTimeline(props: {
|
||||
class="text-14-medium text-text-strong truncate grow-1 min-w-0"
|
||||
onDblClick={openTitleEditor}
|
||||
>
|
||||
{titleValue()}
|
||||
{titleLabel()}
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createEffect, onCleanup, type JSX } from "solid-js"
|
||||
import { createEffect, createSignal, onCleanup, type JSX } from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import type { FileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { SessionReview } from "@opencode-ai/ui/session-review"
|
||||
import type {
|
||||
@@ -30,6 +31,9 @@ export interface SessionReviewTabProps {
|
||||
onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
|
||||
focusedFile?: string
|
||||
onScrollRef?: (el: HTMLDivElement) => void
|
||||
commentMentions?: {
|
||||
items: (query: string) => string[] | Promise<string[]>
|
||||
}
|
||||
classes?: {
|
||||
root?: string
|
||||
header?: string
|
||||
@@ -120,13 +124,6 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
|
||||
|
||||
onCleanup(() => {
|
||||
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 (
|
||||
@@ -135,11 +132,11 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
|
||||
empty={props.empty}
|
||||
scrollRef={(el) => {
|
||||
scroll = el
|
||||
el.addEventListener("wheel", handleInteraction, { passive: true, capture: true })
|
||||
el.addEventListener("mousewheel", handleInteraction, { passive: true, capture: true })
|
||||
el.addEventListener("pointerdown", handleInteraction, { passive: true, capture: true })
|
||||
el.addEventListener("touchstart", handleInteraction, { passive: true, capture: true })
|
||||
el.addEventListener("keydown", handleInteraction, { passive: true, capture: true })
|
||||
makeEventListener(el, "wheel", handleInteraction, { passive: true, capture: true })
|
||||
makeEventListener(el, "mousewheel", handleInteraction, { passive: true, capture: true })
|
||||
makeEventListener(el, "pointerdown", handleInteraction, { passive: true, capture: true })
|
||||
makeEventListener(el, "touchstart", handleInteraction, { passive: true, capture: true })
|
||||
makeEventListener(el, "keydown", handleInteraction, { capture: true })
|
||||
props.onScrollRef?.(el)
|
||||
queueRestore()
|
||||
}}
|
||||
@@ -162,6 +159,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
|
||||
onLineCommentUpdate={props.onLineCommentUpdate}
|
||||
onLineCommentDelete={props.onLineCommentDelete}
|
||||
lineCommentActions={props.lineCommentActions}
|
||||
lineCommentMention={props.commentMentions}
|
||||
comments={props.comments}
|
||||
focusedComment={props.focusedComment}
|
||||
onFocusedCommentChange={props.onFocusedCommentChange}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||
import type { FileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
|
||||
@@ -18,7 +19,6 @@ import { useCommand } from "@/context/command"
|
||||
import { useFile, type SelectedLineRange } from "@/context/file"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
||||
import { FileTabContent } from "@/pages/session/file-tabs"
|
||||
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
|
||||
@@ -26,6 +26,12 @@ import { setSessionHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
|
||||
export function SessionSidePanel(props: {
|
||||
canReview: () => boolean
|
||||
diffs: () => FileDiff[]
|
||||
diffsReady: () => boolean
|
||||
empty: () => string
|
||||
hasReview: () => boolean
|
||||
reviewCount: () => number
|
||||
reviewPanel: () => JSX.Element
|
||||
activeDiff?: string
|
||||
focusReviewDiff: (path: string) => void
|
||||
@@ -33,12 +39,11 @@ export function SessionSidePanel(props: {
|
||||
size: Sizing
|
||||
}) {
|
||||
const layout = useLayout()
|
||||
const sync = useSync()
|
||||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const dialog = useDialog()
|
||||
const { params, sessionKey, tabs, view } = useSessionLayout()
|
||||
const { sessionKey, tabs, view } = useSessionLayout()
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
|
||||
@@ -53,24 +58,7 @@ export function SessionSidePanel(props: {
|
||||
})
|
||||
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
|
||||
|
||||
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 diffFiles = createMemo(() => props.diffs().map((d) => d.file))
|
||||
const kinds = createMemo(() => {
|
||||
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
|
||||
if (!a) return b
|
||||
@@ -81,7 +69,7 @@ export function SessionSidePanel(props: {
|
||||
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
|
||||
|
||||
const out = new Map<string, "add" | "del" | "mix">()
|
||||
for (const diff of diffs()) {
|
||||
for (const diff of props.diffs()) {
|
||||
const file = normalize(diff.file)
|
||||
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
|
||||
|
||||
@@ -135,7 +123,7 @@ export function SessionSidePanel(props: {
|
||||
pathFromTab: file.pathFromTab,
|
||||
normalizeTab,
|
||||
review: reviewTab,
|
||||
hasReview,
|
||||
hasReview: props.canReview,
|
||||
})
|
||||
const contextOpen = tabState.contextOpen
|
||||
const openedTabs = tabState.openedTabs
|
||||
@@ -240,12 +228,12 @@ export function SessionSidePanel(props: {
|
||||
onCleanup(stop)
|
||||
}}
|
||||
>
|
||||
<Show when={reviewTab()}>
|
||||
<Show when={reviewTab() && props.canReview()}>
|
||||
<Tabs.Trigger value="review">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div>{language.t("session.tab.review")}</div>
|
||||
<Show when={hasReview()}>
|
||||
<div>{reviewCount()}</div>
|
||||
<Show when={props.hasReview()}>
|
||||
<div>{props.reviewCount()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
@@ -304,7 +292,7 @@ export function SessionSidePanel(props: {
|
||||
</Tabs.List>
|
||||
</div>
|
||||
|
||||
<Show when={reviewTab()}>
|
||||
<Show when={reviewTab() && props.canReview()}>
|
||||
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
|
||||
</Tabs.Content>
|
||||
@@ -378,8 +366,10 @@ export function SessionSidePanel(props: {
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
|
||||
{reviewCount()}{" "}
|
||||
{language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
|
||||
{props.reviewCount()}{" "}
|
||||
{language.t(
|
||||
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
|
||||
)}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
|
||||
{language.t("session.files.all")}
|
||||
@@ -387,9 +377,9 @@ export function SessionSidePanel(props: {
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
|
||||
<Switch>
|
||||
<Match when={hasReview()}>
|
||||
<Match when={props.hasReview() || !props.diffsReady()}>
|
||||
<Show
|
||||
when={diffsReady()}
|
||||
when={props.diffsReady()}
|
||||
fallback={
|
||||
<div class="px-2 py-2 text-12-regular text-text-weak">
|
||||
{language.t("common.loading")}
|
||||
@@ -408,11 +398,7 @@ export function SessionSidePanel(props: {
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
{empty(
|
||||
language.t(sync.project && !sync.project.vcs ? "session.review.noChanges" : reviewEmptyKey()),
|
||||
)}
|
||||
</Match>
|
||||
<Match when={true}>{empty(props.empty())}</Match>
|
||||
</Switch>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { For, Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
@@ -50,12 +51,8 @@ export function TerminalPanel() {
|
||||
const port = window.visualViewport
|
||||
|
||||
sync()
|
||||
window.addEventListener("resize", sync)
|
||||
port?.addEventListener("resize", sync)
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("resize", sync)
|
||||
port?.removeEventListener("resize", sync)
|
||||
})
|
||||
makeEventListener(window, "resize", sync)
|
||||
if (port) makeEventListener(port, "resize", sync)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
|
||||
@@ -52,11 +52,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
if (!id) return
|
||||
return sync.session.get(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 hasReview = () => !!params.id
|
||||
const normalizeTab = (tab: string) => {
|
||||
if (!tab.startsWith("file://")) return tab
|
||||
return file.tab(tab)
|
||||
@@ -128,380 +124,452 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory)
|
||||
return permission.isAutoAcceptingDirectory(sdk.directory)
|
||||
}
|
||||
command.register("session", () => {
|
||||
const share =
|
||||
sync.data.config.share === "disabled"
|
||||
? []
|
||||
: [
|
||||
sessionCommand({
|
||||
id: "session.share",
|
||||
title: info()?.share?.url
|
||||
? language.t("session.share.copy.copyLink")
|
||||
: language.t("command.session.share"),
|
||||
description: info()?.share?.url
|
||||
? language.t("toast.session.share.success.description")
|
||||
: language.t("command.session.share.description"),
|
||||
slash: "share",
|
||||
disabled: !params.id,
|
||||
onSelect: async () => {
|
||||
if (!params.id) return
|
||||
const write = async (value: string) => {
|
||||
const body = typeof document === "undefined" ? undefined : document.body
|
||||
if (body) {
|
||||
const textarea = document.createElement("textarea")
|
||||
textarea.value = value
|
||||
textarea.setAttribute("readonly", "")
|
||||
textarea.style.position = "fixed"
|
||||
textarea.style.opacity = "0"
|
||||
textarea.style.pointerEvents = "none"
|
||||
body.appendChild(textarea)
|
||||
textarea.select()
|
||||
const copied = document.execCommand("copy")
|
||||
body.removeChild(textarea)
|
||||
if (copied) return true
|
||||
}
|
||||
|
||||
const write = (value: string) => {
|
||||
const body = typeof document === "undefined" ? undefined : document.body
|
||||
if (body) {
|
||||
const textarea = document.createElement("textarea")
|
||||
textarea.value = value
|
||||
textarea.setAttribute("readonly", "")
|
||||
textarea.style.position = "fixed"
|
||||
textarea.style.opacity = "0"
|
||||
textarea.style.pointerEvents = "none"
|
||||
body.appendChild(textarea)
|
||||
textarea.select()
|
||||
const copied = document.execCommand("copy")
|
||||
body.removeChild(textarea)
|
||||
if (copied) return Promise.resolve(true)
|
||||
}
|
||||
const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
|
||||
if (!clipboard?.writeText) return false
|
||||
return clipboard.writeText(value).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
}
|
||||
|
||||
const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
|
||||
if (!clipboard?.writeText) return Promise.resolve(false)
|
||||
return clipboard.writeText(value).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
}
|
||||
const copyShare = async (url: string, existing: boolean) => {
|
||||
if (!(await write(url))) {
|
||||
showToast({
|
||||
title: language.t("toast.session.share.copyFailed.title"),
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const copy = async (url: string, existing: boolean) => {
|
||||
const ok = await write(url)
|
||||
if (!ok) {
|
||||
showToast({
|
||||
title: language.t("toast.session.share.copyFailed.title"),
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
showToast({
|
||||
title: existing ? language.t("session.share.copy.copied") : language.t("toast.session.share.success.title"),
|
||||
description: language.t("toast.session.share.success.description"),
|
||||
variant: "success",
|
||||
})
|
||||
}
|
||||
|
||||
showToast({
|
||||
title: existing
|
||||
? language.t("session.share.copy.copied")
|
||||
: language.t("toast.session.share.success.title"),
|
||||
description: language.t("toast.session.share.success.description"),
|
||||
variant: "success",
|
||||
})
|
||||
}
|
||||
const share = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
const existing = info()?.share?.url
|
||||
if (existing) {
|
||||
await copy(existing, true)
|
||||
return
|
||||
}
|
||||
const existing = info()?.share?.url
|
||||
if (existing) {
|
||||
await copyShare(existing, true)
|
||||
return
|
||||
}
|
||||
|
||||
const url = await sdk.client.session
|
||||
.share({ sessionID: params.id })
|
||||
.then((res) => res.data?.share?.url)
|
||||
.catch(() => undefined)
|
||||
if (!url) {
|
||||
showToast({
|
||||
title: language.t("toast.session.share.failed.title"),
|
||||
description: language.t("toast.session.share.failed.description"),
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
const url = await sdk.client.session
|
||||
.share({ sessionID })
|
||||
.then((res) => res.data?.share?.url)
|
||||
.catch(() => undefined)
|
||||
if (!url) {
|
||||
showToast({
|
||||
title: language.t("toast.session.share.failed.title"),
|
||||
description: language.t("toast.session.share.failed.description"),
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await copy(url, false)
|
||||
},
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.unshare",
|
||||
title: language.t("command.session.unshare"),
|
||||
description: language.t("command.session.unshare.description"),
|
||||
slash: "unshare",
|
||||
disabled: !params.id || !info()?.share?.url,
|
||||
onSelect: async () => {
|
||||
if (!params.id) return
|
||||
await sdk.client.session
|
||||
.unshare({ sessionID: params.id })
|
||||
.then(() =>
|
||||
showToast({
|
||||
title: language.t("toast.session.unshare.success.title"),
|
||||
description: language.t("toast.session.unshare.success.description"),
|
||||
variant: "success",
|
||||
}),
|
||||
)
|
||||
.catch(() =>
|
||||
showToast({
|
||||
title: language.t("toast.session.unshare.failed.title"),
|
||||
description: language.t("toast.session.unshare.failed.description"),
|
||||
variant: "error",
|
||||
}),
|
||||
)
|
||||
},
|
||||
}),
|
||||
]
|
||||
await copyShare(url, false)
|
||||
}
|
||||
|
||||
const unshare = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
await sdk.client.session
|
||||
.unshare({ sessionID })
|
||||
.then(() =>
|
||||
showToast({
|
||||
title: language.t("toast.session.unshare.success.title"),
|
||||
description: language.t("toast.session.unshare.success.description"),
|
||||
variant: "success",
|
||||
}),
|
||||
)
|
||||
.catch(() =>
|
||||
showToast({
|
||||
title: language.t("toast.session.unshare.failed.title"),
|
||||
description: language.t("toast.session.unshare.failed.description"),
|
||||
variant: "error",
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const openFile = () => {
|
||||
void import("@/components/dialog-select-file").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />)
|
||||
})
|
||||
}
|
||||
|
||||
const closeTab = () => {
|
||||
const tab = closableTab()
|
||||
if (!tab) return
|
||||
tabs().close(tab)
|
||||
}
|
||||
|
||||
const addSelection = () => {
|
||||
const tab = activeFileTab()
|
||||
if (!tab) return
|
||||
|
||||
const path = file.pathFromTab(tab)
|
||||
if (!path) return
|
||||
|
||||
const range = file.selectedLines(path) as SelectedLineRange | null | undefined
|
||||
if (!range) {
|
||||
showToast({
|
||||
title: language.t("toast.context.noLineSelection.title"),
|
||||
description: language.t("toast.context.noLineSelection.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
addSelectionToContext(path, selectionFromLines(range))
|
||||
}
|
||||
|
||||
const openTerminal = () => {
|
||||
if (terminal.all().length > 0) terminal.new()
|
||||
view().terminal.open()
|
||||
}
|
||||
|
||||
const chooseModel = () => {
|
||||
void import("@/components/dialog-select-model").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectModel model={local.model} />)
|
||||
})
|
||||
}
|
||||
|
||||
const chooseMcp = () => {
|
||||
void import("@/components/dialog-select-mcp").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectMcp />)
|
||||
})
|
||||
}
|
||||
|
||||
const toggleAutoAccept = () => {
|
||||
const sessionID = params.id
|
||||
if (sessionID) permission.toggleAutoAccept(sessionID, sdk.directory)
|
||||
else permission.toggleAutoAcceptDirectory(sdk.directory)
|
||||
|
||||
const active = sessionID
|
||||
? permission.isAutoAccepting(sessionID, sdk.directory)
|
||||
: permission.isAutoAcceptingDirectory(sdk.directory)
|
||||
showToast({
|
||||
title: active
|
||||
? language.t("toast.permissions.autoaccept.on.title")
|
||||
: language.t("toast.permissions.autoaccept.off.title"),
|
||||
description: active
|
||||
? language.t("toast.permissions.autoaccept.on.description")
|
||||
: language.t("toast.permissions.autoaccept.off.description"),
|
||||
})
|
||||
}
|
||||
|
||||
const undo = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
if (status().type !== "idle") {
|
||||
await sdk.client.session.abort({ sessionID }).catch(() => {})
|
||||
}
|
||||
|
||||
const revert = info()?.revert?.messageID
|
||||
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
|
||||
if (!message) return
|
||||
|
||||
await sdk.client.session.revert({ sessionID, messageID: message.id })
|
||||
const parts = sync.data.part[message.id]
|
||||
if (parts) {
|
||||
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
|
||||
prompt.set(restored)
|
||||
}
|
||||
|
||||
const prev = findLast(userMessages(), (x) => x.id < message.id)
|
||||
setActiveMessage(prev)
|
||||
}
|
||||
|
||||
const redo = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
const revertMessageID = info()?.revert?.messageID
|
||||
if (!revertMessageID) return
|
||||
|
||||
const next = userMessages().find((x) => x.id > revertMessageID)
|
||||
if (!next) {
|
||||
await sdk.client.session.unrevert({ sessionID })
|
||||
prompt.reset()
|
||||
const last = findLast(userMessages(), (x) => x.id >= revertMessageID)
|
||||
setActiveMessage(last)
|
||||
return
|
||||
}
|
||||
|
||||
await sdk.client.session.revert({ sessionID, messageID: next.id })
|
||||
const prev = findLast(userMessages(), (x) => x.id < next.id)
|
||||
setActiveMessage(prev)
|
||||
}
|
||||
|
||||
const compact = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
const model = local.model.current()
|
||||
if (!model) {
|
||||
showToast({
|
||||
title: language.t("toast.model.none.title"),
|
||||
description: language.t("toast.model.none.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await sdk.client.session.summarize({
|
||||
sessionID,
|
||||
modelID: model.id,
|
||||
providerID: model.provider.id,
|
||||
})
|
||||
}
|
||||
|
||||
const fork = () => {
|
||||
void import("@/components/dialog-fork").then((x) => {
|
||||
dialog.show(() => <x.DialogFork />)
|
||||
})
|
||||
}
|
||||
|
||||
const shareCmds = () => {
|
||||
if (sync.data.config.share === "disabled") return []
|
||||
return [
|
||||
sessionCommand({
|
||||
id: "session.new",
|
||||
title: language.t("command.session.new"),
|
||||
keybind: "mod+shift+s",
|
||||
slash: "new",
|
||||
onSelect: () => navigate(`/${params.dir}/session`),
|
||||
}),
|
||||
fileCommand({
|
||||
id: "file.open",
|
||||
title: language.t("command.file.open"),
|
||||
description: language.t("palette.search.placeholder"),
|
||||
keybind: "mod+k,mod+p",
|
||||
slash: "open",
|
||||
onSelect: () => {
|
||||
void import("@/components/dialog-select-file").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />)
|
||||
})
|
||||
},
|
||||
}),
|
||||
fileCommand({
|
||||
id: "tab.close",
|
||||
title: language.t("command.tab.close"),
|
||||
keybind: "mod+w",
|
||||
disabled: !closableTab(),
|
||||
onSelect: () => {
|
||||
const tab = closableTab()
|
||||
if (!tab) return
|
||||
tabs().close(tab)
|
||||
},
|
||||
}),
|
||||
contextCommand({
|
||||
id: "context.addSelection",
|
||||
title: language.t("command.context.addSelection"),
|
||||
description: language.t("command.context.addSelection.description"),
|
||||
keybind: "mod+shift+l",
|
||||
disabled: !canAddSelectionContext(),
|
||||
onSelect: () => {
|
||||
const tab = activeFileTab()
|
||||
if (!tab) return
|
||||
const path = file.pathFromTab(tab)
|
||||
if (!path) return
|
||||
|
||||
const range = file.selectedLines(path) as SelectedLineRange | null | undefined
|
||||
if (!range) {
|
||||
showToast({
|
||||
title: language.t("toast.context.noLineSelection.title"),
|
||||
description: language.t("toast.context.noLineSelection.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
addSelectionToContext(path, selectionFromLines(range))
|
||||
},
|
||||
}),
|
||||
viewCommand({
|
||||
id: "terminal.toggle",
|
||||
title: language.t("command.terminal.toggle"),
|
||||
keybind: "ctrl+`",
|
||||
slash: "terminal",
|
||||
onSelect: () => view().terminal.toggle(),
|
||||
}),
|
||||
viewCommand({
|
||||
id: "review.toggle",
|
||||
title: language.t("command.review.toggle"),
|
||||
keybind: "mod+shift+r",
|
||||
onSelect: () => view().reviewPanel.toggle(),
|
||||
}),
|
||||
viewCommand({
|
||||
id: "fileTree.toggle",
|
||||
title: language.t("command.fileTree.toggle"),
|
||||
keybind: "mod+\\",
|
||||
onSelect: () => layout.fileTree.toggle(),
|
||||
}),
|
||||
viewCommand({
|
||||
id: "input.focus",
|
||||
title: language.t("command.input.focus"),
|
||||
keybind: "ctrl+l",
|
||||
onSelect: focusInput,
|
||||
}),
|
||||
terminalCommand({
|
||||
id: "terminal.new",
|
||||
title: language.t("command.terminal.new"),
|
||||
description: language.t("command.terminal.new.description"),
|
||||
keybind: "ctrl+alt+t",
|
||||
onSelect: () => {
|
||||
if (terminal.all().length > 0) terminal.new()
|
||||
view().terminal.open()
|
||||
},
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "message.previous",
|
||||
title: language.t("command.message.previous"),
|
||||
description: language.t("command.message.previous.description"),
|
||||
keybind: "mod+alt+[",
|
||||
id: "session.share",
|
||||
title: info()?.share?.url ? language.t("session.share.copy.copyLink") : language.t("command.session.share"),
|
||||
description: info()?.share?.url
|
||||
? language.t("toast.session.share.success.description")
|
||||
: language.t("command.session.share.description"),
|
||||
slash: "share",
|
||||
disabled: !params.id,
|
||||
onSelect: () => navigateMessageByOffset(-1),
|
||||
onSelect: share,
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "message.next",
|
||||
title: language.t("command.message.next"),
|
||||
description: language.t("command.message.next.description"),
|
||||
keybind: "mod+alt+]",
|
||||
disabled: !params.id,
|
||||
onSelect: () => navigateMessageByOffset(1),
|
||||
id: "session.unshare",
|
||||
title: language.t("command.session.unshare"),
|
||||
description: language.t("command.session.unshare.description"),
|
||||
slash: "unshare",
|
||||
disabled: !params.id || !info()?.share?.url,
|
||||
onSelect: unshare,
|
||||
}),
|
||||
modelCommand({
|
||||
id: "model.choose",
|
||||
title: language.t("command.model.choose"),
|
||||
description: language.t("command.model.choose.description"),
|
||||
keybind: "mod+'",
|
||||
slash: "model",
|
||||
onSelect: () => {
|
||||
void import("@/components/dialog-select-model").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectModel model={local.model} />)
|
||||
})
|
||||
},
|
||||
}),
|
||||
mcpCommand({
|
||||
id: "mcp.toggle",
|
||||
title: language.t("command.mcp.toggle"),
|
||||
description: language.t("command.mcp.toggle.description"),
|
||||
keybind: "mod+;",
|
||||
slash: "mcp",
|
||||
onSelect: () => {
|
||||
void import("@/components/dialog-select-mcp").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectMcp />)
|
||||
})
|
||||
},
|
||||
}),
|
||||
agentCommand({
|
||||
id: "agent.cycle",
|
||||
title: language.t("command.agent.cycle"),
|
||||
description: language.t("command.agent.cycle.description"),
|
||||
keybind: "mod+.",
|
||||
slash: "agent",
|
||||
onSelect: () => local.agent.move(1),
|
||||
}),
|
||||
agentCommand({
|
||||
id: "agent.cycle.reverse",
|
||||
title: language.t("command.agent.cycle.reverse"),
|
||||
description: language.t("command.agent.cycle.reverse.description"),
|
||||
keybind: "shift+mod+.",
|
||||
onSelect: () => local.agent.move(-1),
|
||||
}),
|
||||
modelCommand({
|
||||
id: "model.variant.cycle",
|
||||
title: language.t("command.model.variant.cycle"),
|
||||
description: language.t("command.model.variant.cycle.description"),
|
||||
keybind: "shift+mod+d",
|
||||
onSelect: () => local.model.variant.cycle(),
|
||||
}),
|
||||
permissionsCommand({
|
||||
id: "permissions.autoaccept",
|
||||
title: isAutoAcceptActive()
|
||||
? language.t("command.permissions.autoaccept.disable")
|
||||
: language.t("command.permissions.autoaccept.enable"),
|
||||
keybind: "mod+shift+a",
|
||||
disabled: false,
|
||||
onSelect: () => {
|
||||
const sessionID = params.id
|
||||
if (sessionID) permission.toggleAutoAccept(sessionID, sdk.directory)
|
||||
else permission.toggleAutoAcceptDirectory(sdk.directory)
|
||||
|
||||
const active = sessionID
|
||||
? permission.isAutoAccepting(sessionID, sdk.directory)
|
||||
: permission.isAutoAcceptingDirectory(sdk.directory)
|
||||
showToast({
|
||||
title: active
|
||||
? language.t("toast.permissions.autoaccept.on.title")
|
||||
: language.t("toast.permissions.autoaccept.off.title"),
|
||||
description: active
|
||||
? language.t("toast.permissions.autoaccept.on.description")
|
||||
: language.t("toast.permissions.autoaccept.off.description"),
|
||||
})
|
||||
},
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.undo",
|
||||
title: language.t("command.session.undo"),
|
||||
description: language.t("command.session.undo.description"),
|
||||
slash: "undo",
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
onSelect: async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
if (status().type !== "idle") {
|
||||
await sdk.client.session.abort({ sessionID }).catch(() => {})
|
||||
}
|
||||
const revert = info()?.revert?.messageID
|
||||
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
|
||||
if (!message) return
|
||||
await sdk.client.session.revert({ sessionID, messageID: message.id })
|
||||
const parts = sync.data.part[message.id]
|
||||
if (parts) {
|
||||
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
|
||||
prompt.set(restored)
|
||||
}
|
||||
const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
|
||||
setActiveMessage(priorMessage)
|
||||
},
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.redo",
|
||||
title: language.t("command.session.redo"),
|
||||
description: language.t("command.session.redo.description"),
|
||||
slash: "redo",
|
||||
disabled: !params.id || !info()?.revert?.messageID,
|
||||
onSelect: async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
const revertMessageID = info()?.revert?.messageID
|
||||
if (!revertMessageID) return
|
||||
const nextMessage = userMessages().find((x) => x.id > revertMessageID)
|
||||
if (!nextMessage) {
|
||||
await sdk.client.session.unrevert({ sessionID })
|
||||
prompt.reset()
|
||||
const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
|
||||
setActiveMessage(lastMsg)
|
||||
return
|
||||
}
|
||||
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
|
||||
const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
|
||||
setActiveMessage(priorMsg)
|
||||
},
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.compact",
|
||||
title: language.t("command.session.compact"),
|
||||
description: language.t("command.session.compact.description"),
|
||||
slash: "compact",
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
onSelect: async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
const model = local.model.current()
|
||||
if (!model) {
|
||||
showToast({
|
||||
title: language.t("toast.model.none.title"),
|
||||
description: language.t("toast.model.none.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
await sdk.client.session.summarize({
|
||||
sessionID,
|
||||
modelID: model.id,
|
||||
providerID: model.provider.id,
|
||||
})
|
||||
},
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.fork",
|
||||
title: language.t("command.session.fork"),
|
||||
description: language.t("command.session.fork.description"),
|
||||
slash: "fork",
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
onSelect: () => {
|
||||
void import("@/components/dialog-fork").then((x) => {
|
||||
dialog.show(() => <x.DialogFork />)
|
||||
})
|
||||
},
|
||||
}),
|
||||
...share,
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const sessionCmds = () => [
|
||||
sessionCommand({
|
||||
id: "session.new",
|
||||
title: language.t("command.session.new"),
|
||||
keybind: "mod+shift+s",
|
||||
slash: "new",
|
||||
onSelect: () => navigate(`/${params.dir}/session`),
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.undo",
|
||||
title: language.t("command.session.undo"),
|
||||
description: language.t("command.session.undo.description"),
|
||||
slash: "undo",
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
onSelect: undo,
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.redo",
|
||||
title: language.t("command.session.redo"),
|
||||
description: language.t("command.session.redo.description"),
|
||||
slash: "redo",
|
||||
disabled: !params.id || !info()?.revert?.messageID,
|
||||
onSelect: redo,
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.compact",
|
||||
title: language.t("command.session.compact"),
|
||||
description: language.t("command.session.compact.description"),
|
||||
slash: "compact",
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
onSelect: compact,
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.fork",
|
||||
title: language.t("command.session.fork"),
|
||||
description: language.t("command.session.fork.description"),
|
||||
slash: "fork",
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
onSelect: fork,
|
||||
}),
|
||||
]
|
||||
|
||||
const fileCmds = () => [
|
||||
fileCommand({
|
||||
id: "file.open",
|
||||
title: language.t("command.file.open"),
|
||||
description: language.t("palette.search.placeholder"),
|
||||
keybind: "mod+k,mod+p",
|
||||
slash: "open",
|
||||
onSelect: openFile,
|
||||
}),
|
||||
fileCommand({
|
||||
id: "tab.close",
|
||||
title: language.t("command.tab.close"),
|
||||
keybind: "mod+w",
|
||||
disabled: !closableTab(),
|
||||
onSelect: closeTab,
|
||||
}),
|
||||
]
|
||||
|
||||
const contextCmds = () => [
|
||||
contextCommand({
|
||||
id: "context.addSelection",
|
||||
title: language.t("command.context.addSelection"),
|
||||
description: language.t("command.context.addSelection.description"),
|
||||
keybind: "mod+shift+l",
|
||||
disabled: !canAddSelectionContext(),
|
||||
onSelect: addSelection,
|
||||
}),
|
||||
]
|
||||
|
||||
const viewCmds = () => [
|
||||
viewCommand({
|
||||
id: "terminal.toggle",
|
||||
title: language.t("command.terminal.toggle"),
|
||||
keybind: "ctrl+`",
|
||||
slash: "terminal",
|
||||
onSelect: () => view().terminal.toggle(),
|
||||
}),
|
||||
viewCommand({
|
||||
id: "review.toggle",
|
||||
title: language.t("command.review.toggle"),
|
||||
keybind: "mod+shift+r",
|
||||
onSelect: () => view().reviewPanel.toggle(),
|
||||
}),
|
||||
viewCommand({
|
||||
id: "fileTree.toggle",
|
||||
title: language.t("command.fileTree.toggle"),
|
||||
keybind: "mod+\\",
|
||||
onSelect: () => layout.fileTree.toggle(),
|
||||
}),
|
||||
viewCommand({
|
||||
id: "input.focus",
|
||||
title: language.t("command.input.focus"),
|
||||
keybind: "ctrl+l",
|
||||
onSelect: focusInput,
|
||||
}),
|
||||
]
|
||||
|
||||
const terminalCmds = () => [
|
||||
terminalCommand({
|
||||
id: "terminal.new",
|
||||
title: language.t("command.terminal.new"),
|
||||
description: language.t("command.terminal.new.description"),
|
||||
keybind: "ctrl+alt+t",
|
||||
onSelect: openTerminal,
|
||||
}),
|
||||
]
|
||||
|
||||
const messageCmds = () => [
|
||||
sessionCommand({
|
||||
id: "message.previous",
|
||||
title: language.t("command.message.previous"),
|
||||
description: language.t("command.message.previous.description"),
|
||||
keybind: "mod+alt+[",
|
||||
disabled: !params.id,
|
||||
onSelect: () => navigateMessageByOffset(-1),
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "message.next",
|
||||
title: language.t("command.message.next"),
|
||||
description: language.t("command.message.next.description"),
|
||||
keybind: "mod+alt+]",
|
||||
disabled: !params.id,
|
||||
onSelect: () => navigateMessageByOffset(1),
|
||||
}),
|
||||
]
|
||||
|
||||
const modelCmds = () => [
|
||||
modelCommand({
|
||||
id: "model.choose",
|
||||
title: language.t("command.model.choose"),
|
||||
description: language.t("command.model.choose.description"),
|
||||
keybind: "mod+'",
|
||||
slash: "model",
|
||||
onSelect: chooseModel,
|
||||
}),
|
||||
modelCommand({
|
||||
id: "model.variant.cycle",
|
||||
title: language.t("command.model.variant.cycle"),
|
||||
description: language.t("command.model.variant.cycle.description"),
|
||||
keybind: "shift+mod+d",
|
||||
onSelect: () => local.model.variant.cycle(),
|
||||
}),
|
||||
]
|
||||
|
||||
const mcpCmds = () => [
|
||||
mcpCommand({
|
||||
id: "mcp.toggle",
|
||||
title: language.t("command.mcp.toggle"),
|
||||
description: language.t("command.mcp.toggle.description"),
|
||||
keybind: "mod+;",
|
||||
slash: "mcp",
|
||||
onSelect: chooseMcp,
|
||||
}),
|
||||
]
|
||||
|
||||
const agentCmds = () => [
|
||||
agentCommand({
|
||||
id: "agent.cycle",
|
||||
title: language.t("command.agent.cycle"),
|
||||
description: language.t("command.agent.cycle.description"),
|
||||
keybind: "mod+.",
|
||||
slash: "agent",
|
||||
onSelect: () => local.agent.move(1),
|
||||
}),
|
||||
agentCommand({
|
||||
id: "agent.cycle.reverse",
|
||||
title: language.t("command.agent.cycle.reverse"),
|
||||
description: language.t("command.agent.cycle.reverse.description"),
|
||||
keybind: "shift+mod+.",
|
||||
onSelect: () => local.agent.move(-1),
|
||||
}),
|
||||
]
|
||||
|
||||
const permissionsCmds = () => [
|
||||
permissionsCommand({
|
||||
id: "permissions.autoaccept",
|
||||
title: isAutoAcceptActive()
|
||||
? language.t("command.permissions.autoaccept.disable")
|
||||
: language.t("command.permissions.autoaccept.enable"),
|
||||
keybind: "mod+shift+a",
|
||||
disabled: false,
|
||||
onSelect: toggleAutoAccept,
|
||||
}),
|
||||
]
|
||||
|
||||
command.register("session", () => [
|
||||
...sessionCmds(),
|
||||
...shareCmds(),
|
||||
...fileCmds(),
|
||||
...contextCmds(),
|
||||
...viewCmds(),
|
||||
...terminalCmds(),
|
||||
...messageCmds(),
|
||||
...modelCmds(),
|
||||
...mcpCmds(),
|
||||
...agentCmds(),
|
||||
...permissionsCmds(),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -10,6 +10,13 @@ export type PromptProbeState = {
|
||||
selects: number
|
||||
}
|
||||
|
||||
export type PromptSendState = {
|
||||
started: number
|
||||
count: number
|
||||
sessionID?: string
|
||||
directory?: string
|
||||
}
|
||||
|
||||
export const promptEnabled = () => {
|
||||
if (typeof window === "undefined") return false
|
||||
return (window as E2EWindow).__opencode_e2e?.prompt?.enabled === true
|
||||
@@ -53,4 +60,24 @@ export const promptProbe = {
|
||||
if (!state) return
|
||||
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,6 +23,7 @@ export type E2EWindow = Window & {
|
||||
prompt?: {
|
||||
enabled?: boolean
|
||||
current?: import("./prompt").PromptProbeState
|
||||
sent?: import("./prompt").PromptSendState
|
||||
}
|
||||
terminal?: {
|
||||
enabled?: boolean
|
||||
|
||||
7
packages/app/src/utils/session-title.ts
Normal file
7
packages/app/src/utils/session-title.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
const pattern = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
|
||||
|
||||
export function sessionTitle(title?: string) {
|
||||
if (!title) return title
|
||||
const match = title.match(pattern)
|
||||
return match?.[1] ?? title
|
||||
}
|
||||
66
packages/app/test/e2e/mock.test.ts
Normal file
66
packages/app/test/e2e/mock.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { bodyText, inputMatch, promptMatch } from "../../e2e/prompt/mock"
|
||||
|
||||
function hit(body: Record<string, unknown>) {
|
||||
return { body }
|
||||
}
|
||||
|
||||
describe("promptMatch", () => {
|
||||
test("matches token in serialized body", () => {
|
||||
const match = promptMatch("hello")
|
||||
expect(match(hit({ messages: [{ role: "user", content: "say hello" }] }))).toBe(true)
|
||||
expect(match(hit({ messages: [{ role: "user", content: "say goodbye" }] }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("inputMatch", () => {
|
||||
test("matches exact tool input in chat completions body", () => {
|
||||
const input = { questions: [{ header: "Need input", question: "Pick one" }] }
|
||||
const match = inputMatch(input)
|
||||
|
||||
// The seed prompt embeds JSON.stringify(input) in the user message
|
||||
const prompt = `Use this JSON input: ${JSON.stringify(input)}`
|
||||
const body = { messages: [{ role: "user", content: prompt }] }
|
||||
expect(match(hit(body))).toBe(true)
|
||||
})
|
||||
|
||||
test("matches exact tool input in responses API body", () => {
|
||||
const input = { questions: [{ header: "Need input", question: "Pick one" }] }
|
||||
const match = inputMatch(input)
|
||||
|
||||
const prompt = `Use this JSON input: ${JSON.stringify(input)}`
|
||||
const body = { model: "test", input: [{ role: "user", content: [{ type: "input_text", text: prompt }] }] }
|
||||
expect(match(hit(body))).toBe(true)
|
||||
})
|
||||
|
||||
test("matches patchText with newlines", () => {
|
||||
const patchText = "*** Begin Patch\n*** Add File: test.txt\n+line1\n*** End Patch"
|
||||
const match = inputMatch({ patchText })
|
||||
|
||||
const prompt = `Use this JSON input: ${JSON.stringify({ patchText })}`
|
||||
const body = { messages: [{ role: "user", content: prompt }] }
|
||||
expect(match(hit(body))).toBe(true)
|
||||
|
||||
// Also works in responses API format
|
||||
const respBody = { model: "test", input: [{ role: "user", content: [{ type: "input_text", text: prompt }] }] }
|
||||
expect(match(hit(respBody))).toBe(true)
|
||||
})
|
||||
|
||||
test("does not match unrelated requests", () => {
|
||||
const input = { questions: [{ header: "Need input" }] }
|
||||
const match = inputMatch(input)
|
||||
|
||||
expect(match(hit({ messages: [{ role: "user", content: "hello" }] }))).toBe(false)
|
||||
expect(match(hit({ model: "test", input: [] }))).toBe(false)
|
||||
})
|
||||
|
||||
test("does not match partial input", () => {
|
||||
const input = { questions: [{ header: "Need input", question: "Pick one" }] }
|
||||
const match = inputMatch(input)
|
||||
|
||||
// Only header, missing question
|
||||
const partial = `Use this JSON input: ${JSON.stringify({ questions: [{ header: "Need input" }] })}`
|
||||
const body = { messages: [{ role: "user", content: partial }] }
|
||||
expect(match(hit(body))).toBe(false)
|
||||
})
|
||||
})
|
||||
27
packages/app/test/e2e/no-real-llm.test.ts
Normal file
27
packages/app/test/e2e/no-real-llm.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
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",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.13",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -204,6 +204,14 @@ 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>) {
|
||||
return (
|
||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
|
||||
@@ -249,7 +249,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | نماذج برمجة منخفضة التكلفة للجميع",
|
||||
"go.meta.description":
|
||||
"يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5 و Kimi K2.5 و MiniMax M2.5 وMiniMax M2.7.",
|
||||
"يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5 و Kimi K2.5 وMiMo-V2-Pro وMiMo-V2-Omni و MiniMax M2.5 وMiniMax M2.7.",
|
||||
"go.hero.title": "نماذج برمجة منخفضة التكلفة للجميع",
|
||||
"go.hero.body":
|
||||
"يجلب Go البرمجة الوكيلة للمبرمجين حول العالم. يوفر حدودًا سخية ووصولًا موثوقًا إلى أقوى النماذج مفتوحة المصدر، حتى تتمكن من البناء باستخدام وكلاء أقوياء دون القلق بشأن التكلفة أو التوفر.",
|
||||
@@ -297,7 +297,7 @@ export const dict = {
|
||||
"go.problem.item1": "أسعار اشتراك منخفضة التكلفة",
|
||||
"go.problem.item2": "حدود سخية ووصول موثوق",
|
||||
"go.problem.item3": "مصمم لأكبر عدد ممكن من المبرمجين",
|
||||
"go.problem.item4": "يتضمن GLM-5 وKimi K2.5 وMiniMax M2.5 وMiniMax M2.7",
|
||||
"go.problem.item4": "يتضمن GLM-5 وKimi K2.5 وMiMo-V2-Pro وMiMo-V2-Omni وMiniMax M2.5 وMiniMax M2.7",
|
||||
"go.how.title": "كيف يعمل Go",
|
||||
"go.how.body": "يبدأ Go من $5 للشهر الأول، ثم $10/شهر. يمكنك استخدامه مع OpenCode أو أي وكيل.",
|
||||
"go.how.step1.title": "أنشئ حسابًا",
|
||||
@@ -318,10 +318,11 @@ export const dict = {
|
||||
"go.faq.q1": "ما هو OpenCode Go؟",
|
||||
"go.faq.a1": "Go هو اشتراك منخفض التكلفة يمنحك وصولًا موثوقًا إلى نماذج مفتوحة المصدر قادرة على البرمجة الوكيلة.",
|
||||
"go.faq.q2": "ما النماذج التي يتضمنها Go؟",
|
||||
"go.faq.a2": "يتضمن Go نماذج GLM-5 وKimi K2.5 وMiniMax M2.5 وMiniMax M2.7، مع حدود سخية ووصول موثوق.",
|
||||
"go.faq.a2":
|
||||
"يتضمن Go نماذج GLM-5 وKimi K2.5 وMiMo-V2-Pro وMiMo-V2-Omni وMiniMax M2.5 وMiniMax M2.7، مع حدود سخية ووصول موثوق.",
|
||||
"go.faq.q3": "هل Go هو نفسه Zen؟",
|
||||
"go.faq.a3":
|
||||
"لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح GLM-5 و Kimi K2.5 و MiniMax M2.5 وMiniMax M2.7.",
|
||||
"لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح GLM-5 و Kimi K2.5 وMiMo-V2-Pro وMiMo-V2-Omni و MiniMax M2.5 وMiniMax M2.7.",
|
||||
"go.faq.q4": "كم تكلفة Go؟",
|
||||
"go.faq.a4.p1.beforePricing": "تكلفة Go",
|
||||
"go.faq.a4.p1.pricingLink": "$5 للشهر الأول",
|
||||
@@ -344,7 +345,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "ما الفرق بين النماذج المجانية وGo؟",
|
||||
"go.faq.a9":
|
||||
"تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج GLM-5 وKimi K2.5 وMiniMax M2.5 وMiniMax M2.7 مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).",
|
||||
"تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج GLM-5 وKimi K2.5 وMiMo-V2-Pro وMiMo-V2-Omni وMiniMax M2.5 وMiniMax M2.7 مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "تم تجاوز حد الطلبات. يرجى المحاولة مرة أخرى لاحقًا.",
|
||||
"zen.api.error.modelNotSupported": "النموذج {{model}} غير مدعوم",
|
||||
@@ -363,6 +364,8 @@ export const dict = {
|
||||
"zen.api.error.userMonthlyLimitReached":
|
||||
"لقد وصلت إلى حد الإنفاق الشهري البالغ ${{amount}}. إدارة حدودك هنا: {{membersUrl}}",
|
||||
"zen.api.error.modelDisabled": "النموذج معطل",
|
||||
"zen.api.error.trialEnded":
|
||||
"انتهى العرض المجاني لـ {{model}}. يمكنك مواصلة استخدام النموذج بالاشتراك في OpenCode Go - {{link}}",
|
||||
|
||||
"black.meta.title": "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.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, Kimi K2.5, 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, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 e MiniMax M2.7.",
|
||||
"go.hero.title": "Modelos de codificação de baixo custo para todos",
|
||||
"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.",
|
||||
@@ -302,7 +302,7 @@ export const dict = {
|
||||
"go.problem.item1": "Preço de assinatura de baixo custo",
|
||||
"go.problem.item2": "Limites generosos e acesso confiável",
|
||||
"go.problem.item3": "Feito para o maior número possível de programadores",
|
||||
"go.problem.item4": "Inclui GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7",
|
||||
"go.problem.item4": "Inclui GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 e MiniMax M2.7",
|
||||
"go.how.title": "Como o Go funciona",
|
||||
"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.",
|
||||
@@ -325,10 +325,11 @@ export const dict = {
|
||||
"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.faq.q2": "Quais modelos o Go inclui?",
|
||||
"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.faq.a2":
|
||||
"Go inclui 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.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, Kimi K2.5, 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, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 e MiniMax M2.7.",
|
||||
"go.faq.q4": "Quanto custa o Go?",
|
||||
"go.faq.a4.p1.beforePricing": "O Go custa",
|
||||
"go.faq.a4.p1.pricingLink": "$5 no primeiro mês",
|
||||
@@ -352,7 +353,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "Qual a diferença entre os modelos gratuitos e o Go?",
|
||||
"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, 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).",
|
||||
"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, 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).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Limite de taxa excedido. Por favor, tente novamente mais tarde.",
|
||||
"zen.api.error.modelNotSupported": "Modelo {{model}} não suportado",
|
||||
@@ -371,6 +372,8 @@ export const dict = {
|
||||
"zen.api.error.userMonthlyLimitReached":
|
||||
"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.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.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.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, Kimi K2.5, 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, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 og MiniMax M2.7.",
|
||||
"go.hero.title": "Kodningsmodeller til lav pris for alle",
|
||||
"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.",
|
||||
@@ -299,7 +299,7 @@ export const dict = {
|
||||
"go.problem.item1": "Lavpris abonnementspriser",
|
||||
"go.problem.item2": "Generøse grænser og pålidelig adgang",
|
||||
"go.problem.item3": "Bygget til så mange programmører som muligt",
|
||||
"go.problem.item4": "Inkluderer GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7",
|
||||
"go.problem.item4": "Inkluderer GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 og MiniMax M2.7",
|
||||
"go.how.title": "Hvordan Go virker",
|
||||
"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.",
|
||||
@@ -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.faq.q2": "Hvilke modeller inkluderer Go?",
|
||||
"go.faq.a2":
|
||||
"Go inkluderer GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7, med generøse grænser og pålidelig adgang.",
|
||||
"Go inkluderer 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.faq.q3": "Er Go det samme som Zen?",
|
||||
"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, Kimi K2.5, 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, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 og MiniMax M2.7.",
|
||||
"go.faq.q4": "Hvad koster Go?",
|
||||
"go.faq.a4.p1.beforePricing": "Go koster",
|
||||
"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.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, 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).",
|
||||
"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, 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).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Hastighedsgrænse overskredet. Prøv venligst igen senere.",
|
||||
"zen.api.error.modelNotSupported": "Model {{model}} understøttes ikke",
|
||||
@@ -368,6 +368,8 @@ export const dict = {
|
||||
"zen.api.error.userMonthlyLimitReached":
|
||||
"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.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.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.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, Kimi K2.5, 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, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 und MiniMax M2.7.",
|
||||
"go.hero.title": "Kostengünstige Coding-Modelle für alle",
|
||||
"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.",
|
||||
@@ -301,7 +301,7 @@ export const dict = {
|
||||
"go.problem.item1": "Kostengünstiges Abonnement",
|
||||
"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.item4": "Beinhaltet GLM-5, Kimi K2.5, MiniMax M2.5 und MiniMax M2.7",
|
||||
"go.problem.item4": "Beinhaltet GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 und MiniMax M2.7",
|
||||
"go.how.title": "Wie Go funktioniert",
|
||||
"go.how.body":
|
||||
"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.faq.q2": "Welche Modelle beinhaltet Go?",
|
||||
"go.faq.a2":
|
||||
"Go beinhaltet GLM-5, Kimi K2.5, MiniMax M2.5 und MiniMax M2.7, mit großzügigen Limits und zuverlässigem Zugang.",
|
||||
"Go beinhaltet 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.faq.q3": "Ist Go dasselbe wie Zen?",
|
||||
"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, Kimi K2.5, 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, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 und MiniMax M2.7.",
|
||||
"go.faq.q4": "Wie viel kostet Go?",
|
||||
"go.faq.a4.p1.beforePricing": "Go kostet",
|
||||
"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.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, 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).",
|
||||
"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, 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).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Ratenlimit überschritten. Bitte versuche es später erneut.",
|
||||
"zen.api.error.modelNotSupported": "Modell {{model}} wird nicht unterstützt",
|
||||
@@ -371,6 +371,8 @@ export const dict = {
|
||||
"zen.api.error.userMonthlyLimitReached":
|
||||
"Du hast dein monatliches Ausgabenlimit von ${{amount}} erreicht. Verwalte deine Limits hier: {{membersUrl}}",
|
||||
"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.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.meta.description":
|
||||
"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 starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5, and MiniMax M2.7.",
|
||||
"go.hero.title": "Low cost coding models for everyone",
|
||||
"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.",
|
||||
@@ -295,7 +295,7 @@ export const dict = {
|
||||
"go.problem.item1": "Low cost subscription pricing",
|
||||
"go.problem.item2": "Generous limits and reliable access",
|
||||
"go.problem.item3": "Built for as many programmers as possible",
|
||||
"go.problem.item4": "Includes GLM-5, Kimi K2.5, MiniMax M2.5, and MiniMax M2.7",
|
||||
"go.problem.item4": "Includes GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5, and MiniMax M2.7",
|
||||
"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.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.faq.q2": "What models does Go include?",
|
||||
"go.faq.a2":
|
||||
"Go includes GLM-5, Kimi K2.5, MiniMax M2.5, and MiniMax M2.7, with generous limits and reliable access.",
|
||||
"Go includes 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.faq.q3": "Is Go the same as Zen?",
|
||||
"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, Kimi K2.5, 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, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5, and MiniMax M2.7.",
|
||||
"go.faq.q4": "How much does Go cost?",
|
||||
"go.faq.a4.p1.beforePricing": "Go costs",
|
||||
"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.a9":
|
||||
"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).",
|
||||
"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, 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).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Rate limit exceeded. Please try again later.",
|
||||
"zen.api.error.modelNotSupported": "Model {{model}} not supported",
|
||||
@@ -364,6 +364,8 @@ export const dict = {
|
||||
"zen.api.error.userMonthlyLimitReached":
|
||||
"You have reached your monthly spending limit of ${{amount}}. Manage your limits here: {{membersUrl}}",
|
||||
"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.description": "Get access to Claude, GPT, Gemini and more with OpenCode Black subscription plans.",
|
||||
|
||||
@@ -254,7 +254,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Modelos de programación de bajo coste para todos",
|
||||
"go.meta.description":
|
||||
"Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5, Kimi K2.5, MiniMax M2.5 y MiniMax M2.7.",
|
||||
"Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 y MiniMax M2.7.",
|
||||
"go.hero.title": "Modelos de programación de bajo coste para todos",
|
||||
"go.hero.body":
|
||||
"Go lleva la programación agéntica a programadores de todo el mundo. Ofrece límites generosos y acceso fiable a los modelos de código abierto más capaces, para que puedas crear con agentes potentes sin preocuparte por el coste o la disponibilidad.",
|
||||
@@ -303,7 +303,7 @@ export const dict = {
|
||||
"go.problem.item1": "Precios de suscripción de bajo coste",
|
||||
"go.problem.item2": "Límites generosos y acceso fiable",
|
||||
"go.problem.item3": "Creado para tantos programadores como sea posible",
|
||||
"go.problem.item4": "Incluye GLM-5, Kimi K2.5, MiniMax M2.5 y MiniMax M2.7",
|
||||
"go.problem.item4": "Incluye GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 y MiniMax M2.7",
|
||||
"go.how.title": "Cómo funciona Go",
|
||||
"go.how.body": "Go comienza en $5 el primer mes, luego 10 $/mes. Puedes usarlo con OpenCode o cualquier agente.",
|
||||
"go.how.step1.title": "Crear una cuenta",
|
||||
@@ -325,10 +325,11 @@ export const dict = {
|
||||
"go.faq.a1":
|
||||
"Go es una suscripción de bajo coste que te da acceso fiable a modelos de código abierto capaces para programación agéntica.",
|
||||
"go.faq.q2": "¿Qué modelos incluye Go?",
|
||||
"go.faq.a2": "Go incluye GLM-5, Kimi K2.5, MiniMax M2.5 y MiniMax M2.7, con límites generosos y acceso fiable.",
|
||||
"go.faq.a2":
|
||||
"Go incluye GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 y MiniMax M2.7, con límites generosos y acceso fiable.",
|
||||
"go.faq.q3": "¿Es Go lo mismo que Zen?",
|
||||
"go.faq.a3":
|
||||
"No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto GLM-5, Kimi K2.5, MiniMax M2.5 y MiniMax M2.7.",
|
||||
"No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 y MiniMax M2.7.",
|
||||
"go.faq.q4": "¿Cuánto cuesta Go?",
|
||||
"go.faq.a4.p1.beforePricing": "Go cuesta",
|
||||
"go.faq.a4.p1.pricingLink": "$5 el primer mes",
|
||||
@@ -352,7 +353,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "¿Cuál es la diferencia entre los modelos gratuitos y Go?",
|
||||
"go.faq.a9":
|
||||
"Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye GLM-5, Kimi K2.5, MiniMax M2.5 y MiniMax M2.7 con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).",
|
||||
"Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 y MiniMax M2.7 con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Límite de tasa excedido. Por favor, inténtalo de nuevo más tarde.",
|
||||
"zen.api.error.modelNotSupported": "Modelo {{model}} no soportado",
|
||||
@@ -371,6 +372,8 @@ export const dict = {
|
||||
"zen.api.error.userMonthlyLimitReached":
|
||||
"Has alcanzado tu límite de gasto mensual de ${{amount}}. Gestiona tus límites aquí: {{membersUrl}}",
|
||||
"zen.api.error.modelDisabled": "El modelo está deshabilitado",
|
||||
"zen.api.error.trialEnded":
|
||||
"La promoción gratuita de {{model}} ha finalizado. Puedes seguir usando el modelo suscribiéndote a OpenCode Go - {{link}}",
|
||||
|
||||
"black.meta.title": "OpenCode Black | Accede a los mejores modelos de codificación del mundo",
|
||||
"black.meta.description": "Obtén acceso a Claude, GPT, Gemini y más con los planes de suscripción de OpenCode Black.",
|
||||
|
||||
@@ -255,7 +255,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Modèles de code à faible coût pour tous",
|
||||
"go.meta.description":
|
||||
"Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour GLM-5, Kimi K2.5, MiniMax M2.5 et MiniMax M2.7.",
|
||||
"Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 et MiniMax M2.7.",
|
||||
"go.hero.title": "Modèles de code à faible coût pour tous",
|
||||
"go.hero.body":
|
||||
"Go apporte le codage agentique aux programmeurs du monde entier. Offrant des limites généreuses et un accès fiable aux modèles open source les plus capables, pour que vous puissiez construire avec des agents puissants sans vous soucier du coût ou de la disponibilité.",
|
||||
@@ -303,7 +303,7 @@ export const dict = {
|
||||
"go.problem.item1": "Prix d'abonnement bas",
|
||||
"go.problem.item2": "Limites généreuses et accès fiable",
|
||||
"go.problem.item3": "Conçu pour autant de programmeurs que possible",
|
||||
"go.problem.item4": "Inclut GLM-5, Kimi K2.5, MiniMax M2.5 et MiniMax M2.7",
|
||||
"go.problem.item4": "Inclut GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 et MiniMax M2.7",
|
||||
"go.how.title": "Comment fonctionne Go",
|
||||
"go.how.body":
|
||||
"Go commence à $5 pour le premier mois, puis 10 $/mois. Vous pouvez l'utiliser avec OpenCode ou n'importe quel agent.",
|
||||
@@ -327,10 +327,10 @@ export const dict = {
|
||||
"Go est un abonnement à faible coût qui vous donne un accès fiable à des modèles open source performants pour le codage agentique.",
|
||||
"go.faq.q2": "Quels modèles Go inclut-il ?",
|
||||
"go.faq.a2":
|
||||
"Go inclut GLM-5, Kimi K2.5, MiniMax M2.5 et MiniMax M2.7, avec des limites généreuses et un accès fiable.",
|
||||
"Go inclut GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 et MiniMax M2.7, avec des limites généreuses et un accès fiable.",
|
||||
"go.faq.q3": "Est-ce que Go est la même chose que Zen ?",
|
||||
"go.faq.a3":
|
||||
"Non. Zen est un paiement à l'utilisation, tandis que Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites généreuses et un accès fiable aux modèles open source GLM-5, Kimi K2.5, MiniMax M2.5 et MiniMax M2.7.",
|
||||
"Non. Zen est un paiement à l'utilisation, tandis que Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites généreuses et un accès fiable aux modèles open source GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 et MiniMax M2.7.",
|
||||
"go.faq.q4": "Combien coûte Go ?",
|
||||
"go.faq.a4.p1.beforePricing": "Go coûte",
|
||||
"go.faq.a4.p1.pricingLink": "$5 le premier mois",
|
||||
@@ -353,7 +353,7 @@ export const dict = {
|
||||
"Oui, vous pouvez utiliser Go avec n'importe quel agent. Suivez les instructions de configuration dans votre agent de code préféré.",
|
||||
"go.faq.q9": "Quelle est la différence entre les modèles gratuits et Go ?",
|
||||
"go.faq.a9":
|
||||
"Les modèles gratuits incluent Big Pickle ainsi que des modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go inclut GLM-5, Kimi K2.5, MiniMax M2.5 et MiniMax M2.7 avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalent à 12 $ par 5 heures, 30 $ par semaine et 60 $ par mois (le nombre réel de requêtes varie selon le modèle et l'utilisation).",
|
||||
"Les modèles gratuits incluent Big Pickle ainsi que des modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go inclut GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 et MiniMax M2.7 avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalent à 12 $ par 5 heures, 30 $ par semaine et 60 $ par mois (le nombre réel de requêtes varie selon le modèle et l'utilisation).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Limite de débit dépassée. Veuillez réessayer plus tard.",
|
||||
"zen.api.error.modelNotSupported": "Modèle {{model}} non pris en charge",
|
||||
@@ -372,6 +372,8 @@ export const dict = {
|
||||
"zen.api.error.userMonthlyLimitReached":
|
||||
"Vous avez atteint votre limite de dépense mensuelle de {{amount}} $. Gérez vos limites ici : {{membersUrl}}",
|
||||
"zen.api.error.modelDisabled": "Le modèle est désactivé",
|
||||
"zen.api.error.trialEnded":
|
||||
"La promotion gratuite de {{model}} est terminée. Vous pouvez continuer à utiliser le modèle en vous abonnant à OpenCode Go - {{link}}",
|
||||
|
||||
"black.meta.title": "OpenCode Black | Accédez aux meilleurs modèles de code au monde",
|
||||
"black.meta.description": "Accédez à Claude, GPT, Gemini et plus avec les forfaits d'abonnement OpenCode Black.",
|
||||
|
||||
@@ -251,7 +251,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Modelli di coding a basso costo per tutti",
|
||||
"go.meta.description":
|
||||
"Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7.",
|
||||
"Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 e MiniMax M2.7.",
|
||||
"go.hero.title": "Modelli di coding a basso costo per tutti",
|
||||
"go.hero.body":
|
||||
"Go porta il coding agentico ai programmatori di tutto il mondo. Offrendo limiti generosi e un accesso affidabile ai modelli open source più capaci, in modo da poter costruire con agenti potenti senza preoccuparsi dei costi o della disponibilità.",
|
||||
@@ -299,7 +299,7 @@ export const dict = {
|
||||
"go.problem.item1": "Prezzo di abbonamento a basso costo",
|
||||
"go.problem.item2": "Limiti generosi e accesso affidabile",
|
||||
"go.problem.item3": "Costruito per il maggior numero possibile di programmatori",
|
||||
"go.problem.item4": "Include GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7",
|
||||
"go.problem.item4": "Include GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 e MiniMax M2.7",
|
||||
"go.how.title": "Come funziona Go",
|
||||
"go.how.body": "Go inizia a $5 per il primo mese, poi $10/mese. Puoi usarlo con OpenCode o qualsiasi agente.",
|
||||
"go.how.step1.title": "Crea un account",
|
||||
@@ -321,10 +321,11 @@ export const dict = {
|
||||
"go.faq.a1":
|
||||
"Go è un abbonamento a basso costo che ti dà un accesso affidabile a modelli open source capaci per il coding agentico.",
|
||||
"go.faq.q2": "Quali modelli include Go?",
|
||||
"go.faq.a2": "Go include GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7, con limiti generosi e accesso affidabile.",
|
||||
"go.faq.a2":
|
||||
"Go include GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 e MiniMax M2.7, con limiti generosi e accesso affidabile.",
|
||||
"go.faq.q3": "Go è lo stesso di Zen?",
|
||||
"go.faq.a3":
|
||||
"No. Zen è a consumo, mentre Go inizia a $5 per il primo mese, poi $10/mese, con limiti generosi e accesso affidabile ai modelli open source GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7.",
|
||||
"No. Zen è a consumo, mentre Go inizia a $5 per il primo mese, poi $10/mese, con limiti generosi e accesso affidabile ai modelli open source GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 e MiniMax M2.7.",
|
||||
"go.faq.q4": "Quanto costa Go?",
|
||||
"go.faq.a4.p1.beforePricing": "Go costa",
|
||||
"go.faq.a4.p1.pricingLink": "$5 il primo mese",
|
||||
@@ -348,7 +349,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "Qual è la differenza tra i modelli gratuiti e Go?",
|
||||
"go.faq.a9":
|
||||
"I modelli gratuiti includono Big Pickle più modelli promozionali disponibili al momento, con una quota di 200 richieste/giorno. Go include GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7 con quote di richiesta più elevate applicate su finestre mobili (5 ore, settimanale e mensile), approssimativamente equivalenti a $12 ogni 5 ore, $30 a settimana e $60 al mese (il conteggio effettivo delle richieste varia in base al modello e all'utilizzo).",
|
||||
"I modelli gratuiti includono Big Pickle più modelli promozionali disponibili al momento, con una quota di 200 richieste/giorno. Go include GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 e MiniMax M2.7 con quote di richiesta più elevate applicate su finestre mobili (5 ore, settimanale e mensile), approssimativamente equivalenti a $12 ogni 5 ore, $30 a settimana e $60 al mese (il conteggio effettivo delle richieste varia in base al modello e all'utilizzo).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Limite di richieste superato. Riprova più tardi.",
|
||||
"zen.api.error.modelNotSupported": "Modello {{model}} non supportato",
|
||||
@@ -367,6 +368,8 @@ export const dict = {
|
||||
"zen.api.error.userMonthlyLimitReached":
|
||||
"Hai raggiunto il tuo limite di spesa mensile di ${{amount}}. Gestisci i tuoi limiti qui: {{membersUrl}}",
|
||||
"zen.api.error.modelDisabled": "Il modello è disabilitato",
|
||||
"zen.api.error.trialEnded":
|
||||
"La promozione gratuita di {{model}} è terminata. Puoi continuare a usare il modello abbonandoti a OpenCode Go - {{link}}",
|
||||
|
||||
"black.meta.title": "OpenCode Black | Accedi ai migliori modelli di coding al mondo",
|
||||
"black.meta.description":
|
||||
|
||||
@@ -250,7 +250,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | すべての人のための低価格なコーディングモデル",
|
||||
"go.meta.description":
|
||||
"Goは最初の月$5、その後$10/月で、GLM-5、Kimi K2.5、MiniMax M2.5、MiniMax M2.7に対して5時間のゆとりあるリクエスト上限があります。",
|
||||
"Goは最初の月$5、その後$10/月で、GLM-5、Kimi K2.5、MiMo-V2-Pro、MiMo-V2-Omni、MiniMax M2.5、MiniMax M2.7に対して5時間のゆとりあるリクエスト上限があります。",
|
||||
"go.hero.title": "すべての人のための低価格なコーディングモデル",
|
||||
"go.hero.body":
|
||||
"Goは、世界中のプログラマーにエージェント型コーディングをもたらします。最も高性能なオープンソースモデルへの十分な制限と安定したアクセスを提供し、コストや可用性を気にすることなく強力なエージェントで構築できます。",
|
||||
@@ -299,7 +299,7 @@ export const dict = {
|
||||
"go.problem.item1": "低価格なサブスクリプション料金",
|
||||
"go.problem.item2": "十分な制限と安定したアクセス",
|
||||
"go.problem.item3": "できるだけ多くのプログラマーのために構築",
|
||||
"go.problem.item4": "GLM-5、Kimi K2.5、MiniMax M2.5、MiniMax M2.7を含む",
|
||||
"go.problem.item4": "GLM-5、Kimi K2.5、MiMo-V2-Pro、MiMo-V2-Omni、MiniMax M2.5、MiniMax M2.7を含む",
|
||||
"go.how.title": "Goの仕組み",
|
||||
"go.how.body": "Goは最初の月$5、その後$10/月で始まります。OpenCodeまたは任意のエージェントで使えます。",
|
||||
"go.how.step1.title": "アカウントを作成",
|
||||
@@ -322,10 +322,10 @@ export const dict = {
|
||||
"Goは、エージェント型コーディングのための有能なオープンソースモデルへの安定したアクセスを提供する低価格なサブスクリプションです。",
|
||||
"go.faq.q2": "Goにはどのモデルが含まれますか?",
|
||||
"go.faq.a2":
|
||||
"Goには、GLM-5、Kimi K2.5、MiniMax M2.5、MiniMax M2.7が含まれており、十分な制限と安定したアクセスが提供されます。",
|
||||
"Goには、GLM-5、Kimi K2.5、MiMo-V2-Pro、MiMo-V2-Omni、MiniMax M2.5、MiniMax M2.7が含まれており、十分な制限と安定したアクセスが提供されます。",
|
||||
"go.faq.q3": "GoはZenと同じですか?",
|
||||
"go.faq.a3":
|
||||
"いいえ。Zenは従量課金制ですが、Goは最初の月$5、その後$10/月で始まり、GLM-5、Kimi K2.5、MiniMax M2.5、MiniMax M2.7のオープンソースモデルに対して、ゆとりある上限と信頼できるアクセスを提供します。",
|
||||
"いいえ。Zenは従量課金制ですが、Goは最初の月$5、その後$10/月で始まり、GLM-5、Kimi K2.5、MiMo-V2-Pro、MiMo-V2-Omni、MiniMax M2.5、MiniMax M2.7のオープンソースモデルに対して、ゆとりある上限と信頼できるアクセスを提供します。",
|
||||
"go.faq.q4": "Goの料金は?",
|
||||
"go.faq.a4.p1.beforePricing": "Goは",
|
||||
"go.faq.a4.p1.pricingLink": "最初の月$5",
|
||||
@@ -349,7 +349,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "無料モデルとGoの違いは何ですか?",
|
||||
"go.faq.a9":
|
||||
"無料モデルにはBig Pickleと、その時点で利用可能なプロモーションモデルが含まれ、1日200リクエストの制限があります。GoにはGLM-5、Kimi K2.5、MiniMax M2.5、MiniMax M2.7が含まれ、ローリングウィンドウ(5時間、週間、月間)全体でより高いリクエスト制限が適用されます。これは概算で5時間あたり$12、週間$30、月間$60相当です(実際のリクエスト数はモデルと使用状況により異なります)。",
|
||||
"無料モデルにはBig Pickleと、その時点で利用可能なプロモーションモデルが含まれ、1日200リクエストの制限があります。GoにはGLM-5、Kimi K2.5、MiMo-V2-Pro、MiMo-V2-Omni、MiniMax M2.5、MiniMax M2.7が含まれ、ローリングウィンドウ(5時間、週間、月間)全体でより高いリクエスト制限が適用されます。これは概算で5時間あたり$12、週間$30、月間$60相当です(実際のリクエスト数はモデルと使用状況により異なります)。",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "レート制限を超えました。後でもう一度お試しください。",
|
||||
"zen.api.error.modelNotSupported": "モデル {{model}} はサポートされていません",
|
||||
@@ -369,6 +369,8 @@ export const dict = {
|
||||
"zen.api.error.userMonthlyLimitReached":
|
||||
"月額の利用上限 ${{amount}} に達しました。こちらから上限を管理してください: {{membersUrl}}",
|
||||
"zen.api.error.modelDisabled": "モデルが無効です",
|
||||
"zen.api.error.trialEnded":
|
||||
"{{model}} の無料プロモーションは終了しました。OpenCode Go を購読するとモデルを引き続き使用できます - {{link}}",
|
||||
|
||||
"black.meta.title": "OpenCode Black | 世界最高峰のコーディングモデルすべてにアクセス",
|
||||
"black.meta.description": "OpenCode Black サブスクリプションプランで、Claude、GPT、Gemini などにアクセス。",
|
||||
|
||||
@@ -247,7 +247,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | 모두를 위한 저비용 코딩 모델",
|
||||
"go.meta.description":
|
||||
"Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5, Kimi K2.5, MiniMax M2.5, MiniMax M2.7에 대해 넉넉한 5시간 요청 한도를 제공합니다.",
|
||||
"Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5, MiniMax M2.7에 대해 넉넉한 5시간 요청 한도를 제공합니다.",
|
||||
"go.hero.title": "모두를 위한 저비용 코딩 모델",
|
||||
"go.hero.body":
|
||||
"Go는 전 세계 프로그래머들에게 에이전트 코딩을 제공합니다. 가장 유능한 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공하므로, 비용이나 가용성 걱정 없이 강력한 에이전트로 빌드할 수 있습니다.",
|
||||
@@ -296,7 +296,7 @@ export const dict = {
|
||||
"go.problem.item1": "저렴한 구독 가격",
|
||||
"go.problem.item2": "넉넉한 한도와 안정적인 액세스",
|
||||
"go.problem.item3": "가능한 한 많은 프로그래머를 위해 제작됨",
|
||||
"go.problem.item4": "GLM-5, Kimi K2.5, MiniMax M2.5, MiniMax M2.7 포함",
|
||||
"go.problem.item4": "GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5, MiniMax M2.7 포함",
|
||||
"go.how.title": "Go 작동 방식",
|
||||
"go.how.body": "Go는 첫 달 $5, 이후 $10/월로 시작합니다. OpenCode 또는 어떤 에이전트와도 함께 사용할 수 있습니다.",
|
||||
"go.how.step1.title": "계정 생성",
|
||||
@@ -318,10 +318,10 @@ export const dict = {
|
||||
"go.faq.a1": "Go는 에이전트 코딩을 위한 유능한 오픈 소스 모델에 대해 안정적인 액세스를 제공하는 저비용 구독입니다.",
|
||||
"go.faq.q2": "Go에는 어떤 모델이 포함되나요?",
|
||||
"go.faq.a2":
|
||||
"Go에는 넉넉한 한도와 안정적인 액세스를 제공하는 GLM-5, Kimi K2.5, MiniMax M2.5, MiniMax M2.7가 포함됩니다.",
|
||||
"Go에는 넉넉한 한도와 안정적인 액세스를 제공하는 GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5, MiniMax M2.7가 포함됩니다.",
|
||||
"go.faq.q3": "Go는 Zen과 같은가요?",
|
||||
"go.faq.a3":
|
||||
"아니요. Zen은 종량제인 반면, Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5, Kimi K2.5, MiniMax M2.5, MiniMax M2.7 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공합니다.",
|
||||
"아니요. Zen은 종량제인 반면, Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5, MiniMax M2.7 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공합니다.",
|
||||
"go.faq.q4": "Go 비용은 얼마인가요?",
|
||||
"go.faq.a4.p1.beforePricing": "Go 비용은",
|
||||
"go.faq.a4.p1.pricingLink": "첫 달 $5",
|
||||
@@ -344,7 +344,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "무료 모델과 Go의 차이점은 무엇인가요?",
|
||||
"go.faq.a9":
|
||||
"무료 모델에는 Big Pickle과 당시 사용 가능한 프로모션 모델이 포함되며, 하루 200회 요청 할당량이 적용됩니다. Go는 GLM-5, Kimi K2.5, MiniMax M2.5, MiniMax M2.7를 포함하며, 롤링 윈도우(5시간, 주간, 월간)에 걸쳐 더 높은 요청 할당량을 적용합니다. 이는 대략 5시간당 $12, 주당 $30, 월 $60에 해당합니다(실제 요청 수는 모델 및 사용량에 따라 다름).",
|
||||
"무료 모델에는 Big Pickle과 당시 사용 가능한 프로모션 모델이 포함되며, 하루 200회 요청 할당량이 적용됩니다. Go는 GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5, MiniMax M2.7를 포함하며, 롤링 윈도우(5시간, 주간, 월간)에 걸쳐 더 높은 요청 할당량을 적용합니다. 이는 대략 5시간당 $12, 주당 $30, 월 $60에 해당합니다(실제 요청 수는 모델 및 사용량에 따라 다름).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "속도 제한을 초과했습니다. 나중에 다시 시도해 주세요.",
|
||||
"zen.api.error.modelNotSupported": "{{model}} 모델은 지원되지 않습니다",
|
||||
@@ -363,6 +363,8 @@ export const dict = {
|
||||
"zen.api.error.userMonthlyLimitReached":
|
||||
"월간 지출 한도인 ${{amount}}에 도달했습니다. 한도 관리를 여기서 하세요: {{membersUrl}}",
|
||||
"zen.api.error.modelDisabled": "모델이 비활성화되었습니다",
|
||||
"zen.api.error.trialEnded":
|
||||
"{{model}}의 무료 프로모션이 종료되었습니다. OpenCode Go를 구독하면 모델을 계속 사용할 수 있습니다 - {{link}}",
|
||||
|
||||
"black.meta.title": "OpenCode Black | 세계 최고의 코딩 모델에 액세스하세요",
|
||||
"black.meta.description": "OpenCode Black 구독 플랜으로 Claude, GPT, Gemini 등에 액세스하세요.",
|
||||
|
||||
@@ -251,7 +251,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Rimelige kodemodeller for alle",
|
||||
"go.meta.description":
|
||||
"Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7.",
|
||||
"Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 og MiniMax M2.7.",
|
||||
"go.hero.title": "Rimelige kodemodeller for alle",
|
||||
"go.hero.body":
|
||||
"Go bringer agent-koding til programmerere over hele verden. Med rause grenser og pålitelig tilgang til de mest kapable åpen kildekode-modellene, kan du bygge med kraftige agenter uten å bekymre deg for kostnader eller tilgjengelighet.",
|
||||
@@ -299,7 +299,7 @@ export const dict = {
|
||||
"go.problem.item1": "Rimelig abonnementspris",
|
||||
"go.problem.item2": "Rause grenser og pålitelig tilgang",
|
||||
"go.problem.item3": "Bygget for så mange programmerere som mulig",
|
||||
"go.problem.item4": "Inkluderer GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7",
|
||||
"go.problem.item4": "Inkluderer GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 og MiniMax M2.7",
|
||||
"go.how.title": "Hvordan Go fungerer",
|
||||
"go.how.body":
|
||||
"Go starter på $5 for den første måneden, deretter $10/måned. Du kan bruke det med OpenCode eller hvilken som helst agent.",
|
||||
@@ -322,10 +322,11 @@ export const dict = {
|
||||
"go.faq.a1":
|
||||
"Go er et rimelig abonnement som gir deg pålitelig tilgang til kapable åpen kildekode-modeller for agent-koding.",
|
||||
"go.faq.q2": "Hvilke modeller inkluderer Go?",
|
||||
"go.faq.a2": "Go inkluderer GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7, med rause grenser og pålitelig tilgang.",
|
||||
"go.faq.a2":
|
||||
"Go inkluderer GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 og MiniMax M2.7, med rause grenser og pålitelig tilgang.",
|
||||
"go.faq.q3": "Er Go det samme som Zen?",
|
||||
"go.faq.a3":
|
||||
"Nei. Zen er betaling etter bruk, mens Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse grenser og pålitelig tilgang til åpen kildekode-modellene GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7.",
|
||||
"Nei. Zen er betaling etter bruk, mens Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse grenser og pålitelig tilgang til åpen kildekode-modellene GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 og MiniMax M2.7.",
|
||||
"go.faq.q4": "Hva koster Go?",
|
||||
"go.faq.a4.p1.beforePricing": "Go koster",
|
||||
"go.faq.a4.p1.pricingLink": "$5 første måned",
|
||||
@@ -349,7 +350,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "Hva er forskjellen mellom gratis modeller og Go?",
|
||||
"go.faq.a9":
|
||||
"Gratis modeller inkluderer Big Pickle pluss kampanjemodeller tilgjengelig på det tidspunktet, med en kvote på 200 forespørsler/dag. Go inkluderer GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7 med høyere kvoter håndhevet over rullerende vinduer (5 timer, ukentlig og månedlig), omtrent tilsvarende $12 per 5 timer, $30 per uke og $60 per måned (faktiske forespørselsantall varierer etter modell og bruk).",
|
||||
"Gratis modeller inkluderer Big Pickle pluss kampanjemodeller tilgjengelig på det tidspunktet, med en kvote på 200 forespørsler/dag. Go inkluderer GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 og MiniMax M2.7 med høyere kvoter håndhevet over rullerende vinduer (5 timer, ukentlig og månedlig), omtrent tilsvarende $12 per 5 timer, $30 per uke og $60 per måned (faktiske forespørselsantall varierer etter modell og bruk).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Rate limit overskredet. Vennligst prøv igjen senere.",
|
||||
"zen.api.error.modelNotSupported": "Modell {{model}} støttes ikke",
|
||||
@@ -368,6 +369,8 @@ export const dict = {
|
||||
"zen.api.error.userMonthlyLimitReached":
|
||||
"Du har nådd din månedlige utgiftsgrense på ${{amount}}. Administrer grensene dine her: {{membersUrl}}",
|
||||
"zen.api.error.modelDisabled": "Modellen er deaktivert",
|
||||
"zen.api.error.trialEnded":
|
||||
"Den gratis kampanjen for {{model}} er avsluttet. Du kan fortsette å bruke modellen ved å abonnere på OpenCode Go - {{link}}",
|
||||
|
||||
"black.meta.title": "OpenCode Black | Få tilgang til verdens beste kodemodeller",
|
||||
"black.meta.description": "Få tilgang til Claude, GPT, Gemini og mer med OpenCode Black-abonnementer.",
|
||||
|
||||
@@ -252,7 +252,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Niskokosztowe modele do kodowania dla każdego",
|
||||
"go.meta.description":
|
||||
"Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla GLM-5, Kimi K2.5, MiniMax M2.5 i MiniMax M2.7.",
|
||||
"Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 i MiniMax M2.7.",
|
||||
"go.hero.title": "Niskokosztowe modele do kodowania dla każdego",
|
||||
"go.hero.body":
|
||||
"Go udostępnia programowanie z agentami programistom na całym świecie. Oferuje hojne limity i niezawodny dostęp do najzdolniejszych modeli open source, dzięki czemu możesz budować za pomocą potężnych agentów, nie martwiąc się o koszty czy dostępność.",
|
||||
@@ -300,7 +300,7 @@ export const dict = {
|
||||
"go.problem.item1": "Niskokosztowa cena subskrypcji",
|
||||
"go.problem.item2": "Hojne limity i niezawodny dostęp",
|
||||
"go.problem.item3": "Stworzony dla jak największej liczby programistów",
|
||||
"go.problem.item4": "Zawiera GLM-5, Kimi K2.5, MiniMax M2.5 i MiniMax M2.7",
|
||||
"go.problem.item4": "Zawiera GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 i MiniMax M2.7",
|
||||
"go.how.title": "Jak działa Go",
|
||||
"go.how.body":
|
||||
"Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc. Możesz go używać z OpenCode lub dowolnym agentem.",
|
||||
@@ -323,10 +323,11 @@ export const dict = {
|
||||
"go.faq.a1":
|
||||
"Go to niskokosztowa subskrypcja, która daje niezawodny dostęp do zdolnych modeli open source dla agentów kodujących.",
|
||||
"go.faq.q2": "Jakie modele zawiera Go?",
|
||||
"go.faq.a2": "Go zawiera GLM-5, Kimi K2.5, MiniMax M2.5 i MiniMax M2.7, z hojnymi limitami i niezawodnym dostępem.",
|
||||
"go.faq.a2":
|
||||
"Go zawiera GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 i MiniMax M2.7, z hojnymi limitami i niezawodnym dostępem.",
|
||||
"go.faq.q3": "Czy Go to to samo co Zen?",
|
||||
"go.faq.a3":
|
||||
"Nie. Zen to model płatności za użycie, podczas gdy Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi limitami i niezawodnym dostępem do modeli open source GLM-5, Kimi K2.5, MiniMax M2.5 i MiniMax M2.7.",
|
||||
"Nie. Zen to model płatności za użycie, podczas gdy Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi limitami i niezawodnym dostępem do modeli open source GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 i MiniMax M2.7.",
|
||||
"go.faq.q4": "Ile kosztuje Go?",
|
||||
"go.faq.a4.p1.beforePricing": "Go kosztuje",
|
||||
"go.faq.a4.p1.pricingLink": "$5 za pierwszy miesiąc",
|
||||
@@ -350,7 +351,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "Jaka jest różnica między darmowymi modelami a Go?",
|
||||
"go.faq.a9":
|
||||
"Darmowe modele obejmują Big Pickle oraz modele promocyjne dostępne w danym momencie, z limitem 200 zapytań/dzień. Go zawiera GLM-5, Kimi K2.5, MiniMax M2.5 i MiniMax M2.7 z wyższymi limitami zapytań egzekwowanymi w oknach kroczących (5-godzinnych, tygodniowych i miesięcznych), w przybliżeniu równoważnymi $12 na 5 godzin, $30 tygodniowo i $60 miesięcznie (rzeczywista liczba zapytań zależy od modelu i użycia).",
|
||||
"Darmowe modele obejmują Big Pickle oraz modele promocyjne dostępne w danym momencie, z limitem 200 zapytań/dzień. Go zawiera GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 i MiniMax M2.7 z wyższymi limitami zapytań egzekwowanymi w oknach kroczących (5-godzinnych, tygodniowych i miesięcznych), w przybliżeniu równoważnymi $12 na 5 godzin, $30 tygodniowo i $60 miesięcznie (rzeczywista liczba zapytań zależy od modelu i użycia).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Przekroczono limit zapytań. Spróbuj ponownie później.",
|
||||
"zen.api.error.modelNotSupported": "Model {{model}} nie jest obsługiwany",
|
||||
@@ -369,6 +370,8 @@ export const dict = {
|
||||
"zen.api.error.userMonthlyLimitReached":
|
||||
"Osiągnąłeś swój miesięczny limit wydatków w wysokości ${{amount}}. Zarządzaj swoimi limitami tutaj: {{membersUrl}}",
|
||||
"zen.api.error.modelDisabled": "Model jest wyłączony",
|
||||
"zen.api.error.trialEnded":
|
||||
"Bezpłatna promocja {{model}} dobiegła końca. Możesz dalej korzystać z modelu, subskrybując OpenCode Go - {{link}}",
|
||||
|
||||
"black.meta.title": "OpenCode Black | Dostęp do najlepszych na świecie modeli kodujących",
|
||||
"black.meta.description": "Uzyskaj dostęp do Claude, GPT, Gemini i innych dzięki planom subskrypcji OpenCode Black.",
|
||||
|
||||
@@ -255,7 +255,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Недорогие модели для кодинга для всех",
|
||||
"go.meta.description":
|
||||
"Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для GLM-5, Kimi K2.5, MiniMax M2.5 и MiniMax M2.7.",
|
||||
"Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 и MiniMax M2.7.",
|
||||
"go.hero.title": "Недорогие модели для кодинга для всех",
|
||||
"go.hero.body":
|
||||
"Go открывает доступ к агентам-программистам разработчикам по всему миру. Предлагая щедрые лимиты и надежный доступ к наиболее способным моделям с открытым исходным кодом, вы можете создавать проекты с мощными агентами, не беспокоясь о затратах или доступности.",
|
||||
@@ -304,7 +304,7 @@ export const dict = {
|
||||
"go.problem.item1": "Недорогая подписка",
|
||||
"go.problem.item2": "Щедрые лимиты и надежный доступ",
|
||||
"go.problem.item3": "Создан для максимального числа программистов",
|
||||
"go.problem.item4": "Включает GLM-5, Kimi K2.5, MiniMax M2.5 и MiniMax M2.7",
|
||||
"go.problem.item4": "Включает GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 и MiniMax M2.7",
|
||||
"go.how.title": "Как работает Go",
|
||||
"go.how.body":
|
||||
"Go начинается с $5 за первый месяц, затем $10/месяц. Вы можете использовать его с OpenCode или любым агентом.",
|
||||
@@ -327,10 +327,11 @@ export const dict = {
|
||||
"go.faq.a1":
|
||||
"Go — это недорогая подписка, дающая надежный доступ к мощным моделям с открытым исходным кодом для агентов-программистов.",
|
||||
"go.faq.q2": "Какие модели включает Go?",
|
||||
"go.faq.a2": "Go включает GLM-5, Kimi K2.5, MiniMax M2.5 и MiniMax M2.7, с щедрыми лимитами и надежным доступом.",
|
||||
"go.faq.a2":
|
||||
"Go включает GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 и MiniMax M2.7, с щедрыми лимитами и надежным доступом.",
|
||||
"go.faq.q3": "Go — это то же самое, что и Zen?",
|
||||
"go.faq.a3":
|
||||
"Нет. Zen - это оплата по мере использования, в то время как Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами и надежным доступом к моделям с открытым исходным кодом GLM-5, Kimi K2.5, MiniMax M2.5 и MiniMax M2.7.",
|
||||
"Нет. Zen - это оплата по мере использования, в то время как Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами и надежным доступом к моделям с открытым исходным кодом GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 и MiniMax M2.7.",
|
||||
"go.faq.q4": "Сколько стоит Go?",
|
||||
"go.faq.a4.p1.beforePricing": "Go стоит",
|
||||
"go.faq.a4.p1.pricingLink": "$5 за первый месяц",
|
||||
@@ -354,7 +355,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "В чем разница между бесплатными моделями и Go?",
|
||||
"go.faq.a9":
|
||||
"Бесплатные модели включают Big Pickle плюс промо-модели, доступные на данный момент, с квотой 200 запросов/день. Go включает GLM-5, Kimi K2.5, MiniMax M2.5 и MiniMax M2.7 с более высокими квотами запросов, применяемыми в скользящих окнах (5 часов, неделя и месяц), что примерно эквивалентно $12 за 5 часов, $30 в неделю и $60 в месяц (фактическое количество запросов зависит от модели и использования).",
|
||||
"Бесплатные модели включают Big Pickle плюс промо-модели, доступные на данный момент, с квотой 200 запросов/день. Go включает GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 и MiniMax M2.7 с более высокими квотами запросов, применяемыми в скользящих окнах (5 часов, неделя и месяц), что примерно эквивалентно $12 за 5 часов, $30 в неделю и $60 в месяц (фактическое количество запросов зависит от модели и использования).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Превышен лимит запросов. Пожалуйста, попробуйте позже.",
|
||||
"zen.api.error.modelNotSupported": "Модель {{model}} не поддерживается",
|
||||
@@ -373,6 +374,8 @@ export const dict = {
|
||||
"zen.api.error.userMonthlyLimitReached":
|
||||
"Вы достигли ежемесячного лимита расходов в ${{amount}}. Управляйте лимитами здесь: {{membersUrl}}",
|
||||
"zen.api.error.modelDisabled": "Модель отключена",
|
||||
"zen.api.error.trialEnded":
|
||||
"Бесплатная акция для {{model}} завершена. Вы можете продолжить использование модели, подписавшись на OpenCode Go - {{link}}",
|
||||
|
||||
"black.meta.title": "OpenCode Black | Доступ к лучшим моделям для кодинга в мире",
|
||||
"black.meta.description": "Получите доступ к Claude, GPT, Gemini и другим моделям с подпиской OpenCode Black.",
|
||||
|
||||
@@ -250,7 +250,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน",
|
||||
"go.meta.description":
|
||||
"Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ GLM-5, Kimi K2.5, MiniMax M2.5 และ MiniMax M2.7",
|
||||
"Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 และ MiniMax M2.7",
|
||||
"go.hero.title": "โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน",
|
||||
"go.hero.body":
|
||||
"Go นำการเขียนโค้ดแบบเอเจนต์มาสู่นักเขียนโปรแกรมทั่วโลก เสนอขีดจำกัดที่กว้างขวางและการเข้าถึงโมเดลโอเพนซอร์สที่มีความสามารถสูงสุดได้อย่างน่าเชื่อถือ เพื่อให้คุณสามารถสร้างสรรค์ด้วยเอเจนต์ที่ทรงพลังโดยไม่ต้องกังวลเรื่องค่าใช้จ่ายหรือความพร้อมใช้งาน",
|
||||
@@ -297,7 +297,7 @@ export const dict = {
|
||||
"go.problem.item1": "ราคาการสมัครสมาชิกที่ต่ำ",
|
||||
"go.problem.item2": "ขีดจำกัดที่กว้างขวางและการเข้าถึงที่เชื่อถือได้",
|
||||
"go.problem.item3": "สร้างขึ้นเพื่อโปรแกรมเมอร์จำนวนมากที่สุดเท่าที่จะเป็นไปได้",
|
||||
"go.problem.item4": "รวมถึง GLM-5, Kimi K2.5, MiniMax M2.5 และ MiniMax M2.7",
|
||||
"go.problem.item4": "รวมถึง GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 และ MiniMax M2.7",
|
||||
"go.how.title": "Go ทำงานอย่างไร",
|
||||
"go.how.body": "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน คุณสามารถใช้กับ OpenCode หรือเอเจนต์ใดก็ได้",
|
||||
"go.how.step1.title": "สร้างบัญชี",
|
||||
@@ -320,10 +320,10 @@ export const dict = {
|
||||
"Go คือการสมัครสมาชิกราคาประหยัดที่ให้คุณเข้าถึงโมเดลโอเพนซอร์สที่มีความสามารถสำหรับการเขียนโค้ดแบบเอเจนต์ได้อย่างน่าเชื่อถือ",
|
||||
"go.faq.q2": "Go รวมโมเดลอะไรบ้าง?",
|
||||
"go.faq.a2":
|
||||
"Go รวมถึง GLM-5, Kimi K2.5, MiniMax M2.5 และ MiniMax M2.7 พร้อมขีดจำกัดที่กว้างขวางและการเข้าถึงที่เชื่อถือได้",
|
||||
"Go รวมถึง GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 และ MiniMax M2.7 พร้อมขีดจำกัดที่กว้างขวางและการเข้าถึงที่เชื่อถือได้",
|
||||
"go.faq.q3": "Go เหมือนกับ Zen หรือไม่?",
|
||||
"go.faq.a3":
|
||||
"ไม่ Zen เป็นแบบจ่ายตามการใช้งาน ในขณะที่ Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อและการเข้าถึงโมเดลโอเพนซอร์ส GLM-5, Kimi K2.5, MiniMax M2.5 และ MiniMax M2.7 อย่างเชื่อถือได้",
|
||||
"ไม่ Zen เป็นแบบจ่ายตามการใช้งาน ในขณะที่ Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อและการเข้าถึงโมเดลโอเพนซอร์ส GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 และ MiniMax M2.7 อย่างเชื่อถือได้",
|
||||
"go.faq.q4": "Go ราคาเท่าไหร่?",
|
||||
"go.faq.a4.p1.beforePricing": "Go ราคา",
|
||||
"go.faq.a4.p1.pricingLink": "$5 เดือนแรก",
|
||||
@@ -346,7 +346,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "ความแตกต่างระหว่างโมเดลฟรีและ Go คืออะไร?",
|
||||
"go.faq.a9":
|
||||
"โมเดลฟรีรวมถึง Big Pickle บวกกับโมเดลโปรโมชั่นที่มีให้ในขณะนั้น ด้วยโควต้า 200 คำขอ/วัน Go รวมถึง GLM-5, Kimi K2.5, MiniMax M2.5 และ MiniMax M2.7 ที่มีโควต้าคำขอสูงกว่า ซึ่งบังคับใช้ผ่านช่วงเวลาหมุนเวียน (5 ชั่วโมง, รายสัปดาห์ และรายเดือน) เทียบเท่าประมาณ $12 ต่อ 5 ชั่วโมง, $30 ต่อสัปดาห์ และ $60 ต่อเดือน (จำนวนคำขอจริงจะแตกต่างกันไปตามโมเดลและการใช้งาน)",
|
||||
"โมเดลฟรีรวมถึง Big Pickle บวกกับโมเดลโปรโมชั่นที่มีให้ในขณะนั้น ด้วยโควต้า 200 คำขอ/วัน Go รวมถึง GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 และ MiniMax M2.7 ที่มีโควต้าคำขอสูงกว่า ซึ่งบังคับใช้ผ่านช่วงเวลาหมุนเวียน (5 ชั่วโมง, รายสัปดาห์ และรายเดือน) เทียบเท่าประมาณ $12 ต่อ 5 ชั่วโมง, $30 ต่อสัปดาห์ และ $60 ต่อเดือน (จำนวนคำขอจริงจะแตกต่างกันไปตามโมเดลและการใช้งาน)",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "เกินขีดจำกัดอัตราการใช้งาน กรุณาลองใหม่ในภายหลัง",
|
||||
"zen.api.error.modelNotSupported": "ไม่รองรับโมเดล {{model}}",
|
||||
@@ -365,6 +365,8 @@ export const dict = {
|
||||
"zen.api.error.userMonthlyLimitReached":
|
||||
"คุณถึงขีดจำกัดการใช้จ่ายรายเดือนที่ ${{amount}} แล้ว จัดการขีดจำกัดของคุณที่นี่: {{membersUrl}}",
|
||||
"zen.api.error.modelDisabled": "โมเดลถูกปิดใช้งาน",
|
||||
"zen.api.error.trialEnded":
|
||||
"โปรโมชันฟรีสำหรับ {{model}} สิ้นสุดแล้ว คุณสามารถใช้โมเดลต่อได้โดยสมัครสมาชิก OpenCode Go - {{link}}",
|
||||
|
||||
"black.meta.title": "OpenCode Black | เข้าถึงโมเดลเขียนโค้ดที่ดีที่สุดในโลก",
|
||||
"black.meta.description": "เข้าถึง Claude, GPT, Gemini และอื่นๆ ด้วยแผนสมาชิก OpenCode Black",
|
||||
|
||||
@@ -253,7 +253,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Herkes için düşük maliyetli kodlama modelleri",
|
||||
"go.meta.description":
|
||||
"Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5, Kimi K2.5, MiniMax M2.5 ve MiniMax M2.7 için cömert 5 saatlik istek limitleri sunar.",
|
||||
"Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 ve MiniMax M2.7 için cömert 5 saatlik istek limitleri sunar.",
|
||||
"go.hero.title": "Herkes için düşük maliyetli kodlama modelleri",
|
||||
"go.hero.body":
|
||||
"Go, dünya çapındaki programcılara ajan tabanlı kodlama getiriyor. En yetenekli açık kaynaklı modellere cömert limitler ve güvenilir erişim sunarak, maliyet veya erişilebilirlik konusunda endişelenmeden güçlü ajanlarla geliştirme yapmanızı sağlar.",
|
||||
@@ -302,7 +302,7 @@ export const dict = {
|
||||
"go.problem.item1": "Düşük maliyetli abonelik fiyatlandırması",
|
||||
"go.problem.item2": "Cömert limitler ve güvenilir erişim",
|
||||
"go.problem.item3": "Mümkün olduğunca çok programcı için geliştirildi",
|
||||
"go.problem.item4": "GLM-5, Kimi K2.5, MiniMax M2.5 ve MiniMax M2.7 içerir",
|
||||
"go.problem.item4": "GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 ve MiniMax M2.7 içerir",
|
||||
"go.how.title": "Go nasıl çalışır?",
|
||||
"go.how.body":
|
||||
"Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar. OpenCode veya herhangi bir ajanla kullanabilirsiniz.",
|
||||
@@ -326,10 +326,10 @@ export const dict = {
|
||||
"Go, ajan tabanlı kodlama için yetenekli açık kaynaklı modellere güvenilir erişim sağlayan düşük maliyetli bir aboneliktir.",
|
||||
"go.faq.q2": "Go hangi modelleri içerir?",
|
||||
"go.faq.a2":
|
||||
"Go, cömert limitler ve güvenilir erişim ile GLM-5, Kimi K2.5, MiniMax M2.5 ve MiniMax M2.7 modellerini içerir.",
|
||||
"Go, cömert limitler ve güvenilir erişim ile GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 ve MiniMax M2.7 modellerini içerir.",
|
||||
"go.faq.q3": "Go, Zen ile aynı mı?",
|
||||
"go.faq.a3":
|
||||
"Hayır. Zen kullandıkça öde modelidir, Go ise ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5, Kimi K2.5, MiniMax M2.5 ve MiniMax M2.7 açık kaynak modellerine cömert limitler ve güvenilir erişim sunar.",
|
||||
"Hayır. Zen kullandıkça öde modelidir, Go ise ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 ve MiniMax M2.7 açık kaynak modellerine cömert limitler ve güvenilir erişim sunar.",
|
||||
"go.faq.q4": "Go ne kadar?",
|
||||
"go.faq.a4.p1.beforePricing": "Go'nun maliyeti",
|
||||
"go.faq.a4.p1.pricingLink": "İlk ay $5",
|
||||
@@ -353,7 +353,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "Ücretsiz modeller ve Go arasındaki fark nedir?",
|
||||
"go.faq.a9":
|
||||
"Ücretsiz modeller, günlük 200 istek kotası ile Big Pickle ve o sırada mevcut olan promosyonel modelleri içerir. Go ise GLM-5, Kimi K2.5, MiniMax M2.5 ve MiniMax M2.7 modellerini; yuvarlanan pencereler (5 saatlik, haftalık ve aylık) üzerinden uygulanan daha yüksek istek kotalarıyla içerir. Bu kotalar kabaca her 5 saatte 12$, haftada 30$ ve ayda 60$ değerine eşdeğerdir (gerçek istek sayıları modele ve kullanıma göre değişir).",
|
||||
"Ücretsiz modeller, günlük 200 istek kotası ile Big Pickle ve o sırada mevcut olan promosyonel modelleri içerir. Go ise GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 ve MiniMax M2.7 modellerini; yuvarlanan pencereler (5 saatlik, haftalık ve aylık) üzerinden uygulanan daha yüksek istek kotalarıyla içerir. Bu kotalar kabaca her 5 saatte 12$, haftada 30$ ve ayda 60$ değerine eşdeğerdir (gerçek istek sayıları modele ve kullanıma göre değişir).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "İstek limiti aşıldı. Lütfen daha sonra tekrar deneyin.",
|
||||
"zen.api.error.modelNotSupported": "{{model}} modeli desteklenmiyor",
|
||||
@@ -372,6 +372,8 @@ export const dict = {
|
||||
"zen.api.error.userMonthlyLimitReached":
|
||||
"Aylık ${{amount}} harcama limitinize ulaştınız. Limitlerinizi buradan yönetin: {{membersUrl}}",
|
||||
"zen.api.error.modelDisabled": "Model devre dışı",
|
||||
"zen.api.error.trialEnded":
|
||||
"{{model}} için ücretsiz promosyon sona erdi. OpenCode Go'ya abone olarak modeli kullanmaya devam edebilirsiniz - {{link}}",
|
||||
|
||||
"black.meta.title": "OpenCode Black | Dünyanın en iyi kodlama modellerine erişin",
|
||||
"black.meta.description": "OpenCode Black abonelik planlarıyla Claude, GPT, Gemini ve daha fazlasına erişin.",
|
||||
|
||||
@@ -241,7 +241,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | 人人可用的低成本编程模型",
|
||||
"go.meta.description":
|
||||
"Go 首月 $5,之后 $10/月,提供对 GLM-5、Kimi K2.5、MiniMax M2.5 和 MiniMax M2.7 的 5 小时充裕请求额度。",
|
||||
"Go 首月 $5,之后 $10/月,提供对 GLM-5、Kimi K2.5、MiMo-V2-Pro、MiMo-V2-Omni、MiniMax M2.5 和 MiniMax M2.7 的 5 小时充裕请求额度。",
|
||||
"go.hero.title": "人人可用的低成本编程模型",
|
||||
"go.hero.body":
|
||||
"Go 将代理编程带给全世界的程序员。提供充裕的限额和对最强大的开源模型的可靠访问,让您可以利用强大的代理进行构建,而无需担心成本或可用性。",
|
||||
@@ -288,7 +288,7 @@ export const dict = {
|
||||
"go.problem.item1": "低成本订阅定价",
|
||||
"go.problem.item2": "充裕的限额和可靠的访问",
|
||||
"go.problem.item3": "为尽可能多的程序员打造",
|
||||
"go.problem.item4": "包含 GLM-5, Kimi K2.5, MiniMax M2.5 和 MiniMax M2.7",
|
||||
"go.problem.item4": "包含 GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 和 MiniMax M2.7",
|
||||
"go.how.title": "Go 如何工作",
|
||||
"go.how.body": "Go 起价为首月 $5,之后 $10/月。您可以将其与 OpenCode 或任何代理搭配使用。",
|
||||
"go.how.step1.title": "创建账户",
|
||||
@@ -307,10 +307,11 @@ export const dict = {
|
||||
"go.faq.q1": "什么是 OpenCode Go?",
|
||||
"go.faq.a1": "Go 是一项低成本订阅服务,为您提供对强大的开源模型的可靠访问,用于代理编程。",
|
||||
"go.faq.q2": "Go 包含哪些模型?",
|
||||
"go.faq.a2": "Go 包含 GLM-5, Kimi K2.5, MiniMax M2.5 和 MiniMax M2.7,并提供充裕的限额和可靠的访问。",
|
||||
"go.faq.a2":
|
||||
"Go 包含 GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 和 MiniMax M2.7,并提供充裕的限额和可靠的访问。",
|
||||
"go.faq.q3": "Go 和 Zen 一样吗?",
|
||||
"go.faq.a3":
|
||||
"不。Zen 是按量付费,而 Go 首月 $5,之后 $10/月,提供充裕的额度,并可可靠地访问 GLM-5、Kimi K2.5、MiniMax M2.5 和 MiniMax M2.7 等开源模型。",
|
||||
"不。Zen 是按量付费,而 Go 首月 $5,之后 $10/月,提供充裕的额度,并可可靠地访问 GLM-5、Kimi K2.5、MiMo-V2-Pro、MiMo-V2-Omni、MiniMax M2.5 和 MiniMax M2.7 等开源模型。",
|
||||
"go.faq.q4": "Go 多少钱?",
|
||||
"go.faq.a4.p1.beforePricing": "Go 费用为",
|
||||
"go.faq.a4.p1.pricingLink": "首月 $5",
|
||||
@@ -332,7 +333,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "免费模型和 Go 之间的区别是什么?",
|
||||
"go.faq.a9":
|
||||
"免费模型包含 Big Pickle 加上当时可用的促销模型,每天有 200 次请求的配额。Go 包含 GLM-5, Kimi K2.5, MiniMax M2.5 和 MiniMax M2.7,并在滚动窗口(5 小时、每周和每月)内执行更高的请求配额,大致相当于每 5 小时 $12、每周 $30 和每月 $60(实际请求计数因模型和使用情况而异)。",
|
||||
"免费模型包含 Big Pickle 加上当时可用的促销模型,每天有 200 次请求的配额。Go 包含 GLM-5, Kimi K2.5, MiMo-V2-Pro, MiMo-V2-Omni, MiniMax M2.5 和 MiniMax M2.7,并在滚动窗口(5 小时、每周和每月)内执行更高的请求配额,大致相当于每 5 小时 $12、每周 $30 和每月 $60(实际请求计数因模型和使用情况而异)。",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "超出速率限制。请稍后重试。",
|
||||
"zen.api.error.modelNotSupported": "不支持模型 {{model}}",
|
||||
@@ -349,6 +350,7 @@ export const dict = {
|
||||
"您的工作区已达到每月支出限额 ${{amount}}。请在此处管理您的限额:{{billingUrl}}",
|
||||
"zen.api.error.userMonthlyLimitReached": "您已达到每月支出限额 ${{amount}}。请在此处管理您的限额:{{membersUrl}}",
|
||||
"zen.api.error.modelDisabled": "模型已禁用",
|
||||
"zen.api.error.trialEnded": "{{model}} 的限免活动已结束。您可以订阅 OpenCode Go 继续使用该模型 - {{link}}",
|
||||
|
||||
"black.meta.title": "OpenCode Black | 访问全球顶尖编程模型",
|
||||
"black.meta.description": "通过 OpenCode Black 订阅计划使用 Claude, GPT, Gemini 等模型。",
|
||||
|
||||
@@ -241,7 +241,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | 低成本全民編碼模型",
|
||||
"go.meta.description":
|
||||
"Go 首月 $5,之後 $10/月,提供對 GLM-5、Kimi K2.5、MiniMax M2.5 和 MiniMax M2.7 的 5 小時充裕請求額度。",
|
||||
"Go 首月 $5,之後 $10/月,提供對 GLM-5、Kimi K2.5、MiMo-V2-Pro、MiMo-V2-Omni、MiniMax M2.5 和 MiniMax M2.7 的 5 小時充裕請求額度。",
|
||||
"go.hero.title": "低成本全民編碼模型",
|
||||
"go.hero.body":
|
||||
"Go 將代理編碼帶給全世界的程式設計師。提供寬裕的限額以及對最強大開源模型的穩定存取,讓你可以使用強大的代理進行構建,而無需擔心成本或可用性。",
|
||||
@@ -288,7 +288,7 @@ export const dict = {
|
||||
"go.problem.item1": "低成本訂閱定價",
|
||||
"go.problem.item2": "寬裕的限額與穩定存取",
|
||||
"go.problem.item3": "專為盡可能多的程式設計師打造",
|
||||
"go.problem.item4": "包含 GLM-5、Kimi K2.5、MiniMax M2.5 與 MiniMax M2.7",
|
||||
"go.problem.item4": "包含 GLM-5、Kimi K2.5、MiMo-V2-Pro、MiMo-V2-Omni、MiniMax M2.5 與 MiniMax M2.7",
|
||||
"go.how.title": "Go 如何運作",
|
||||
"go.how.body": "Go 起價為首月 $5,之後 $10/月。您可以將其與 OpenCode 或任何代理搭配使用。",
|
||||
"go.how.step1.title": "建立帳號",
|
||||
@@ -307,10 +307,11 @@ export const dict = {
|
||||
"go.faq.q1": "什麼是 OpenCode Go?",
|
||||
"go.faq.a1": "Go 是一個低成本訂閱方案,讓你穩定存取強大的開源模型以進行代理編碼。",
|
||||
"go.faq.q2": "Go 包含哪些模型?",
|
||||
"go.faq.a2": "Go 包含 GLM-5、Kimi K2.5、MiniMax M2.5 與 MiniMax M2.7,並提供寬裕的限額與穩定存取。",
|
||||
"go.faq.a2":
|
||||
"Go 包含 GLM-5、Kimi K2.5、MiMo-V2-Pro、MiMo-V2-Omni、MiniMax M2.5 與 MiniMax M2.7,並提供寬裕的限額與穩定存取。",
|
||||
"go.faq.q3": "Go 與 Zen 一樣嗎?",
|
||||
"go.faq.a3":
|
||||
"不。Zen 是按量付費,而 Go 首月 $5,之後 $10/月,提供充裕的額度,並可可靠地存取 GLM-5、Kimi K2.5、MiniMax M2.5 和 MiniMax M2.7 等開源模型。",
|
||||
"不。Zen 是按量付費,而 Go 首月 $5,之後 $10/月,提供充裕的額度,並可可靠地存取 GLM-5、Kimi K2.5、MiMo-V2-Pro、MiMo-V2-Omni、MiniMax M2.5 和 MiniMax M2.7 等開源模型。",
|
||||
"go.faq.q4": "Go 費用是多少?",
|
||||
"go.faq.a4.p1.beforePricing": "Go 費用為",
|
||||
"go.faq.a4.p1.pricingLink": "首月 $5",
|
||||
@@ -332,7 +333,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "免費模型與 Go 有什麼區別?",
|
||||
"go.faq.a9":
|
||||
"免費模型包括 Big Pickle 以及當時可用的促銷模型,配額為 200 次請求/天。Go 包括 GLM-5、Kimi K2.5、MiniMax M2.5 與 MiniMax M2.7,並在滾動視窗(5 小時、每週和每月)內執行更高的請求配額,大約相當於每 5 小時 $12、每週 $30 和每月 $60(實際請求數因模型和使用情況而異)。",
|
||||
"免費模型包括 Big Pickle 以及當時可用的促銷模型,配額為 200 次請求/天。Go 包括 GLM-5、Kimi K2.5、MiMo-V2-Pro、MiMo-V2-Omni、MiniMax M2.5 與 MiniMax M2.7,並在滾動視窗(5 小時、每週和每月)內執行更高的請求配額,大約相當於每 5 小時 $12、每週 $30 和每月 $60(實際請求數因模型和使用情況而異)。",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "超出頻率限制。請稍後再試。",
|
||||
"zen.api.error.modelNotSupported": "不支援模型 {{model}}",
|
||||
@@ -349,6 +350,7 @@ export const dict = {
|
||||
"你的工作區已達到每月支出限額 ${{amount}}。請在此處管理你的限額:{{billingUrl}}",
|
||||
"zen.api.error.userMonthlyLimitReached": "你已達到每月支出限額 ${{amount}}。請在此處管理你的限額:{{membersUrl}}",
|
||||
"zen.api.error.modelDisabled": "模型已停用",
|
||||
"zen.api.error.trialEnded": "{{model}} 的限免活动已結束。您可以訂閱 OpenCode Go 繼續使用該模型 - {{link}}",
|
||||
|
||||
"black.meta.title": "OpenCode Black | 存取全球最佳編碼模型",
|
||||
"black.meta.description": "透過 OpenCode Black 訂閱方案存取 Claude、GPT、Gemini 等模型。",
|
||||
|
||||
@@ -564,7 +564,7 @@ body {
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
height: 260px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Footer } from "~/component/footer"
|
||||
import { Header } from "~/component/header"
|
||||
import { config } from "~/config"
|
||||
import { getLastSeenWorkspaceID } from "../workspace/common"
|
||||
import { IconMiniMax, IconZai } from "~/component/icon"
|
||||
import { IconMiniMax, IconMiMo, IconZai } from "~/component/icon"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
import { useLanguage } from "~/context/language"
|
||||
import { LocaleLinks } from "~/component/locale-links"
|
||||
@@ -47,12 +47,14 @@ function LimitsGraph(props: { href: string }) {
|
||||
const models = [
|
||||
{ id: "glm", name: "GLM-5", req: 1150, d: "120ms" },
|
||||
{ id: "kimi", name: "Kimi K2.5", req: 1850, d: "240ms" },
|
||||
{ id: "mimo-v2-pro", name: "MiMo-V2-Pro", req: 1290, d: "150ms" },
|
||||
{ id: "mimo-v2-omni", name: "MiMo-V2-Omni", req: 2150, d: "270ms" },
|
||||
{ id: "minimax-m2.7", name: "MiniMax M2.7", req: 14000, d: "330ms" },
|
||||
{ id: "minimax-m2.5", name: "MiniMax M2.5", req: 20000, d: "360ms" },
|
||||
]
|
||||
|
||||
const w = 720
|
||||
const h = 220
|
||||
const h = 260
|
||||
const left = 40
|
||||
const right = 60
|
||||
const top = 18
|
||||
@@ -298,6 +300,9 @@ export default function Home() {
|
||||
<div>
|
||||
<IconZai width="24" height="24" />
|
||||
</div>
|
||||
<div>
|
||||
<IconMiMo width="24" height="24" />
|
||||
</div>
|
||||
{/*
|
||||
<div>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -432,9 +437,7 @@ export default function Home() {
|
||||
</Faq>
|
||||
</li>
|
||||
<li>
|
||||
<Faq question={i18n.t("go.faq.q5")}>
|
||||
{i18n.t("go.faq.a5.body")} <a href="mailto:contact@anoma.ly">{i18n.t("common.contactUs")}</a>
|
||||
</Faq>
|
||||
<Faq question={i18n.t("go.faq.q5")}>{i18n.t("go.faq.a5.body")}</Faq>
|
||||
</li>
|
||||
<li>
|
||||
<Faq question={i18n.t("go.faq.q6")}>{i18n.t("go.faq.a6")}</Faq>
|
||||
|
||||
@@ -100,6 +100,7 @@ export async function handler(
|
||||
session: sessionId,
|
||||
request: requestId,
|
||||
client: ocClient,
|
||||
...(model === "mimo-v2-pro-free" && JSON.stringify(body).length < 1000 ? { payload: JSON.stringify(body) } : {}),
|
||||
})
|
||||
const zenData = ZenData.list(opts.modelList)
|
||||
const modelInfo = validateModel(zenData, model)
|
||||
@@ -139,19 +140,16 @@ export async function handler(
|
||||
const startTimestamp = Date.now()
|
||||
const reqUrl = providerInfo.modifyUrl(providerInfo.api, isStream)
|
||||
const reqBody = JSON.stringify(
|
||||
providerInfo.modifyBody(
|
||||
{
|
||||
...createBodyConverter(opts.format, providerInfo.format)(body),
|
||||
model: providerInfo.model,
|
||||
...(providerInfo.payloadModifier ?? {}),
|
||||
...Object.fromEntries(
|
||||
Object.entries(providerInfo.payloadMappings ?? {})
|
||||
.map(([k, v]) => [k, input.request.headers.get(v)])
|
||||
.filter(([_k, v]) => !!v),
|
||||
),
|
||||
},
|
||||
authInfo?.workspaceID,
|
||||
),
|
||||
providerInfo.modifyBody({
|
||||
...createBodyConverter(opts.format, providerInfo.format)(body),
|
||||
model: providerInfo.model,
|
||||
...(providerInfo.payloadModifier ?? {}),
|
||||
...Object.fromEntries(
|
||||
Object.entries(providerInfo.payloadMappings ?? {})
|
||||
.map(([k, v]) => [k, input.request.headers.get(v)])
|
||||
.filter(([_k, v]) => !!v),
|
||||
),
|
||||
}),
|
||||
)
|
||||
logger.debug("REQUEST URL: " + reqUrl)
|
||||
logger.debug("REQUEST: " + reqBody.substring(0, 300) + "...")
|
||||
@@ -406,6 +404,14 @@ export async function handler(
|
||||
}),
|
||||
)
|
||||
|
||||
if (modelData.trialEnded)
|
||||
throw new ModelError(
|
||||
`${t("zen.api.error.trialEnded", {
|
||||
model: modelData.name,
|
||||
link: "https://opencode.ai/go",
|
||||
})}`,
|
||||
)
|
||||
|
||||
logger.metric({ model: modelId })
|
||||
|
||||
return { id: modelId, ...modelData }
|
||||
@@ -470,15 +476,17 @@ export async function handler(
|
||||
...(() => {
|
||||
const providerProps = zenData.providers[modelProvider.id]
|
||||
const format = providerProps.format
|
||||
const providerModel = modelProvider.model
|
||||
if (format === "anthropic") return anthropicHelper({ reqModel, providerModel })
|
||||
if (format === "google") return googleHelper({ reqModel, providerModel })
|
||||
if (format === "openai") return openaiHelper({ reqModel, providerModel })
|
||||
return oaCompatHelper({
|
||||
const opts = {
|
||||
reqModel,
|
||||
providerModel,
|
||||
providerModel: modelProvider.model,
|
||||
adjustCacheUsage: providerProps.adjustCacheUsage,
|
||||
})
|
||||
safetyIdentifier: modelProvider.safetyIdentifier ? ip : undefined,
|
||||
workspaceID: authInfo?.workspaceID,
|
||||
}
|
||||
if (format === "anthropic") return anthropicHelper(opts)
|
||||
if (format === "google") return googleHelper(opts)
|
||||
if (format === "openai") return openaiHelper(opts)
|
||||
return oaCompatHelper(opts)
|
||||
})(),
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user