Compare commits

..
Author SHA1 Message Date
Kit Langton 33637f3e35 fix(app): suppress idle sound after interrupt 2026-03-10 20:25:34 -04:00
Dax Raad a44f78c34a core: maintain backward compatibility with existing account data by restoring legacy ControlAccountTable alongside new AccountTable structure 2026-03-01 14:21:55 -05:00
Dax Raad a5d727e7f9 core: enable workspace-aware configuration and account management commands
Switch from boolean active flag to workspace_id tracking so users can select which workspace context to operate in. Login now automatically selects the first available workspace and stores it on the account record.

Logout command now actually removes account records and supports targeting specific accounts by email. Switch command provides an interactive picker to change active workspace. Workspaces command lists all available workspaces across accounts.

Configuration now loads workspace-specific settings from the server when an active workspace is selected, enabling per-workspace customization of opencode behavior.
2026-03-01 14:21:55 -05:00
Dax Raad 7b5b665b4a core: support managing multiple authenticated accounts with individual workspace access
Enable users to authenticate with multiple accounts and switch between
them, accessing workspaces from each account separately.
2026-03-01 14:21:55 -05:00
Dax Raad b5515dd2f7 core: add device flow authentication commands
Allow users to authenticate via browser-based OAuth device flow
with opencode login command. Includes login, logout, switch account,
and workspaces list commands for managing multiple accounts.
2026-03-01 14:21:55 -05:00
Dax Raad d16e5b98dc core: rename control module to account for clearer authentication management
Refactor internal authentication system by renaming the control module to account,
making it easier to understand that this handles user account credentials and
tokens. Simplify database schema management by removing the centralized schema
exports and letting each module manage its own tables directly.
2026-03-01 14:21:55 -05:00
Dax Raad 9dbf3a2042 core: rename auth command to providers for clearer credential management
The auth command has been renamed to providers to better reflect its purpose of managing AI provider credentials. This makes it easier for users to discover and use the credential management features when configuring different AI providers.
2026-03-01 14:21:55 -05:00
652 changed files with 6902 additions and 25257 deletions
+37 -4
View File
@@ -59,10 +59,43 @@ jobs:
{ {
"permission": { "permission": {
"*": "deny", "*": "deny",
"read": "allow", "read": {
"edit": "allow", "*": "deny",
"glob": "allow", "packages/web/src/content/docs": "allow",
"task": "allow" "packages/web/src/content/docs/*": "allow",
"packages/web/src/content/docs/*.mdx": "allow",
"packages/web/src/content/docs/*/*.mdx": "allow",
".opencode": "allow",
".opencode/agent": "allow",
".opencode/glossary": "allow",
".opencode/agent/translator.md": "allow",
".opencode/glossary/*.md": "allow"
},
"edit": {
"*": "deny",
"packages/web/src/content/docs/*/*.mdx": "allow"
},
"glob": {
"*": "deny",
"packages/web/src/content/docs*": "allow",
".opencode/glossary*": "allow"
},
"task": {
"*": "deny",
"translator": "allow"
}
},
"agent": {
"translator": {
"permission": {
"*": "deny",
"read": {
"*": "deny",
".opencode/agent/translator.md": "allow",
".opencode/glossary/*.md": "allow"
}
}
}
} }
} }
run: | run: |
+1 -127
View File
@@ -99,6 +99,7 @@ jobs:
with: with:
name: opencode-cli name: opencode-cli
path: packages/opencode/dist path: packages/opencode/dist
outputs: outputs:
version: ${{ needs.version.outputs.version }} version: ${{ needs.version.outputs.version }}
@@ -239,131 +240,11 @@ jobs:
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8 APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
build-electron:
needs:
- build-cli
- version
continue-on-error: false
strategy:
fail-fast: false
matrix:
settings:
- host: macos-latest
target: x86_64-apple-darwin
platform_flag: --mac --x64
- host: macos-latest
target: aarch64-apple-darwin
platform_flag: --mac --arm64
- host: "blacksmith-4vcpu-windows-2025"
target: x86_64-pc-windows-msvc
platform_flag: --win
- host: "blacksmith-4vcpu-ubuntu-2404"
target: x86_64-unknown-linux-gnu
platform_flag: --linux
- host: "blacksmith-4vcpu-ubuntu-2404"
target: aarch64-unknown-linux-gnu
platform_flag: --linux
runs-on: ${{ matrix.settings.host }}
# if: github.ref_name == 'beta'
steps:
- uses: actions/checkout@v3
- uses: apple-actions/import-codesign-certs@v2
if: runner.os == 'macOS'
with:
keychain: build
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- name: Setup Apple API Key
if: runner.os == 'macOS'
run: echo "${{ secrets.APPLE_API_KEY_PATH }}" > $RUNNER_TEMP/apple-api-key.p8
- uses: ./.github/actions/setup-bun
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: Cache apt packages
if: contains(matrix.settings.host, 'ubuntu')
uses: actions/cache@v4
with:
path: ~/apt-cache
key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-electron-${{ hashFiles('.github/workflows/publish.yml') }}
restore-keys: |
${{ runner.os }}-${{ matrix.settings.target }}-apt-electron-
- name: Install dependencies (ubuntu only)
if: contains(matrix.settings.host, 'ubuntu')
run: |
mkdir -p ~/apt-cache && chmod -R a+rw ~/apt-cache
sudo apt-get update
sudo apt-get install -y --no-install-recommends -o dir::cache::archives="$HOME/apt-cache" rpm
sudo chmod -R a+rw ~/apt-cache
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Prepare
run: bun ./scripts/prepare.ts
working-directory: packages/desktop-electron
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
- name: Build
run: bun run build
working-directory: packages/desktop-electron
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
- name: Package and publish
if: needs.version.outputs.release
run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish always --config electron-builder.config.ts
working-directory: packages/desktop-electron
timeout-minutes: 60
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
GH_TOKEN: ${{ steps.committer.outputs.token }}
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
- name: Package (no publish)
if: ${{ !needs.version.outputs.release }}
run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish never --config electron-builder.config.ts
working-directory: packages/desktop-electron
timeout-minutes: 60
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
- uses: actions/upload-artifact@v4
with:
name: opencode-electron-${{ matrix.settings.target }}
path: packages/desktop-electron/dist/*
- uses: actions/upload-artifact@v4
if: needs.version.outputs.release
with:
name: latest-yml-${{ matrix.settings.target }}
path: packages/desktop-electron/dist/latest*.yml
publish: publish:
needs: needs:
- version - version
- build-cli - build-cli
- build-tauri - build-tauri
- build-electron
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: blacksmith-4vcpu-ubuntu-2404
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
@@ -400,12 +281,6 @@ jobs:
name: opencode-cli name: opencode-cli
path: packages/opencode/dist path: packages/opencode/dist
- uses: actions/download-artifact@v4
if: needs.version.outputs.release
with:
pattern: latest-yml-*
path: /tmp/latest-yml
- name: Cache apt packages (AUR) - name: Cache apt packages (AUR)
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -433,4 +308,3 @@ jobs:
GITHUB_TOKEN: ${{ steps.committer.outputs.token }} GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
GH_REPO: ${{ needs.version.outputs.repo }} GH_REPO: ${{ needs.version.outputs.repo }}
NPM_CONFIG_PROVENANCE: false NPM_CONFIG_PROVENANCE: false
LATEST_YML_DIR: /tmp/latest-yml
-38
View File
@@ -1,38 +0,0 @@
# tr Glossary
## Sources
- PR #15835: https://github.com/anomalyco/opencode/pull/15835
## Do Not Translate (Locale Additions)
- `OpenCode` (preserve casing in prose, docs, and UI copy)
- Keep lowercase `opencode` in commands, package names, paths, URLs, and other exact identifiers
- `<TAB>` stays the literal key token in code blocks; use `Tab` for the nearby explanatory label in prose
- Commands, flags, file paths, and code literals (keep exactly as written)
## Preferred Terms
These are PR-backed wording preferences and may evolve.
| English / Context | Preferred | Notes |
| ------------------------- | --------------------------------------- | ------------------------------------------------------------- |
| available in beta | `beta olarak mevcut` | Prefer this over `beta olarak kullanılabilir` |
| privacy-first | `Gizlilik öncelikli tasarlandı` | Prefer this over `Önce gizlilik için tasarlandı` |
| connect your local models | `yerel modellerinizi bağlayabilirsiniz` | Use the fuller, more direct action phrase |
| `<TAB>` key label | `Tab` | Use `Tab` in prose; keep `<TAB>` in literal UI or code blocks |
| cross-platform | `cross-platform (tüm platformlarda)` | Keep the English term, add a short clarification when helpful |
## Guidance
- Prefer natural Turkish phrasing over literal translation
- Merge broken sentence fragments into one clear sentence when the source is a single thought
- Keep product naming consistent: `OpenCode` in prose, `opencode` only for exact technical identifiers
- When an English technical term is intentionally kept, add a short Turkish clarification only if it improves readability
## Avoid
- Avoid `beta olarak kullanılabilir` when `beta olarak mevcut` fits
- Avoid `Önce gizlilik için tasarlandı`; use the more natural reviewed wording instead
- Avoid `Sekme` for the translated key label in prose when referring to `<TAB>`
- Avoid changing `opencode` to `OpenCode` inside commands, URLs, package names, or code literals
+1 -1
View File
@@ -1,6 +1,6 @@
Use this tool to search GitHub pull requests by title and description. Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including: This tool searches PRs in the sst/opencode repository and returns LLM-friendly results including:
- PR number and title - PR number and title
- Author - Author
- State (open/closed/merged) - State (open/closed/merged)
@@ -0,0 +1 @@
Fixed typecheck error by reverting key name from 'session.new.worktree.startup' back to 'session.new.workspace.startup' in packages/console/app/src/i18n/tr.ts.
@@ -0,0 +1 @@
Applied minor linguistic polishes to Turkish translations in packages/console/app/src/i18n/tr.ts. PR created at https://github.com/anomalyco/opencode/pull/15468
+4 -11
View File
@@ -20,17 +20,6 @@
Prefer single word names for variables and functions. Only use multiple words if necessary. Prefer single word names for variables and functions. Only use multiple words if necessary.
### Naming Enforcement (Read This)
THIS RULE IS MANDATORY FOR AGENT WRITTEN CODE.
- Use single word names by default for new locals, params, and helper functions.
- Multi-word names are allowed only when a single word would be unclear or ambiguous.
- Do not introduce new camelCase compounds when a short single-word alternative is clear.
- Before finishing edits, review touched lines and shorten newly introduced identifiers where possible.
- Good short names to prefer: `pid`, `cfg`, `err`, `opts`, `dir`, `root`, `child`, `state`, `timeout`.
- Examples to avoid unless truly required: `inputPID`, `existingClient`, `connectTimeout`, `workerPath`.
```ts ```ts
// Good // Good
const foo = 1 const foo = 1
@@ -122,3 +111,7 @@ const table = sqliteTable("session", {
- Avoid mocks as much as possible - Avoid mocks as much as possible
- Test actual implementation, do not duplicate logic into tests - Test actual implementation, do not duplicate logic into tests
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`. - Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
## Type Checking
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
+1 -2
View File
@@ -35,8 +35,7 @@
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> | <a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> | <a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> | <a href="README.gr.md">Ελληνικά</a>
<a href="README.vi.md">Tiếng Việt</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -2
View File
@@ -35,8 +35,7 @@
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> | <a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> | <a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> | <a href="README.gr.md">Ελληνικά</a>
<a href="README.vi.md">Tiếng Việt</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -3
View File
@@ -27,7 +27,6 @@
<a href="README.ja.md">日本語</a> | <a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> | <a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
@@ -35,8 +34,7 @@
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> | <a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> | <a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> | <a href="README.gr.md">Ελληνικά</a>
<a href="README.vi.md">Tiếng Việt</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -2
View File
@@ -35,8 +35,7 @@
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> | <a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> | <a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> | <a href="README.gr.md">Ελληνικά</a>
<a href="README.vi.md">Tiếng Việt</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -3
View File
@@ -27,7 +27,6 @@
<a href="README.ja.md">日本語</a> | <a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> | <a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
@@ -35,8 +34,7 @@
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> | <a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> | <a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> | <a href="README.gr.md">Ελληνικά</a>
<a href="README.vi.md">Tiếng Việt</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -3
View File
@@ -27,7 +27,6 @@
<a href="README.ja.md">日本語</a> | <a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> | <a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
@@ -35,8 +34,7 @@
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> | <a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> | <a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> | <a href="README.gr.md">Ελληνικά</a>
<a href="README.vi.md">Tiếng Việt</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -3
View File
@@ -27,7 +27,6 @@
<a href="README.ja.md">日本語</a> | <a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> | <a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
@@ -35,8 +34,7 @@
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> | <a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> | <a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> | <a href="README.gr.md">Ελληνικά</a>
<a href="README.vi.md">Tiếng Việt</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -3
View File
@@ -27,7 +27,6 @@
<a href="README.ja.md">日本語</a> | <a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> | <a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
@@ -35,8 +34,7 @@
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> | <a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> | <a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> | <a href="README.gr.md">Ελληνικά</a>
<a href="README.vi.md">Tiếng Việt</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -2
View File
@@ -35,8 +35,7 @@
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> | <a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> | <a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> | <a href="README.gr.md">Ελληνικά</a>
<a href="README.vi.md">Tiếng Việt</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -3
View File
@@ -27,7 +27,6 @@
<a href="README.ja.md">日本語</a> | <a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> | <a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
@@ -35,8 +34,7 @@
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> | <a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> | <a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> | <a href="README.gr.md">Ελληνικά</a>
<a href="README.vi.md">Tiếng Việt</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -3
View File
@@ -27,7 +27,6 @@
<a href="README.ja.md">日本語</a> | <a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> | <a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
@@ -35,8 +34,7 @@
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> | <a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> | <a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> | <a href="README.gr.md">Ελληνικά</a>
<a href="README.vi.md">Tiếng Việt</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -3
View File
@@ -27,7 +27,6 @@
<a href="README.ja.md">日本語</a> | <a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> | <a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
@@ -35,8 +34,7 @@
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> | <a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> | <a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> | <a href="README.gr.md">Ελληνικά</a>
<a href="README.vi.md">Tiếng Việt</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -2
View File
@@ -35,8 +35,7 @@
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> | <a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> | <a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> | <a href="README.gr.md">Ελληνικά</a>
<a href="README.vi.md">Tiếng Việt</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -3
View File
@@ -27,7 +27,6 @@
<a href="README.ja.md">日本語</a> | <a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> | <a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
@@ -35,8 +34,7 @@
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> | <a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> | <a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> | <a href="README.gr.md">Ελληνικά</a>
<a href="README.vi.md">Tiếng Việt</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -3
View File
@@ -27,7 +27,6 @@
<a href="README.ja.md">日本語</a> | <a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> | <a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
@@ -35,8 +34,7 @@
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> | <a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> | <a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> | <a href="README.gr.md">Ελληνικά</a>
<a href="README.vi.md">Tiếng Việt</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -3
View File
@@ -27,7 +27,6 @@
<a href="README.ja.md">日本語</a> | <a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> | <a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
@@ -35,8 +34,7 @@
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> | <a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> | <a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> | <a href="README.gr.md">Ελληνικά</a>
<a href="README.vi.md">Tiếng Việt</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -3
View File
@@ -27,7 +27,6 @@
<a href="README.ja.md">日本語</a> | <a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> | <a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
@@ -35,8 +34,7 @@
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> | <a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> | <a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> | <a href="README.gr.md">Ελληνικά</a>
<a href="README.vi.md">Tiếng Việt</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -3
View File
@@ -27,7 +27,6 @@
<a href="README.ja.md">日本語</a> | <a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> | <a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
@@ -35,8 +34,7 @@
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> | <a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> | <a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> | <a href="README.gr.md">Ελληνικά</a>
<a href="README.vi.md">Tiếng Việt</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -2
View File
@@ -35,8 +35,7 @@
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> | <a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> | <a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> | <a href="README.gr.md">Ελληνικά</a>
<a href="README.vi.md">Tiếng Việt</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
-141
View File
@@ -1,141 +0,0 @@
<p align="center">
<a href="https://opencode.ai">
<picture>
<source srcset="packages/console/app/src/asset/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
<source srcset="packages/console/app/src/asset/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
<img src="packages/console/app/src/asset/logo-ornate-light.svg" alt="OpenCode logo">
</picture>
</a>
</p>
<p align="center">Trợ lý lập trình AI mã nguồn mở.</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh.md">简体中文</a> |
<a href="README.zht.md">繁體中文</a> |
<a href="README.ko.md">한국어</a> |
<a href="README.de.md">Deutsch</a> |
<a href="README.es.md">Español</a> |
<a href="README.fr.md">Français</a> |
<a href="README.it.md">Italiano</a> |
<a href="README.da.md">Dansk</a> |
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> |
<a href="README.vi.md">Tiếng Việt</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
---
### Cài đặt
```bash
# YOLO
curl -fsSL https://opencode.ai/install | bash
# Các trình quản lý gói (Package managers)
npm i -g opencode-ai@latest # hoặc bun/pnpm/yarn
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS và Linux (khuyên dùng, luôn cập nhật)
brew install opencode # macOS và Linux (công thức brew chính thức, ít cập nhật hơn)
sudo pacman -S opencode # Arch Linux (Bản ổn định)
paru -S opencode-bin # Arch Linux (Bản mới nhất từ AUR)
mise use -g opencode # Mọi hệ điều hành
nix run nixpkgs#opencode # hoặc github:anomalyco/opencode cho nhánh dev mới nhất
```
> [!TIP]
> Hãy xóa các phiên bản cũ hơn 0.1.x trước khi cài đặt.
### Ứng dụng Desktop (BETA)
OpenCode cũng có sẵn dưới dạng ứng dụng desktop. Tải trực tiếp từ [trang releases](https://github.com/anomalyco/opencode/releases) hoặc [opencode.ai/download](https://opencode.ai/download).
| Nền tảng | Tải xuống |
| --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb`, `.rpm`, hoặc AppImage |
```bash
# macOS (Homebrew)
brew install --cask opencode-desktop
# Windows (Scoop)
scoop bucket add extras; scoop install extras/opencode-desktop
```
#### Thư mục cài đặt
Tập lệnh cài đặt tuân theo thứ tự ưu tiên sau cho đường dẫn cài đặt:
1. `$OPENCODE_INSTALL_DIR` - Thư mục cài đặt tùy chỉnh
2. `$XDG_BIN_DIR` - Đường dẫn tuân thủ XDG Base Directory Specification
3. `$HOME/bin` - Thư mục nhị phân tiêu chuẩn của người dùng (nếu tồn tại hoặc có thể tạo)
4. `$HOME/.opencode/bin` - Mặc định dự phòng
```bash
# Ví dụ
OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
```
### Agents (Đại diện)
OpenCode bao gồm hai agent được tích hợp sẵn mà bạn có thể chuyển đổi bằng phím `Tab`.
- **build** - Agent mặc định, có toàn quyền truy cập cho công việc lập trình
- **plan** - Agent chỉ đọc dùng để phân tích và khám phá mã nguồn
- Mặc định từ chối việc chỉnh sửa tệp
- Hỏi quyền trước khi chạy các lệnh bash
- Lý tưởng để khám phá các codebase lạ hoặc lên kế hoạch thay đổi
Ngoài ra còn có một subagent **general** dùng cho các tìm kiếm phức tạp và tác vụ nhiều bước.
Agent này được sử dụng nội bộ và có thể gọi bằng cách dùng `@general` trong tin nhắn.
Tìm hiểu thêm về [agents](https://opencode.ai/docs/agents).
### Tài liệu
Để biết thêm thông tin về cách cấu hình OpenCode, [**hãy truy cập tài liệu của chúng tôi**](https://opencode.ai/docs).
### Đóng góp
Nếu bạn muốn đóng góp cho OpenCode, vui lòng đọc [tài liệu hướng dẫn đóng góp](./CONTRIBUTING.md) trước khi gửi pull request.
### Xây dựng trên nền tảng OpenCode
Nếu bạn đang làm việc trên một dự án liên quan đến OpenCode và sử dụng "opencode" như một phần của tên dự án, ví dụ "opencode-dashboard" hoặc "opencode-mobile", vui lòng thêm một ghi chú vào README của bạn để làm rõ rằng dự án đó không được xây dựng bởi đội ngũ OpenCode và không liên kết với chúng tôi dưới bất kỳ hình thức nào.
### Các câu hỏi thường gặp (FAQ)
#### OpenCode khác biệt thế nào so với Claude Code?
Về mặt tính năng, nó rất giống Claude Code. Dưới đây là những điểm khác biệt chính:
- 100% mã nguồn mở
- Không bị ràng buộc với bất kỳ nhà cung cấp nào. Mặc dù chúng tôi khuyên dùng các mô hình được cung cấp qua [OpenCode Zen](https://opencode.ai/zen), OpenCode có thể được sử dụng với Claude, OpenAI, Google, hoặc thậm chí các mô hình chạy cục bộ. Khi các mô hình phát triển, khoảng cách giữa chúng sẽ thu hẹp lại và giá cả sẽ giảm, vì vậy việc không phụ thuộc vào nhà cung cấp là rất quan trọng.
- Hỗ trợ LSP ngay từ đầu
- Tập trung vào TUI (Giao diện người dùng dòng lệnh). OpenCode được xây dựng bởi những người dùng neovim và đội ngũ tạo ra [terminal.shop](https://terminal.shop); chúng tôi sẽ đẩy giới hạn của những gì có thể làm được trên terminal lên mức tối đa.
- Kiến trúc client/server. Chẳng hạn, điều này cho phép OpenCode chạy trên máy tính của bạn trong khi bạn điều khiển nó từ xa qua một ứng dụng di động, nghĩa là frontend TUI chỉ là một trong những client có thể dùng.
---
**Tham gia cộng đồng của chúng tôi** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+1 -3
View File
@@ -27,7 +27,6 @@
<a href="README.ja.md">日本語</a> | <a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> | <a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
@@ -35,8 +34,7 @@
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> | <a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> | <a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> | <a href="README.gr.md">Ελληνικά</a>
<a href="README.vi.md">Tiếng Việt</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+1 -3
View File
@@ -27,7 +27,6 @@
<a href="README.ja.md">日本語</a> | <a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> | <a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
@@ -35,8 +34,7 @@
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a> | <a href="README.uk.md">Українська</a> |
<a href="README.bn.md">বাংলা</a> | <a href="README.bn.md">বাংলা</a> |
<a href="README.gr.md">Ελληνικά</a> | <a href="README.gr.md">Ελληνικά</a>
<a href="README.vi.md">Tiếng Việt</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+65 -740
View File
File diff suppressed because it is too large Load Diff
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1772091128, "lastModified": 1770812194,
"narHash": "sha256-TnrYykX8Mf/Ugtkix6V+PjW7miU2yClA6uqWl/v6KWM=", "narHash": "sha256-OH+lkaIKAvPXR3nITO7iYZwew2nW9Y7Xxq0yfM/UcUU=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "3f0336406035444b4a24b942788334af5f906259", "rev": "8482c7ded03bae7550f3d69884f1e611e3bd19e8",
"type": "github" "type": "github"
}, },
"original": { "original": {
+1 -2
View File
@@ -8,7 +8,6 @@ import type { Context as GitHubContext } from "@actions/github/lib/context"
import type { IssueCommentEvent, PullRequestReviewCommentEvent } from "@octokit/webhooks-types" import type { IssueCommentEvent, PullRequestReviewCommentEvent } from "@octokit/webhooks-types"
import { createOpencodeClient } from "@opencode-ai/sdk" import { createOpencodeClient } from "@opencode-ai/sdk"
import { spawn } from "node:child_process" import { spawn } from "node:child_process"
import { setTimeout as sleep } from "node:timers/promises"
type GitHubAuthor = { type GitHubAuthor = {
login: string login: string
@@ -282,7 +281,7 @@ async function assertOpencodeConnected() {
connected = true connected = true
break break
} catch (e) {} } catch (e) {}
await sleep(300) await Bun.sleep(300)
} while (retry++ < 30) } while (retry++ < 30)
if (!connected) { if (!connected) {
+4 -1
View File
@@ -118,6 +118,7 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
price: zenLitePrice.id, price: zenLitePrice.id,
}, },
}) })
const ZEN_LITE_LIMITS = new sst.Secret("ZEN_LITE_LIMITS")
const zenBlackProduct = new stripe.Product("ZenBlack", { const zenBlackProduct = new stripe.Product("ZenBlack", {
name: "OpenCode Black", name: "OpenCode Black",
@@ -141,6 +142,7 @@ const ZEN_BLACK_PRICE = new sst.Linkable("ZEN_BLACK_PRICE", {
plan20: zenBlackPrice20.id, plan20: zenBlackPrice20.id,
}, },
}) })
const ZEN_BLACK_LIMITS = new sst.Secret("ZEN_BLACK_LIMITS")
const ZEN_MODELS = [ const ZEN_MODELS = [
new sst.Secret("ZEN_MODELS1"), new sst.Secret("ZEN_MODELS1"),
@@ -213,8 +215,9 @@ new sst.cloudflare.x.SolidStart("Console", {
AWS_SES_ACCESS_KEY_ID, AWS_SES_ACCESS_KEY_ID,
AWS_SES_SECRET_ACCESS_KEY, AWS_SES_SECRET_ACCESS_KEY,
ZEN_BLACK_PRICE, ZEN_BLACK_PRICE,
ZEN_BLACK_LIMITS,
ZEN_LITE_PRICE, ZEN_LITE_PRICE,
new sst.Secret("ZEN_LIMITS"), ZEN_LITE_LIMITS,
new sst.Secret("ZEN_SESSION_SECRET"), new sst.Secret("ZEN_SESSION_SECRET"),
...ZEN_MODELS, ...ZEN_MODELS,
...($dev ...($dev
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-4kjoJ06VNvHltPHfzQRBG0bC6R39jao10ffGzrNZ230=", "x86_64-linux": "sha256-2XLuizbG90QDUQL+1M90XxfVZxjkIQ1cFYS46nnVO7g=",
"aarch64-linux": "sha256-6Uio+S2rcyBWbBEeOZb9N1CCKgkbKi68lOIKi3Ws/pQ=", "aarch64-linux": "sha256-hlckiGAtbpAlwgcE7KgzKKRq9T2FEOSq3Q1MhuHfZ2c=",
"aarch64-darwin": "sha256-8ngN5KVN4vhdsk0QJ11BGgSVBrcaEbwSj23c77HBpgs=", "aarch64-darwin": "sha256-V/8Kay+5bDb/BSVgBQhSMwzmRmkNGl3U0HFMVbVcMak=",
"x86_64-darwin": "sha256-v/ueYGb9a0Nymzy+mkO4uQr78DAuJnES1qOT0onFgnQ=" "x86_64-darwin": "sha256-duLDF88Q/hXK5jwBy4dVxMSiTTS0R4obp9MlTuOF/Pw="
} }
} }
-1
View File
@@ -31,7 +31,6 @@ stdenvNoCC.mkDerivation {
../package.json ../package.json
../patches ../patches
../install # required by desktop build (cli.rs include_str!) ../install # required by desktop build (cli.rs include_str!)
../.github/TEAM_MEMBERS # required by @opencode-ai/script
] ]
); );
}; };
+4 -7
View File
@@ -9,7 +9,6 @@
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop tauri dev", "dev:desktop": "bun --cwd packages/desktop tauri dev",
"dev:web": "bun --cwd packages/app dev", "dev:web": "bun --cwd packages/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"typecheck": "bun turbo typecheck", "typecheck": "bun turbo typecheck",
"prepare": "husky", "prepare": "husky",
"random": "echo 'Random script'", "random": "echo 'Random script'",
@@ -41,8 +40,8 @@
"@tailwindcss/vite": "4.1.11", "@tailwindcss/vite": "4.1.11",
"diff": "8.0.2", "diff": "8.0.2",
"dompurify": "3.3.1", "dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.16-ea816b6", "drizzle-kit": "1.0.0-beta.12-a5629fb",
"drizzle-orm": "1.0.0-beta.16-ea816b6", "drizzle-orm": "1.0.0-beta.12-a5629fb",
"ai": "5.0.124", "ai": "5.0.124",
"hono": "4.10.7", "hono": "4.10.7",
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",
@@ -71,13 +70,12 @@
"@actions/artifact": "5.0.1", "@actions/artifact": "5.0.1",
"@tsconfig/bun": "catalog:", "@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1", "@types/mime-types": "3.0.1",
"@typescript/native-preview": "catalog:",
"glob": "13.0.5", "glob": "13.0.5",
"husky": "9.1.7", "husky": "9.1.7",
"prettier": "3.6.2", "prettier": "3.6.2",
"semver": "^7.6.0", "semver": "^7.6.0",
"sst": "3.18.10", "sst": "3.18.10",
"turbo": "2.8.13" "turbo": "2.5.6"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "3.933.0", "@aws-sdk/client-s3": "3.933.0",
@@ -100,8 +98,7 @@
"protobufjs", "protobufjs",
"tree-sitter", "tree-sitter",
"tree-sitter-bash", "tree-sitter-bash",
"web-tree-sitter", "web-tree-sitter"
"electron"
], ],
"overrides": { "overrides": {
"@types/bun": "catalog:", "@types/bun": "catalog:",
@@ -1,515 +0,0 @@
# CreateEffect Simplification Implementation Spec
Reduce reactive misuse across `packages/app`.
---
## Context
This work targets `packages/app/src`, which currently has 101 `createEffect` calls across 37 files.
The biggest clusters are `pages/session.tsx` (19), `pages/layout.tsx` (13), `pages/session/file-tabs.tsx` (6), and several context providers that mirror one store into another.
Key issues from the audit:
- Derived state is being written through effects instead of computed directly
- Session and file resets are handled by watch-and-clear effects instead of keyed state boundaries
- User-driven actions are hidden inside reactive effects
- Context layers mirror and hydrate child stores with multiple sync effects
- Several areas repeat the same imperative trigger pattern in multiple effects
Keep the implementation focused on removing unnecessary effects, not on broad UI redesign.
## Goals
- Cut high-churn `createEffect` usage in the hottest files first
- Replace effect-driven derived state with reactive derivation
- Replace reset-on-key effects with keyed ownership boundaries
- Move event-driven work to direct actions and write paths
- Remove mirrored store hydration where a single source of truth can exist
- Leave necessary external sync effects in place, but make them narrower and clearer
## Non-Goals
- Do not rewrite unrelated component structure just to reduce the count
- Do not change product behavior, navigation flow, or persisted data shape unless required for a cleaner write boundary
- Do not remove effects that bridge to DOM, editors, polling, or external APIs unless there is a clearly safer equivalent
- Do not attempt a repo-wide cleanup outside `packages/app`
## Effect Taxonomy And Replacement Rules
Use these rules during implementation.
### Prefer `createMemo`
Use `createMemo` when the target value is pure derived state from other signals or stores.
Do this when an effect only reads reactive inputs and writes another reactive value that could be computed instead.
Apply this to:
- `packages/app/src/pages/session.tsx:141`
- `packages/app/src/pages/layout.tsx:557`
- `packages/app/src/components/terminal.tsx:261`
- `packages/app/src/components/session/session-header.tsx:309`
Rules:
- If no external system is touched, do not use `createEffect`
- Derive once, then read the memo where needed
- If normalization is required, prefer normalizing at the write boundary before falling back to a memo
### Prefer Keyed Remounts
Use keyed remounts when local UI state should reset because an identity changed.
Do this with `sessionKey`, `scope()`, or another stable identity instead of watching the key and manually clearing signals.
Apply this to:
- `packages/app/src/pages/session.tsx:325`
- `packages/app/src/pages/session.tsx:336`
- `packages/app/src/pages/session.tsx:477`
- `packages/app/src/pages/session.tsx:869`
- `packages/app/src/pages/session.tsx:963`
- `packages/app/src/pages/session/message-timeline.tsx:149`
- `packages/app/src/context/file.tsx:100`
Rules:
- If the desired behavior is "new identity, fresh local state," key the owner subtree
- Keep state local to the keyed boundary so teardown and recreation handle the reset naturally
### Prefer Event Handlers And Actions
Use direct handlers, store actions, and async command functions when work happens because a user clicked, selected, reloaded, or navigated.
Do this when an effect is just watching for a flag change, command token, or event-bus signal to trigger imperative logic.
Apply this to:
- `packages/app/src/pages/layout.tsx:484`
- `packages/app/src/pages/layout.tsx:652`
- `packages/app/src/pages/layout.tsx:776`
- `packages/app/src/pages/layout.tsx:1489`
- `packages/app/src/pages/layout.tsx:1519`
- `packages/app/src/components/file-tree.tsx:328`
- `packages/app/src/pages/session/terminal-panel.tsx:55`
- `packages/app/src/context/global-sync.tsx:148`
- Duplicated trigger sets in:
- `packages/app/src/pages/session/review-tab.tsx:122`
- `packages/app/src/pages/session/review-tab.tsx:130`
- `packages/app/src/pages/session/review-tab.tsx:138`
- `packages/app/src/pages/session/file-tabs.tsx:367`
- `packages/app/src/pages/session/file-tabs.tsx:378`
- `packages/app/src/pages/session/file-tabs.tsx:389`
- `packages/app/src/pages/session/use-session-hash-scroll.ts:144`
- `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
- `packages/app/src/pages/session/use-session-hash-scroll.ts:167`
Rules:
- If the trigger is user intent, call the action at the source of that intent
- If the same imperative work is triggered from multiple places, extract one function and call it directly
### Prefer `onMount` And `onCleanup`
Use `onMount` and `onCleanup` for lifecycle-only setup and teardown.
This is the right fit for subscriptions, one-time wiring, timers, and imperative integration that should not rerun for ordinary reactive changes.
Use this when:
- Setup should happen once per owner lifecycle
- Cleanup should always pair with teardown
- The work is not conceptually derived state
### Keep `createEffect` When It Is A Real Bridge
Keep `createEffect` when it synchronizes reactive data to an external imperative sink.
Examples that should remain, though they may be narrowed or split:
- DOM/editor sync in `packages/app/src/components/prompt-input.tsx:690`
- Scroll sync in `packages/app/src/pages/session.tsx:685`
- Scroll/hash sync in `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
- External sync in:
- `packages/app/src/context/language.tsx:207`
- `packages/app/src/context/settings.tsx:110`
- `packages/app/src/context/sdk.tsx:26`
- Polling in:
- `packages/app/src/components/status-popover.tsx:59`
- `packages/app/src/components/dialog-select-server.tsx:273`
Rules:
- Keep the effect single-purpose
- Make dependencies explicit and narrow
- Avoid writing back into the same reactive graph unless absolutely required
## Implementation Plan
### Phase 0: Classification Pass
Before changing code, tag each targeted effect as one of: derive, reset, event, lifecycle, or external bridge.
Acceptance criteria:
- Every targeted effect in this spec is tagged with a replacement strategy before refactoring starts
- Shared helpers to be introduced are identified up front to avoid repeating patterns
### Phase 1: Derived-State Cleanup
Tackle highest-value, lowest-risk derived-state cleanup first.
Priority items:
- Normalize tabs at write boundaries and remove `packages/app/src/pages/session.tsx:141`
- Stop syncing `workspaceOrder` in `packages/app/src/pages/layout.tsx:557`
- Make prompt slash filtering reactive so `packages/app/src/components/prompt-input.tsx:652` can be removed
- Replace other obvious derived-state effects in terminal and session header
Acceptance criteria:
- No behavior change in tab ordering, prompt filtering, terminal display, or header state
- Targeted derived-state effects are deleted, not just moved
### Phase 2: Keyed Reset Cleanup
Replace reset-on-key effects with keyed ownership boundaries.
Priority items:
- Key session-scoped UI and state by `sessionKey`
- Key file-scoped state by `scope()`
- Remove manual clear-and-reseed effects in session and file context
Acceptance criteria:
- Switching session or file scope recreates the intended local state cleanly
- No stale state leaks across session or scope changes
- Target reset effects are deleted
### Phase 3: Event-Driven Work Extraction
Move event-driven work out of reactive effects.
Priority items:
- Replace `globalStore.reload` effect dispatching with direct calls
- Split mixed-responsibility effect in `packages/app/src/pages/layout.tsx:1489`
- Collapse duplicated imperative trigger triplets into single functions
- Move file-tree and terminal-panel imperative work to explicit handlers
Acceptance criteria:
- User-triggered behavior still fires exactly once per intended action
- No effect remains whose only job is to notice a command-like state and trigger an imperative function
### Phase 4: Context Ownership Cleanup
Remove mirrored child-store hydration patterns.
Priority items:
- Remove child-store hydration mirrors in `packages/app/src/context/global-sync/child-store.ts:184`, `:190`, `:193`
- Simplify mirror logic in `packages/app/src/context/global-sync.tsx:130`, `:138`
- Revisit `packages/app/src/context/layout.tsx:424` if it still mirrors instead of deriving
Acceptance criteria:
- There is one clear source of truth for each synced value
- Child stores no longer need effect-based hydration to stay consistent
- Initialization and updates both work without manual mirror effects
### Phase 5: Cleanup And Keeper Review
Clean up remaining targeted hotspots and narrow the effects that should stay.
Acceptance criteria:
- Remaining `createEffect` calls in touched files are all true bridges or clearly justified lifecycle sync
- Mixed-responsibility effects are split into smaller units where still needed
## Detailed Work Items By Area
### 1. Normalize Tab State
Files:
- `packages/app/src/pages/session.tsx:141`
Work:
- Move tab normalization into the functions that create, load, or update tab state
- Make readers consume already-normalized tab data
- Remove the effect that rewrites derived tab state after the fact
Rationale:
- Tabs should become valid when written, not be repaired later
- This removes a feedback loop and makes state easier to trust
Acceptance criteria:
- The effect at `packages/app/src/pages/session.tsx:141` is removed
- Newly created and restored tabs are normalized before they enter local state
- Tab rendering still matches current behavior for valid and edge-case inputs
### 2. Key Session-Owned State
Files:
- `packages/app/src/pages/session.tsx:325`
- `packages/app/src/pages/session.tsx:336`
- `packages/app/src/pages/session.tsx:477`
- `packages/app/src/pages/session.tsx:869`
- `packages/app/src/pages/session.tsx:963`
- `packages/app/src/pages/session/message-timeline.tsx:149`
Work:
- Identify state that should reset when `sessionKey` changes
- Move that state under a keyed subtree or keyed owner boundary
- Remove effects that watch `sessionKey` just to clear local state, refs, or temporary UI flags
Rationale:
- Session identity already defines the lifetime of this UI state
- Keyed ownership makes reset behavior automatic and easier to reason about
Acceptance criteria:
- The targeted reset effects are removed
- Changing sessions resets only the intended session-local state
- Scroll and editor state that should persist are not accidentally reset
### 3. Derive Workspace Order
Files:
- `packages/app/src/pages/layout.tsx:557`
Work:
- Stop writing `workspaceOrder` from live workspace data in an effect
- Represent user overrides separately from live workspace data
- Compute effective order from current data plus overrides with a memo or pure helper
Rationale:
- Persisted user intent and live source data should not mirror each other through an effect
- A computed effective order avoids drift and racey resync behavior
Acceptance criteria:
- The effect at `packages/app/src/pages/layout.tsx:557` is removed
- Workspace order updates correctly when workspaces appear, disappear, or are reordered by the user
- User overrides persist without requiring a sync-back effect
### 4. Remove Child-Store Mirrors
Files:
- `packages/app/src/context/global-sync.tsx:130`
- `packages/app/src/context/global-sync.tsx:138`
- `packages/app/src/context/global-sync.tsx:148`
- `packages/app/src/context/global-sync/child-store.ts:184`
- `packages/app/src/context/global-sync/child-store.ts:190`
- `packages/app/src/context/global-sync/child-store.ts:193`
- `packages/app/src/context/layout.tsx:424`
Work:
- Trace the actual ownership of global and child store values
- Replace hydration and mirror effects with explicit initialization and direct updates
- Remove the `globalStore.reload` event-bus pattern and call the needed reload paths directly
Rationale:
- Mirrors make it hard to tell which state is authoritative
- Event-bus style state toggles hide control flow and create accidental reruns
Acceptance criteria:
- Child store hydration no longer depends on effect-based copying
- Reload work can be followed from the event source to the handler without a reactive relay
- State remains correct on first load, child creation, and subsequent updates
### 5. Key File-Scoped State
Files:
- `packages/app/src/context/file.tsx:100`
Work:
- Move file-scoped local state under a boundary keyed by `scope()`
- Remove any effect that watches `scope()` only to reset file-local state
Rationale:
- File scope changes are identity changes
- Keyed ownership gives a cleaner reset than manual clear logic
Acceptance criteria:
- The effect at `packages/app/src/context/file.tsx:100` is removed
- Switching scopes resets only scope-local state
- No previous-scope data appears after a scope change
### 6. Split Layout Side Effects
Files:
- `packages/app/src/pages/layout.tsx:1489`
- Related event-driven effects near `packages/app/src/pages/layout.tsx:484`, `:652`, `:776`, `:1519`
Work:
- Break the mixed-responsibility effect at `:1489` into direct actions and smaller bridge effects only where required
- Move user-triggered branches into the actual command or handler that causes them
- Remove any branch that only exists because one effect is handling unrelated concerns
Rationale:
- Mixed effects hide cause and make reruns hard to predict
- Smaller units reduce accidental coupling and make future cleanup safer
Acceptance criteria:
- The effect at `packages/app/src/pages/layout.tsx:1489` no longer mixes unrelated responsibilities
- Event-driven branches execute from direct handlers
- Remaining effects in this area each have one clear external sync purpose
### 7. Remove Duplicate Triggers
Files:
- `packages/app/src/pages/session/review-tab.tsx:122`
- `packages/app/src/pages/session/review-tab.tsx:130`
- `packages/app/src/pages/session/review-tab.tsx:138`
- `packages/app/src/pages/session/file-tabs.tsx:367`
- `packages/app/src/pages/session/file-tabs.tsx:378`
- `packages/app/src/pages/session/file-tabs.tsx:389`
- `packages/app/src/pages/session/use-session-hash-scroll.ts:144`
- `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
- `packages/app/src/pages/session/use-session-hash-scroll.ts:167`
Work:
- Extract one explicit imperative function per behavior
- Call that function from each source event instead of replicating the same effect pattern multiple times
- Preserve the scroll-sync effect that is truly syncing with the DOM, but remove duplicate trigger scaffolding around it
Rationale:
- Duplicate triggers make it easy to miss a case or fire twice
- One named action is easier to test and reason about
Acceptance criteria:
- Repeated imperative effect triplets are collapsed into shared functions
- Scroll behavior still works, including hash-based navigation
- No duplicate firing is introduced
### 8. Make Prompt Filtering Reactive
Files:
- `packages/app/src/components/prompt-input.tsx:652`
- Keep `packages/app/src/components/prompt-input.tsx:690` as needed
Work:
- Convert slash filtering into a pure reactive derivation from the current input and candidate command list
- Keep only the editor or DOM bridge effect if it is still needed for imperative syncing
Rationale:
- Filtering is classic derived state
- It should not need an effect if it can be computed from current inputs
Acceptance criteria:
- The effect at `packages/app/src/components/prompt-input.tsx:652` is removed
- Filtered slash-command results update correctly as the input changes
- The editor sync effect at `:690` still behaves correctly
### 9. Clean Up Smaller Derived-State Cases
Files:
- `packages/app/src/components/terminal.tsx:261`
- `packages/app/src/components/session/session-header.tsx:309`
Work:
- Replace effect-written local state with memos or inline derivation
- Remove intermediate setters when the value can be computed directly
Rationale:
- These are low-risk wins that reinforce the same pattern
- They also help keep follow-up cleanup consistent
Acceptance criteria:
- Targeted effects are removed
- UI output remains unchanged under the same inputs
## Verification And Regression Checks
Run focused checks after each phase, not only at the end.
### Suggested Verification
- Switch between sessions rapidly and confirm local session UI resets only where intended
- Open, close, and reorder tabs and confirm order and normalization remain stable
- Change workspaces, reload workspace data, and verify effective ordering is correct
- Change file scope and confirm stale file state does not bleed across scopes
- Trigger layout actions that previously depended on effects and confirm they still fire once
- Use slash commands in the prompt and verify filtering updates as you type
- Test review tab, file tab, and hash-scroll flows for duplicate or missing triggers
- Verify global sync initialization, reload, and child-store creation paths
### Regression Checks
- No accidental infinite reruns
- No double-firing network or command actions
- No lost cleanup for listeners, timers, or scroll handlers
- No preserved stale state after identity changes
- No removed effect that was actually bridging to DOM or an external API
If available, add or update tests around pure helpers introduced during this cleanup.
Favor tests for derived ordering, normalization, and action extraction, since those are easiest to lock down.
## Definition Of Done
This work is done when all of the following are true:
- The highest-leverage targets in this spec are implemented
- Each removed effect has been replaced by a clearer pattern: memo, keyed boundary, direct action, or lifecycle hook
- The "should remain" effects still exist only where they serve a real external sync purpose
- Touched files have fewer mixed-responsibility effects and clearer ownership of state
- Manual verification covers session switching, file scope changes, workspace ordering, prompt filtering, and reload flows
- No behavior regressions are found in the targeted areas
A reduced raw `createEffect` count is helpful, but it is not the main success metric.
The main success metric is clearer ownership and fewer effect-driven state repairs.
## Risks And Rollout Notes
Main risks:
- Keyed remounts can reset too much if state boundaries are drawn too high
- Store mirror removal can break initialization order if ownership is not mapped first
- Moving event work out of effects can accidentally skip triggers that were previously implicit
Rollout notes:
- Land in small phases, with each phase keeping the app behaviorally stable
- Prefer isolated PRs by phase or by file cluster, especially for context-store changes
- Review each remaining effect in touched files and leave it only if it clearly bridges to something external
+1 -9
View File
@@ -71,9 +71,6 @@ test("test description", async ({ page, sdk, gotoSession }) => {
- `closeDialog(page, dialog)` - Close any dialog - `closeDialog(page, dialog)` - Close any dialog
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar - `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
- `withSession(sdk, title, callback)` - Create temp session - `withSession(sdk, title, callback)` - Create temp session
- `withProject(...)` - Create temp project/workspace
- `trackSession(sessionID, directory?)` - Register session for fixture cleanup
- `trackDirectory(directory)` - Register directory for fixture cleanup
- `clickListItem(container, filter)` - Click list item by key/text - `clickListItem(container, filter)` - Click list item by key/text
**Selectors** (`selectors.ts`): **Selectors** (`selectors.ts`):
@@ -112,7 +109,7 @@ import { test, expect } from "@playwright/test"
### Error Handling ### Error Handling
Tests should clean up after themselves. Prefer fixture-managed cleanup: Tests should clean up after themselves:
```typescript ```typescript
test("test with cleanup", async ({ page, sdk, gotoSession }) => { test("test with cleanup", async ({ page, sdk, gotoSession }) => {
@@ -123,11 +120,6 @@ test("test with cleanup", async ({ page, sdk, gotoSession }) => {
}) })
``` ```
- Prefer `withSession(...)` for temp sessions
- In `withProject(...)` tests that create sessions or extra workspaces, call `trackSession(sessionID, directory?)` and `trackDirectory(directory)`
- This lets fixture teardown abort, wait for idle, and clean up safely under CI concurrency
- Avoid calling `sdk.session.delete(...)` directly
### Timeouts ### Timeouts
Default: 60s per test, 10s per assertion. Override when needed: Default: 60s per test, 10s per assertion. Override when needed:
+50 -158
View File
@@ -3,12 +3,12 @@ import fs from "node:fs/promises"
import os from "node:os" import os from "node:os"
import path from "node:path" import path from "node:path"
import { execSync } from "node:child_process" import { execSync } from "node:child_process"
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils" import { modKey, serverUrl } from "./utils"
import { import {
sessionItemSelector,
dropdownMenuTriggerSelector, dropdownMenuTriggerSelector,
dropdownMenuContentSelector, dropdownMenuContentSelector,
projectMenuTriggerSelector, projectMenuTriggerSelector,
projectCloseMenuSelector,
projectWorkspacesToggleSelector, projectWorkspacesToggleSelector,
titlebarRightSelector, titlebarRightSelector,
popoverBodySelector, popoverBodySelector,
@@ -18,6 +18,7 @@ import {
workspaceItemSelector, workspaceItemSelector,
workspaceMenuTriggerSelector, workspaceMenuTriggerSelector,
} from "./selectors" } from "./selectors"
import type { createSdk } from "./utils"
export async function defocus(page: Page) { export async function defocus(page: Page) {
await page await page
@@ -60,9 +61,9 @@ export async function closeDialog(page: Page, dialog: Locator) {
} }
export async function isSidebarClosed(page: Page) { export async function isSidebarClosed(page: Page) {
const button = page.getByRole("button", { name: /toggle sidebar/i }).first() const main = page.locator("main")
await expect(button).toBeVisible() const classes = (await main.getAttribute("class")) ?? ""
return (await button.getAttribute("aria-expanded")) !== "true" return classes.includes("xl:border-l")
} }
export async function toggleSidebar(page: Page) { export async function toggleSidebar(page: Page) {
@@ -74,34 +75,48 @@ export async function openSidebar(page: Page) {
if (!(await isSidebarClosed(page))) return if (!(await isSidebarClosed(page))) return
const button = page.getByRole("button", { name: /toggle sidebar/i }).first() const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
await button.click() const visible = await button
.isVisible()
.then((x) => x)
.catch(() => false)
const opened = await expect(button) if (visible) await button.click()
.toHaveAttribute("aria-expanded", "true", { timeout: 1500 }) if (!visible) await toggleSidebar(page)
const main = page.locator("main")
const opened = await expect(main)
.not.toHaveClass(/xl:border-l/, { timeout: 1500 })
.then(() => true) .then(() => true)
.catch(() => false) .catch(() => false)
if (opened) return if (opened) return
await toggleSidebar(page) await toggleSidebar(page)
await expect(button).toHaveAttribute("aria-expanded", "true") await expect(main).not.toHaveClass(/xl:border-l/)
} }
export async function closeSidebar(page: Page) { export async function closeSidebar(page: Page) {
if (await isSidebarClosed(page)) return if (await isSidebarClosed(page)) return
const button = page.getByRole("button", { name: /toggle sidebar/i }).first() const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
await button.click() const visible = await button
.isVisible()
.then((x) => x)
.catch(() => false)
const closed = await expect(button) if (visible) await button.click()
.toHaveAttribute("aria-expanded", "false", { timeout: 1500 }) if (!visible) await toggleSidebar(page)
const main = page.locator("main")
const closed = await expect(main)
.toHaveClass(/xl:border-l/, { timeout: 1500 })
.then(() => true) .then(() => true)
.catch(() => false) .catch(() => false)
if (closed) return if (closed) return
await toggleSidebar(page) await toggleSidebar(page)
await expect(button).toHaveAttribute("aria-expanded", "false") await expect(main).toHaveClass(/xl:border-l/)
} }
export async function openSettings(page: Page) { export async function openSettings(page: Page) {
@@ -182,21 +197,17 @@ export async function createTestProject() {
await fs.writeFile(path.join(root, "README.md"), "# e2e\n") await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
execSync("git init", { cwd: root, stdio: "ignore" }) execSync("git init", { cwd: root, stdio: "ignore" })
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
execSync("git add -A", { cwd: root, stdio: "ignore" }) execSync("git add -A", { cwd: root, stdio: "ignore" })
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', { execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
cwd: root, cwd: root,
stdio: "ignore", stdio: "ignore",
}) })
return resolveDirectory(root) return root
} }
export async function cleanupTestProject(directory: string) { export async function cleanupTestProject(directory: string) {
try { await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined)
execSync("git fsmonitor--daemon stop", { cwd: directory, stdio: "ignore" })
} catch {}
await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined)
} }
export function sessionIDFromUrl(url: string) { export function sessionIDFromUrl(url: string) {
@@ -205,7 +216,7 @@ export function sessionIDFromUrl(url: string) {
} }
export async function hoverSessionItem(page: Page, sessionID: string) { export async function hoverSessionItem(page: Page, sessionID: string) {
const sessionEl = page.locator(`[data-session-id="${sessionID}"]`).last() const sessionEl = page.locator(sessionItemSelector(sessionID)).first()
await expect(sessionEl).toBeVisible() await expect(sessionEl).toBeVisible()
await sessionEl.hover() await sessionEl.hover()
return sessionEl return sessionEl
@@ -306,57 +317,6 @@ export async function clickListItem(
return item return item
} }
async function status(sdk: ReturnType<typeof createSdk>, sessionID: string) {
const data = await sdk.session
.status()
.then((x) => x.data ?? {})
.catch(() => undefined)
return data?.[sessionID]
}
async function stable(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 10_000) {
let prev = ""
await expect
.poll(
async () => {
const info = await sdk.session
.get({ sessionID })
.then((x) => x.data)
.catch(() => undefined)
if (!info) return true
const next = `${info.title}:${info.time.updated ?? info.time.created}`
if (next !== prev) {
prev = next
return false
}
return true
},
{ timeout },
)
.toBe(true)
}
export async function waitSessionIdle(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 30_000) {
await expect.poll(() => status(sdk, sessionID).then((x) => !x || x.type === "idle"), { timeout }).toBe(true)
}
export async function cleanupSession(input: {
sessionID: string
directory?: string
sdk?: ReturnType<typeof createSdk>
}) {
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory) : undefined)
if (!sdk) throw new Error("cleanupSession requires sdk or directory")
await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
const current = await status(sdk, input.sessionID).catch(() => undefined)
if (current && current.type !== "idle") {
await sdk.session.abort({ sessionID: input.sessionID }).catch(() => undefined)
await waitSessionIdle(sdk, input.sessionID).catch(() => undefined)
}
await stable(sdk, input.sessionID).catch(() => undefined)
await sdk.session.delete({ sessionID: input.sessionID }).catch(() => undefined)
}
export async function withSession<T>( export async function withSession<T>(
sdk: ReturnType<typeof createSdk>, sdk: ReturnType<typeof createSdk>,
title: string, title: string,
@@ -368,7 +328,7 @@ export async function withSession<T>(
try { try {
return await callback(session) return await callback(session)
} finally { } finally {
await cleanupSession({ sdk, sessionID: session.id }) await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
} }
} }
@@ -481,57 +441,6 @@ export async function seedSessionPermission(
return { id: result.id } return { id: result.id }
} }
export async function seedSessionTask(
sdk: ReturnType<typeof createSdk>,
input: {
sessionID: string
description: string
prompt: string
subagentType?: string
},
) {
const text = [
"Your only valid response is one task tool call.",
`Use this JSON input: ${JSON.stringify({
description: input.description,
prompt: input.prompt,
subagent_type: input.subagentType ?? "general",
})}`,
"Do not output plain text.",
"Wait for the task to start and return the child session id.",
].join("\n")
const result = await seed({
sdk,
sessionID: input.sessionID,
prompt: text,
timeout: 90_000,
probe: async () => {
const messages = await sdk.session.messages({ sessionID: input.sessionID, limit: 50 }).then((x) => x.data ?? [])
const part = messages
.flatMap((message) => message.parts)
.find((part) => {
if (part.type !== "tool" || part.tool !== "task") return false
if (part.state.input?.description !== input.description) return false
return typeof part.state.metadata?.sessionId === "string" && part.state.metadata.sessionId.length > 0
})
if (!part) return
const id = part.state.metadata?.sessionId
if (typeof id !== "string" || !id) return
const child = await sdk.session
.get({ sessionID: id })
.then((x) => x.data)
.catch(() => undefined)
if (!child?.id) return
return { sessionID: id }
},
})
if (!result) throw new Error("Timed out seeding task tool")
return result
}
export async function seedSessionTodos( export async function seedSessionTodos(
sdk: ReturnType<typeof createSdk>, sdk: ReturnType<typeof createSdk>,
input: { input: {
@@ -606,42 +515,32 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first() const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
await expect(trigger).toHaveCount(1) await expect(trigger).toHaveCount(1)
const menu = page
.locator(dropdownMenuContentSelector)
.filter({ has: page.locator(projectCloseMenuSelector(projectSlug)) })
.first()
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
const clicked = await trigger
.click({ timeout: 1500 })
.then(() => true)
.catch(() => false)
if (clicked) {
const opened = await menu
.waitFor({ state: "visible", timeout: 1500 })
.then(() => true)
.catch(() => false)
if (opened) {
await expect(close).toBeVisible()
return menu
}
}
await trigger.focus() await trigger.focus()
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
const menu = page.locator(dropdownMenuContentSelector).first()
const opened = await menu const opened = await menu
.waitFor({ state: "visible", timeout: 1500 }) .waitFor({ state: "visible", timeout: 1500 })
.then(() => true) .then(() => true)
.catch(() => false) .catch(() => false)
if (opened) { if (opened) {
await expect(close).toBeVisible() const viewport = page.viewportSize()
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
await page.mouse.move(x, y)
return menu return menu
} }
throw new Error(`Failed to open project menu: ${projectSlug}`) await trigger.click({ force: true })
await expect(menu).toBeVisible()
const viewport = page.viewportSize()
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
await page.mouse.move(x, y)
return menu
} }
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) { export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
@@ -654,18 +553,11 @@ export async function setWorkspacesEnabled(page: Page, projectSlug: string, enab
if (current === enabled) return if (current === enabled) return
const flip = async (timeout?: number) => { await openProjectMenu(page, projectSlug)
const menu = await openProjectMenu(page, projectSlug)
const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
await expect(toggle).toBeVisible()
return toggle.click({ force: true, timeout })
}
const flipped = await flip(1500) const toggle = page.locator(projectWorkspacesToggleSelector(projectSlug)).first()
.then(() => true) await expect(toggle).toBeVisible()
.catch(() => false) await toggle.click({ force: true })
if (!flipped) await flip()
const expected = enabled ? "New workspace" : "New session" const expected = enabled ? "New workspace" : "New session"
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible() await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
@@ -16,6 +16,7 @@ test("titlebar back/forward navigates between sessions", async ({ page, slug, sd
const link = page.locator(`[data-session-id="${two.id}"] a`).first() const link = page.locator(`[data-session-id="${two.id}"] a`).first()
await expect(link).toBeVisible() await expect(link).toBeVisible()
await link.scrollIntoViewIfNeeded()
await link.click() await link.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`)) await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
@@ -55,6 +56,7 @@ test("titlebar forward is cleared after branching history from sidebar", async (
const second = page.locator(`[data-session-id="${b.id}"] a`).first() const second = page.locator(`[data-session-id="${b.id}"] a`).first()
await expect(second).toBeVisible() await expect(second).toBeVisible()
await second.scrollIntoViewIfNeeded()
await second.click() await second.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${b.id}(?:\\?|#|$)`)) await expect(page).toHaveURL(new RegExp(`/${slug}/session/${b.id}(?:\\?|#|$)`))
@@ -74,6 +76,7 @@ test("titlebar forward is cleared after branching history from sidebar", async (
const third = page.locator(`[data-session-id="${c.id}"] a`).first() const third = page.locator(`[data-session-id="${c.id}"] a`).first()
await expect(third).toBeVisible() await expect(third).toBeVisible()
await third.scrollIntoViewIfNeeded()
await third.click() await third.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${c.id}(?:\\?|#|$)`)) await expect(page).toHaveURL(new RegExp(`/${slug}/session/${c.id}(?:\\?|#|$)`))
@@ -99,6 +102,7 @@ test("keyboard shortcuts navigate titlebar history", async ({ page, slug, sdk, g
const link = page.locator(`[data-session-id="${two.id}"] a`).first() const link = page.locator(`[data-session-id="${two.id}"] a`).first()
await expect(link).toBeVisible() await expect(link).toBeVisible()
await link.scrollIntoViewIfNeeded()
await link.click() await link.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`)) await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
@@ -101,56 +101,3 @@ test("cmd+f opens text viewer search while prompt is focused", async ({ page, go
await expect(findInput).toBeVisible() await expect(findInput).toBeVisible()
await expect(findInput).toBeFocused() await expect(findInput).toBeFocused()
}) })
test("cmd+f opens text viewer search while prompt is not focused", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/open")
const command = page.locator('[data-slash-id="file.open"]').first()
await expect(command).toBeVisible()
await page.keyboard.press("Enter")
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
const input = dialog.getByRole("textbox").first()
await input.fill("package.json")
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
let index = -1
await expect
.poll(
async () => {
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
return index >= 0
},
{ timeout: 30_000 },
)
.toBe(true)
const item = items.nth(index)
await expect(item).toBeVisible()
await item.click()
await expect(dialog).toHaveCount(0)
const tab = page.getByRole("tab", { name: "package.json" })
await expect(tab).toBeVisible()
await tab.click()
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
await viewer.click()
await page.keyboard.press(`${modKey}+f`)
const findInput = page.getByPlaceholder("Find")
await expect(findInput).toBeVisible()
await expect(findInput).toBeFocused()
})
+7 -25
View File
@@ -1,5 +1,5 @@
import { test as base, expect, type Page } from "@playwright/test" import { test as base, expect, type Page } from "@playwright/test"
import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions" import { cleanupTestProject, createTestProject, seedProjects } from "./actions"
import { promptSelector } from "./selectors" import { promptSelector } from "./selectors"
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils" import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
@@ -13,8 +13,6 @@ type TestFixtures = {
directory: string directory: string
slug: string slug: string
gotoSession: (sessionID?: string) => Promise<void> gotoSession: (sessionID?: string) => Promise<void>
trackSession: (sessionID: string, directory?: string) => void
trackDirectory: (directory: string) => void
}) => Promise<T>, }) => Promise<T>,
options?: { extra?: string[] }, options?: { extra?: string[] },
) => Promise<T> ) => Promise<T>
@@ -53,36 +51,20 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
}, },
withProject: async ({ page }, use) => { withProject: async ({ page }, use) => {
await use(async (callback, options) => { await use(async (callback, options) => {
const root = await createTestProject() const directory = await createTestProject()
const slug = dirSlug(root) const slug = dirSlug(directory)
const sessions = new Map<string, string>() await seedStorage(page, { directory, extra: options?.extra })
const dirs = new Set<string>()
await seedStorage(page, { directory: root, extra: options?.extra })
const gotoSession = async (sessionID?: string) => { const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(root, sessionID)) await page.goto(sessionPath(directory, sessionID))
await expect(page.locator(promptSelector)).toBeVisible() await expect(page.locator(promptSelector)).toBeVisible()
const current = sessionIDFromUrl(page.url())
if (current) trackSession(current)
}
const trackSession = (sessionID: string, directory?: string) => {
sessions.set(sessionID, directory ?? root)
}
const trackDirectory = (directory: string) => {
if (directory !== root) dirs.add(directory)
} }
try { try {
await gotoSession() await gotoSession()
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory }) return await callback({ directory, slug, gotoSession })
} finally { } finally {
await Promise.allSettled( await cleanupTestProject(directory)
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
)
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
await cleanupTestProject(root)
} }
}) })
}, },
+14 -4
View File
@@ -1,15 +1,25 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { clickMenuItem, openProjectMenu, openSidebar } from "../actions" import { openSidebar } from "../actions"
test("dialog edit project updates name and startup script", async ({ page, withProject }) => { test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ slug }) => { await withProject(async () => {
await openSidebar(page) await openSidebar(page)
const open = async () => { const open = async () => {
const menu = await openProjectMenu(page, slug) const header = page.locator(".group\\/project").first()
await clickMenuItem(menu, /^Edit$/i, { force: true }) await header.hover()
const trigger = header.getByRole("button", { name: "More options" }).first()
await expect(trigger).toBeVisible()
await trigger.click({ force: true })
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
await expect(menu).toBeVisible()
const editItem = menu.getByRole("menuitem", { name: "Edit" }).first()
await expect(editItem).toBeVisible()
await editItem.click({ force: true })
const dialog = page.getByRole("dialog") const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible() await expect(dialog).toBeVisible()
@@ -1,45 +1,20 @@
import { base64Decode } from "@opencode-ai/util/encode" import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl } from "../actions" import {
defocus,
createTestProject,
cleanupTestProject,
openSidebar,
setWorkspacesEnabled,
sessionIDFromUrl,
} from "../actions"
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { dirSlug } from "../utils" import { createSdk, dirSlug, sessionPath } from "../utils"
function slugFromUrl(url: string) { function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? "" return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
} }
async function workspaces(page: Page, directory: string, enabled: boolean) {
await page.evaluate(
({ directory, enabled }: { directory: string; enabled: boolean }) => {
const key = "opencode.global.dat:layout"
const raw = localStorage.getItem(key)
const data = raw ? JSON.parse(raw) : {}
const sidebar = data.sidebar && typeof data.sidebar === "object" ? data.sidebar : {}
const current =
sidebar.workspaces && typeof sidebar.workspaces === "object" && !Array.isArray(sidebar.workspaces)
? sidebar.workspaces
: {}
const next = { ...current }
if (enabled) next[directory] = true
if (!enabled) delete next[directory]
localStorage.setItem(
key,
JSON.stringify({
...data,
sidebar: {
...sidebar,
workspaces: next,
},
}),
)
},
{ directory, enabled },
)
}
test("can switch between projects from sidebar", async ({ page, withProject }) => { test("can switch between projects from sidebar", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
@@ -76,16 +51,17 @@ test("switching back to a project opens the latest workspace session", async ({
const other = await createTestProject() const other = await createTestProject()
const otherSlug = dirSlug(other) const otherSlug = dirSlug(other)
let rootDir: string | undefined
let workspaceDir: string | undefined let workspaceDir: string | undefined
let sessionID: string | undefined
try { try {
await withProject( await withProject(
async ({ directory, slug, trackSession, trackDirectory }) => { async ({ directory, slug }) => {
rootDir = directory
await defocus(page) await defocus(page)
await workspaces(page, directory, true)
await page.reload()
await expect(page.locator(promptSelector)).toBeVisible()
await openSidebar(page) await openSidebar(page)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() await setWorkspacesEnabled(page, slug, true)
await page.getByRole("button", { name: "New workspace" }).first().click() await page.getByRole("button", { name: "New workspace" }).first().click()
@@ -104,7 +80,6 @@ test("switching back to a project opens the latest workspace session", async ({
const workspaceSlug = slugFromUrl(page.url()) const workspaceSlug = slugFromUrl(page.url())
workspaceDir = base64Decode(workspaceSlug) workspaceDir = base64Decode(workspaceSlug)
if (!workspaceDir) throw new Error(`Failed to decode workspace slug: ${workspaceSlug}`) if (!workspaceDir) throw new Error(`Failed to decode workspace slug: ${workspaceSlug}`)
trackDirectory(workspaceDir)
await openSidebar(page) await openSidebar(page)
const workspace = page.locator(workspaceItemSelector(workspaceSlug)).first() const workspace = page.locator(workspaceItemSelector(workspaceSlug)).first()
@@ -117,19 +92,14 @@ test("switching back to a project opens the latest workspace session", async ({
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session(?:[/?#]|$)`)) await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session(?:[/?#]|$)`))
// Create a session by sending a prompt const created = await createSdk(workspaceDir)
const prompt = page.locator(promptSelector) .session.create()
await expect(prompt).toBeVisible() .then((x) => x.data?.id)
await prompt.fill("test") if (!created) throw new Error(`Failed to create session for workspace: ${workspaceDir}`)
await page.keyboard.press("Enter") sessionID = created
// Wait for the URL to update with the new session ID
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
const created = sessionIDFromUrl(page.url())
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
trackSession(created, workspaceDir)
await page.goto(sessionPath(workspaceDir, created))
await expect(page.locator(promptSelector)).toBeVisible()
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`)) await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
await openSidebar(page) await openSidebar(page)
@@ -149,6 +119,20 @@ test("switching back to a project opens the latest workspace session", async ({
{ extra: [other] }, { extra: [other] },
) )
} finally { } finally {
if (sessionID) {
const id = sessionID
const dirs = [rootDir, workspaceDir].filter((x): x is string => !!x)
await Promise.all(
dirs.map((directory) =>
createSdk(directory)
.session.delete({ sessionID: id })
.catch(() => undefined),
),
)
}
if (workspaceDir) {
await cleanupTestProject(workspaceDir)
}
await cleanupTestProject(other) await cleanupTestProject(other)
} }
}) })
@@ -1,7 +1,7 @@
import { base64Decode } from "@opencode-ai/util/encode" import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test" import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled } from "../actions" import { cleanupTestProject, openSidebar, sessionIDFromUrl, setWorkspacesEnabled } from "../actions"
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk } from "../utils" import { createSdk } from "../utils"
@@ -9,26 +9,6 @@ function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? "" return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
} }
async function waitSlug(page: Page, skip: string[] = []) {
let prev = ""
await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
if (!slug) return ""
if (skip.includes(slug)) return ""
if (slug !== prev) {
prev = slug
return ""
}
return slug
},
{ timeout: 45_000 },
)
.not.toBe("")
return slugFromUrl(page.url())
}
async function waitWorkspaceReady(page: Page, slug: string) { async function waitWorkspaceReady(page: Page, slug: string) {
await openSidebar(page) await openSidebar(page)
await expect await expect
@@ -51,7 +31,20 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
await openSidebar(page) await openSidebar(page)
await page.getByRole("button", { name: "New workspace" }).first().click() await page.getByRole("button", { name: "New workspace" }).first().click()
const slug = await waitSlug(page, [root, ...seen]) await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
if (!slug) return ""
if (slug === root) return ""
if (seen.includes(slug)) return ""
return slug
},
{ timeout: 45_000 },
)
.not.toBe("")
const slug = slugFromUrl(page.url())
const directory = base64Decode(slug) const directory = base64Decode(slug)
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`) if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
return { slug, directory } return { slug, directory }
@@ -67,13 +60,12 @@ async function openWorkspaceNewSession(page: Page, slug: string) {
await expect(button).toBeVisible() await expect(button).toBeVisible()
await button.click({ force: true }) await button.click({ force: true })
const next = await waitSlug(page) await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`)) await expect(page).toHaveURL(new RegExp(`/${slug}/session(?:[/?#]|$)`))
return next
} }
async function createSessionFromWorkspace(page: Page, slug: string, text: string) { async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
const next = await openWorkspaceNewSession(page, slug) await openWorkspaceNewSession(page, slug)
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible() await expect(prompt).toBeVisible()
@@ -84,13 +76,13 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text) await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
await prompt.press("Enter") await prompt.press("Enter")
await expect.poll(() => slugFromUrl(page.url())).toBe(next) await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("") await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
const sessionID = sessionIDFromUrl(page.url()) const sessionID = sessionIDFromUrl(page.url())
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`) if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`)) await expect(page).toHaveURL(new RegExp(`/${slug}/session/${sessionID}(?:[/?#]|$)`))
return { sessionID, slug: next } return sessionID
} }
async function sessionDirectory(directory: string, sessionID: string) { async function sessionDirectory(directory: string, sessionID: string) {
@@ -105,29 +97,48 @@ async function sessionDirectory(directory: string, sessionID: string) {
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, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => { await withProject(async ({ directory, slug: root }) => {
await openSidebar(page) const workspaces = [] as { slug: string; directory: string }[]
await setWorkspacesEnabled(page, root, true) const sessions = [] as string[]
const first = await createWorkspace(page, root, []) try {
trackDirectory(first.directory) await openSidebar(page)
await waitWorkspaceReady(page, first.slug) await setWorkspacesEnabled(page, root, true)
const second = await createWorkspace(page, root, [first.slug]) const first = await createWorkspace(page, root, [])
trackDirectory(second.directory) workspaces.push(first)
await waitWorkspaceReady(page, second.slug) await waitWorkspaceReady(page, first.slug)
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`) const second = await createWorkspace(page, root, [first.slug])
trackSession(firstSession.sessionID, first.directory) workspaces.push(second)
await waitWorkspaceReady(page, second.slug)
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`) const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
trackSession(secondSession.sessionID, second.directory) sessions.push(firstSession)
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`) const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
trackSession(thirdSession.sessionID, first.directory) sessions.push(secondSession)
await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory) const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory) sessions.push(thirdSession)
await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
await expect.poll(() => sessionDirectory(first.directory, firstSession)).toBe(first.directory)
await expect.poll(() => sessionDirectory(second.directory, secondSession)).toBe(second.directory)
await expect.poll(() => sessionDirectory(first.directory, thirdSession)).toBe(first.directory)
} finally {
const dirs = [directory, ...workspaces.map((workspace) => workspace.directory)]
await Promise.all(
sessions.map((sessionID) =>
Promise.all(
dirs.map((dir) =>
createSdk(dir)
.session.delete({ sessionID })
.catch(() => undefined),
),
),
),
)
await Promise.all(workspaces.map((workspace) => cleanupTestProject(workspace.directory)))
}
}) })
}) })
+27 -23
View File
@@ -22,26 +22,6 @@ function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? "" return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
} }
async function waitSlug(page: Page, skip: string[] = []) {
let prev = ""
await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
if (!slug) return ""
if (skip.includes(slug)) return ""
if (slug !== prev) {
prev = slug
return ""
}
return slug
},
{ timeout: 45_000 },
)
.not.toBe("")
return slugFromUrl(page.url())
}
async function setupWorkspaceTest(page: Page, project: { slug: string }) { async function setupWorkspaceTest(page: Page, project: { slug: string }) {
const rootSlug = project.slug const rootSlug = project.slug
await openSidebar(page) await openSidebar(page)
@@ -49,7 +29,17 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
await setWorkspacesEnabled(page, rootSlug, true) await setWorkspacesEnabled(page, rootSlug, true)
await page.getByRole("button", { name: "New workspace" }).first().click() await page.getByRole("button", { name: "New workspace" }).first().click()
const slug = await waitSlug(page, [rootSlug]) await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
return slug.length > 0 && slug !== rootSlug
},
{ timeout: 45_000 },
)
.toBe(true)
const slug = slugFromUrl(page.url())
const dir = base64Decode(slug) const dir = base64Decode(slug)
await openSidebar(page) await openSidebar(page)
@@ -101,7 +91,18 @@ test("can create a workspace", async ({ page, withProject }) => {
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() 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 workspaceSlug = await waitSlug(page, [slug])
await expect
.poll(
() => {
const currentSlug = slugFromUrl(page.url())
return currentSlug.length > 0 && currentSlug !== slug
},
{ timeout: 45_000 },
)
.toBe(true)
const workspaceSlug = slugFromUrl(page.url())
const workspaceDir = base64Decode(workspaceSlug) const workspaceDir = base64Decode(workspaceSlug)
await openSidebar(page) await openSidebar(page)
@@ -278,7 +279,7 @@ test("can delete a workspace", async ({ page, withProject }) => {
await clickMenuItem(menu, /^Delete$/i, { force: true }) await clickMenuItem(menu, /^Delete$/i, { force: true })
await confirmDialog(page, /^Delete workspace$/i) await confirmDialog(page, /^Delete workspace$/i)
await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory) await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
await expect await expect
.poll( .poll(
@@ -335,6 +336,9 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
const src = page.locator(workspaceItemSelector(from)).first() const src = page.locator(workspaceItemSelector(from)).first()
const dst = page.locator(workspaceItemSelector(to)).first() const dst = page.locator(workspaceItemSelector(to)).first()
await src.scrollIntoViewIfNeeded()
await dst.scrollIntoViewIfNeeded()
const a = await src.boundingBox() const a = await src.boundingBox()
const b = await dst.boundingBox() const b = await dst.boundingBox()
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds") if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
+2 -35
View File
@@ -1,8 +1,6 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors" import { promptSelector } from "../selectors"
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions" import { sessionIDFromUrl } from "../actions"
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
// Regression test for Issue #12453: the synchronous POST /message endpoint holds // Regression test for Issue #12453: the synchronous POST /message endpoint holds
// the connection open while the agent works, causing "Failed to fetch" over // the connection open while the agent works, causing "Failed to fetch" over
@@ -40,37 +38,6 @@ test("prompt succeeds when sync message endpoint is unreachable", async ({ page,
) )
.toContain(token) .toContain(token)
} finally { } finally {
await cleanupSession({ sdk, sessionID }) await sdk.session.delete({ sessionID }).catch(() => undefined)
} }
}) })
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
await withSession(sdk, `e2e prompt failure ${Date.now()}`, async (session) => {
const prompt = page.locator(promptSelector)
const value = `restore ${Date.now()}`
await page.route(`**/session/${session.id}/prompt_async`, (route) =>
route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ message: "e2e prompt failure" }),
}),
)
await gotoSession(session.id)
await prompt.click()
await page.keyboard.type(value)
await page.keyboard.press("Enter")
await expect.poll(async () => text(await prompt.textContent())).toBe(value)
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID: session.id, limit: 50 }).then((r) => r.data ?? [])
return messages.length
},
{ timeout: 15_000 },
)
.toBe(0)
})
})
@@ -1,181 +0,0 @@
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { withSession } from "../actions"
import { promptSelector } from "../selectors"
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
const isBash = (part: unknown): part is ToolPart => {
if (!part || typeof part !== "object") return false
if (!("type" in part) || part.type !== "tool") return false
if (!("tool" in part) || part.tool !== "bash") return false
return "state" in part
}
async function edge(page: Page, pos: "start" | "end") {
await page.locator(promptSelector).evaluate((el: HTMLDivElement, pos: "start" | "end") => {
const selection = window.getSelection()
if (!selection) return
const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
const nodes: Text[] = []
for (let node = walk.nextNode(); node; node = walk.nextNode()) {
nodes.push(node as Text)
}
if (nodes.length === 0) {
const node = document.createTextNode("")
el.appendChild(node)
nodes.push(node)
}
const node = pos === "start" ? nodes[0]! : nodes[nodes.length - 1]!
const range = document.createRange()
range.setStart(node, pos === "start" ? 0 : (node.textContent ?? "").length)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
}, pos)
}
async function wait(page: Page, value: string) {
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
}
async function reply(sdk: Parameters<typeof withSession>[0], sessionID: string, token: string) {
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
return messages
.filter((item) => item.info.role === "assistant")
.flatMap((item) => item.parts)
.filter((item) => item.type === "text")
.map((item) => item.text)
.join("\n")
},
{ timeout: 90_000 },
)
.toContain(token)
}
async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string, cmd: string, token: string) {
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
const part = messages
.filter((item) => item.info.role === "assistant")
.flatMap((item) => item.parts)
.filter(isBash)
.find((item) => item.state.input?.command === cmd && item.state.status === "completed")
if (!part || part.state.status !== "completed") return
return typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
},
{ timeout: 90_000 },
)
.toContain(token)
}
test("prompt history restores unsent draft with arrow navigation", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
await withSession(sdk, `e2e prompt history ${Date.now()}`, async (session) => {
await gotoSession(session.id)
const prompt = page.locator(promptSelector)
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
const first = `Reply with exactly: ${firstToken}`
const second = `Reply with exactly: ${secondToken}`
const draft = `draft ${Date.now()}`
await prompt.click()
await page.keyboard.type(first)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(sdk, session.id, firstToken)
await prompt.click()
await page.keyboard.type(second)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(sdk, session.id, secondToken)
await prompt.click()
await page.keyboard.type(draft)
await wait(page, draft)
await edge(page, "start")
await page.keyboard.press("ArrowUp")
await wait(page, second)
await page.keyboard.press("ArrowUp")
await wait(page, first)
await page.keyboard.press("ArrowDown")
await wait(page, second)
await page.keyboard.press("ArrowDown")
await wait(page, draft)
})
})
test("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
await withSession(sdk, `e2e shell history ${Date.now()}`, async (session) => {
await gotoSession(session.id)
const prompt = page.locator(promptSelector)
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
const normalToken = `E2E_NORMAL_${Date.now()}`
const first = `echo ${firstToken}`
const second = `echo ${secondToken}`
const normal = `Reply with exactly: ${normalToken}`
await prompt.click()
await page.keyboard.type("!")
await page.keyboard.type(first)
await page.keyboard.press("Enter")
await wait(page, "")
await shell(sdk, session.id, first, firstToken)
await prompt.click()
await page.keyboard.type("!")
await page.keyboard.type(second)
await page.keyboard.press("Enter")
await wait(page, "")
await shell(sdk, session.id, second, secondToken)
await prompt.click()
await page.keyboard.type("!")
await page.keyboard.press("ArrowUp")
await wait(page, second)
await page.keyboard.press("ArrowUp")
await wait(page, first)
await page.keyboard.press("ArrowDown")
await wait(page, second)
await page.keyboard.press("ArrowDown")
await wait(page, "")
await page.keyboard.press("Escape")
await wait(page, "")
await prompt.click()
await page.keyboard.type(normal)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(sdk, session.id, normalToken)
await prompt.click()
await page.keyboard.press("ArrowUp")
await wait(page, normal)
})
})
@@ -1,62 +0,0 @@
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
import { test, expect } from "../fixtures"
import { sessionIDFromUrl } from "../actions"
import { promptSelector } from "../selectors"
import { createSdk } from "../utils"
const isBash = (part: unknown): part is ToolPart => {
if (!part || typeof part !== "object") return false
if (!("type" in part) || part.type !== "tool") return false
if (!("tool" in part) || part.tool !== "bash") return false
return "state" in part
}
test("shell mode runs a command in the project directory", async ({ page, withProject }) => {
test.setTimeout(120_000)
await withProject(async ({ directory, gotoSession, trackSession }) => {
const sdk = createSdk(directory)
const prompt = page.locator(promptSelector)
const cmd = process.platform === "win32" ? "dir" : "ls"
await gotoSession()
await prompt.click()
await page.keyboard.type("!")
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
await page.keyboard.type(cmd)
await page.keyboard.press("Enter")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const id = sessionIDFromUrl(page.url())
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
trackSession(id, directory)
await expect
.poll(
async () => {
const list = await sdk.session.messages({ sessionID: id, limit: 50 }).then((x) => x.data ?? [])
const msg = list.findLast(
(item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === directory,
)
if (!msg) return
const part = msg.parts
.filter(isBash)
.find((item) => item.state.input?.command === cmd && item.state.status === "completed")
if (!part || part.state.status !== "completed") return
const output =
typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
if (!output.includes("README.md")) return
return { cwd: directory, output }
},
{ timeout: 90_000 },
)
.toEqual(expect.objectContaining({ cwd: directory, output: expect.stringContaining("README.md") }))
await expect(prompt).toHaveText("")
})
})
@@ -1,64 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { withSession } from "../actions"
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
await sdk.session.promptAsync({
sessionID,
noReply: true,
parts: [{ type: "text", text: "e2e share seed" }],
})
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
return messages.length
},
{ timeout: 30_000 },
)
.toBeGreaterThan(0)
}
test("/share and /unshare update session share state", async ({ page, sdk, gotoSession }) => {
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
await withSession(sdk, `e2e slash share ${Date.now()}`, async (session) => {
const prompt = page.locator(promptSelector)
await seed(sdk, session.id)
await gotoSession(session.id)
await prompt.click()
await page.keyboard.type("/share")
await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined
},
{ timeout: 30_000 },
)
.not.toBeUndefined()
await prompt.click()
await page.keyboard.type("/unshare")
await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined
},
{ timeout: 30_000 },
)
.toBeUndefined()
})
})
+2 -2
View File
@@ -1,6 +1,6 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors" import { promptSelector } from "../selectors"
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions" import { sessionIDFromUrl, withSession } from "../actions"
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => { test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
@@ -46,7 +46,7 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession })
.toContain(token) .toContain(token)
} finally { } finally {
page.off("pageerror", onPageError) page.off("pageerror", onPageError)
await cleanupSession({ sdk, sessionID }) await sdk.session.delete({ sessionID }).catch(() => undefined)
} }
if (pageErrors.length > 0) { if (pageErrors.length > 0) {
@@ -1,37 +0,0 @@
import { seedSessionTask, withSession } from "../actions"
import { test, expect } from "../fixtures"
test("task tool child-session link does not trigger stale show errors", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
const errs: string[] = []
const onError = (err: Error) => {
errs.push(err.message)
}
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 gotoSession(session.id)
const link = page
.locator("a.subagent-link")
.filter({ hasText: /open child session/i })
.first()
await expect(link).toBeVisible({ timeout: 30_000 })
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)
}
})
})
@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { cleanupSession, clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions" import { clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
import { import {
permissionDockSelector, permissionDockSelector,
promptSelector, promptSelector,
@@ -26,7 +26,7 @@ async function withDockSession<T>(
try { try {
return await fn(session) return await fn(session)
} finally { } finally {
await cleanupSession({ sdk, sessionID: session.id }) await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
} }
} }
@@ -142,17 +142,6 @@ test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
}) })
}) })
test("auto-accept toggle works before first submit", async ({ page, gotoSession }) => {
await gotoSession()
const button = page.locator('[data-action="prompt-permissions"]').first()
await expect(button).toBeVisible()
await expect(button).toHaveAttribute("aria-pressed", "false")
await setAutoAccept(page, true)
await setAutoAccept(page, false)
})
test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => { test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock question", async (session) => { await withDockSession(sdk, "e2e composer dock question", async (session) => {
await withDockSeed(sdk, session.id, async () => { await withDockSeed(sdk, session.id, async () => {
@@ -311,7 +300,7 @@ test("child session question request blocks parent dock and unblocks after submi
await expect(page.locator(promptSelector)).toBeVisible() await expect(page.locator(promptSelector)).toBeVisible()
}) })
} finally { } finally {
await cleanupSession({ sdk, sessionID: child.id }) await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
} }
}) })
}) })
@@ -358,7 +347,7 @@ test("child session permission request blocks parent dock and supports allow onc
}, },
) )
} finally { } finally {
await cleanupSession({ sdk, sessionID: child.id }) await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
} }
}) })
}) })
@@ -32,19 +32,22 @@ test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
await closeDialog(page, dialog) await closeDialog(page, dialog)
const button = page.getByRole("button", { name: /toggle sidebar/i }).first() const main = page.locator("main")
const initiallyClosed = (await button.getAttribute("aria-expanded")) !== "true" const initialClasses = (await main.getAttribute("class")) ?? ""
const initiallyClosed = initialClasses.includes("xl:border-l")
await page.keyboard.press(`${modKey}+Shift+H`) await page.keyboard.press(`${modKey}+Shift+H`)
await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "true" : "false") await page.waitForTimeout(100)
const afterToggleClosed = (await button.getAttribute("aria-expanded")) !== "true" const afterToggleClasses = (await main.getAttribute("class")) ?? ""
const afterToggleClosed = afterToggleClasses.includes("xl:border-l")
expect(afterToggleClosed).toBe(!initiallyClosed) expect(afterToggleClosed).toBe(!initiallyClosed)
await page.keyboard.press(`${modKey}+Shift+H`) await page.keyboard.press(`${modKey}+Shift+H`)
await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "false" : "true") await page.waitForTimeout(100)
const finalClosed = (await button.getAttribute("aria-expanded")) !== "true" const finalClasses = (await main.getAttribute("class")) ?? ""
const finalClosed = finalClasses.includes("xl:border-l")
expect(finalClosed).toBe(initiallyClosed) expect(finalClosed).toBe(initiallyClosed)
}) })
@@ -1,6 +1,6 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { cleanupSession, closeSidebar, hoverSessionItem } from "../actions" import { closeSidebar, hoverSessionItem } from "../actions"
import { projectSwitchSelector } from "../selectors" import { projectSwitchSelector, sessionItemSelector } from "../selectors"
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => { test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
const stamp = Date.now() const stamp = Date.now()
@@ -15,15 +15,12 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
await gotoSession(one.id) await gotoSession(one.id)
await closeSidebar(page) await closeSidebar(page)
const oneItem = page.locator(`[data-session-id="${one.id}"]`).last()
const twoItem = page.locator(`[data-session-id="${two.id}"]`).last()
const project = page.locator(projectSwitchSelector(slug)).first() const project = page.locator(projectSwitchSelector(slug)).first()
await expect(project).toBeVisible() await expect(project).toBeVisible()
await project.hover() await project.hover()
await expect(oneItem).toBeVisible() await expect(page.locator(sessionItemSelector(one.id)).first()).toBeVisible()
await expect(twoItem).toBeVisible() await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
const item = await hoverSessionItem(page, one.id) const item = await hoverSessionItem(page, one.id)
await item await item
@@ -31,9 +28,9 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
.first() .first()
.click() .click()
await expect(twoItem).toBeVisible() await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
} finally { } finally {
await cleanupSession({ sdk, sessionID: one.id }) await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
await cleanupSession({ sdk, sessionID: two.id }) await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
} }
}) })
@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { cleanupSession, openSidebar, withSession } from "../actions" import { openSidebar, withSession } from "../actions"
import { promptSelector } from "../selectors" import { promptSelector } from "../selectors"
test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => { test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => {
@@ -18,13 +18,14 @@ test("sidebar session links navigate to the selected session", async ({ page, sl
const target = page.locator(`[data-session-id="${two.id}"] a`).first() const target = page.locator(`[data-session-id="${two.id}"] a`).first()
await expect(target).toBeVisible() await expect(target).toBeVisible()
await target.scrollIntoViewIfNeeded()
await target.click() await target.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`)) await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible() await expect(page.locator(promptSelector)).toBeVisible()
await expect(page.locator(`[data-session-id="${two.id}"] a`).first()).toHaveClass(/\bactive\b/) await expect(page.locator(`[data-session-id="${two.id}"] a`).first()).toHaveClass(/\bactive\b/)
} finally { } finally {
await cleanupSession({ sdk, sessionID: one.id }) await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
await cleanupSession({ sdk, sessionID: two.id }) await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
} }
}) })
+5 -8
View File
@@ -5,14 +5,12 @@ test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
await gotoSession() await gotoSession()
await openSidebar(page) await openSidebar(page)
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
await expect(button).toHaveAttribute("aria-expanded", "true")
await toggleSidebar(page) await toggleSidebar(page)
await expect(button).toHaveAttribute("aria-expanded", "false") await expect(page.locator("main")).toHaveClass(/xl:border-l/)
await toggleSidebar(page) await toggleSidebar(page)
await expect(button).toHaveAttribute("aria-expanded", "true") await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
}) })
test("sidebar collapsed state persists across navigation and reload", async ({ page, sdk, gotoSession }) => { test("sidebar collapsed state persists across navigation and reload", async ({ page, sdk, gotoSession }) => {
@@ -21,15 +19,14 @@ test("sidebar collapsed state persists across navigation and reload", async ({ p
await gotoSession(session1.id) await gotoSession(session1.id)
await openSidebar(page) await openSidebar(page)
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
await toggleSidebar(page) await toggleSidebar(page)
await expect(button).toHaveAttribute("aria-expanded", "false") await expect(page.locator("main")).toHaveClass(/xl:border-l/)
await gotoSession(session2.id) await gotoSession(session2.id)
await expect(button).toHaveAttribute("aria-expanded", "false") await expect(page.locator("main")).toHaveClass(/xl:border-l/)
await page.reload() await page.reload()
await expect(button).toHaveAttribute("aria-expanded", "false") await expect(page.locator("main")).toHaveClass(/xl:border-l/)
const opened = await page.evaluate( const opened = await page.evaluate(
() => JSON.parse(localStorage.getItem("opencode.global.dat:layout") ?? "{}").sidebar?.opened, () => JSON.parse(localStorage.getItem("opencode.global.dat:layout") ?? "{}").sidebar?.opened,
@@ -1,120 +0,0 @@
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { terminalSelector } from "../selectors"
import { terminalToggleKey, workspacePersistKey } from "../utils"
type State = {
active?: string
all: Array<{
id: string
title: string
titleNumber: number
buffer?: string
}>
}
async function open(page: Page) {
const terminal = page.locator(terminalSelector)
const visible = await terminal.isVisible().catch(() => false)
if (!visible) await page.keyboard.press(terminalToggleKey)
await expect(terminal).toBeVisible()
await expect(terminal.locator("textarea")).toHaveCount(1)
}
async function run(page: Page, cmd: string) {
const terminal = page.locator(terminalSelector)
await expect(terminal).toBeVisible()
await terminal.click()
await page.keyboard.type(cmd)
await page.keyboard.press("Enter")
}
async function store(page: Page, key: string) {
return page.evaluate((key) => {
const raw = localStorage.getItem(key)
if (raw) return JSON.parse(raw) as State
for (let i = 0; i < localStorage.length; i++) {
const next = localStorage.key(i)
if (!next?.endsWith(":workspace:terminal")) continue
const value = localStorage.getItem(next)
if (!value) continue
return JSON.parse(value) as State
}
}, key)
}
test("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"]')
await gotoSession()
await open(page)
await run(page, `echo ${one}`)
await page.getByRole("button", { name: /new terminal/i }).click()
await expect(tabs).toHaveCount(2)
await run(page, `echo ${two}`)
await tabs
.filter({ hasText: /Terminal 1/ })
.first()
.click()
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.includes(one) && second.includes(two)
},
{ timeout: 30_000 },
)
.toBe(true)
})
})
test("closing the active terminal tab falls back to the previous tab", async ({ page, withProject }) => {
await withProject(async ({ directory, gotoSession }) => {
const key = workspacePersistKey(directory, "terminal")
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
await gotoSession()
await open(page)
await page.getByRole("button", { name: /new terminal/i }).click()
await expect(tabs).toHaveCount(2)
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
await second.click()
await expect(second).toHaveAttribute("aria-selected", "true")
await second.hover()
await page
.getByRole("button", { name: /close terminal/i })
.nth(1)
.click({ force: true })
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
await expect(tabs).toHaveCount(1)
await expect(first).toHaveAttribute("aria-selected", "true")
await expect
.poll(
async () => {
const state = await store(page, key)
return {
count: state?.all.length ?? 0,
first: state?.all.some((item) => item.titleNumber === 1) ?? false,
}
},
{ timeout: 15_000 },
)
.toEqual({ count: 1, first: true })
})
})
+1 -13
View File
@@ -1,5 +1,5 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { base64Encode, checksum } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1" export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
@@ -14,12 +14,6 @@ export function createSdk(directory?: string) {
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true }) return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
} }
export async function resolveDirectory(directory: string) {
return createSdk(directory)
.path.get()
.then((x) => x.data?.directory ?? directory)
}
export async function getWorktree() { export async function getWorktree() {
const sdk = createSdk() const sdk = createSdk()
const result = await sdk.path.get() const result = await sdk.path.get()
@@ -39,9 +33,3 @@ export function dirPath(directory: string) {
export function sessionPath(directory: string, sessionID?: string) { export function sessionPath(directory: string, sessionID?: string) {
return `${dirPath(directory)}/session${sessionID ? `/${sessionID}` : ""}` return `${dirPath(directory)}/session${sessionID ? `/${sessionID}` : ""}`
} }
export function workspacePersistKey(directory: string, key: string) {
const head = directory.slice(0, 12) || "workspace"
const sum = checksum(directory) ?? "0"
return `opencode.workspace.${head}.${sum}.dat:workspace:${key}`
}
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.2.20", "version": "1.2.15",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -57,7 +57,7 @@
"@thisbeyond/solid-dnd": "0.7.5", "@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:", "diff": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#main", "ghostty-web": "0.4.0",
"luxon": "catalog:", "luxon": "catalog:",
"marked": "catalog:", "marked": "catalog:",
"marked-shiki": "catalog:", "marked-shiki": "catalog:",
+4 -7
View File
@@ -7,8 +7,8 @@ import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { Font } from "@opencode-ai/ui/font" import { Font } from "@opencode-ai/ui/font"
import { ThemeProvider } from "@opencode-ai/ui/theme" import { ThemeProvider } from "@opencode-ai/ui/theme"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" import { Navigate, Route, Router } from "@solidjs/router"
import { Component, ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js" import { ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
import { CommandProvider } from "@/context/command" import { CommandProvider } from "@/context/command"
import { CommentsProvider } from "@/context/comments" import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file" import { FileProvider } from "@/context/file"
@@ -28,7 +28,6 @@ import { TerminalProvider } from "@/context/terminal"
import DirectoryLayout from "@/pages/directory-layout" import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout" import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error" import { ErrorPage } from "./pages/error"
import { Dynamic } from "solid-js/web"
const Home = lazy(() => import("@/pages/home")) const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session")) const Session = lazy(() => import("@/pages/session"))
@@ -145,15 +144,13 @@ export function AppInterface(props: {
children?: JSX.Element children?: JSX.Element
defaultServer: ServerConnection.Key defaultServer: ServerConnection.Key
servers?: Array<ServerConnection.Any> servers?: Array<ServerConnection.Any>
router?: Component<BaseRouterProps>
}) { }) {
return ( return (
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}> <ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
<ServerKey> <ServerKey>
<GlobalSDKProvider> <GlobalSDKProvider>
<GlobalSyncProvider> <GlobalSyncProvider>
<Dynamic <Router
component={props.router ?? Router}
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>} root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
> >
<Route path="/" component={HomeRoute} /> <Route path="/" component={HomeRoute} />
@@ -161,7 +158,7 @@ export function AppInterface(props: {
<Route path="/" component={SessionIndexRoute} /> <Route path="/" component={SessionIndexRoute} />
<Route path="/session/:id?" component={SessionRoute} /> <Route path="/session/:id?" component={SessionRoute} />
</Route> </Route>
</Dynamic> </Router>
</GlobalSyncProvider> </GlobalSyncProvider>
</GlobalSDKProvider> </GlobalSDKProvider>
</ServerKey> </ServerKey>
@@ -325,6 +325,12 @@ export default function FileTree(props: {
), ),
) )
createEffect(() => {
const dir = file.tree.state(props.path)
if (!shouldListExpanded({ level, dir })) return
void file.tree.list(props.path)
})
const nodes = createMemo(() => { const nodes = createMemo(() => {
const nodes = file.tree.children(props.path) const nodes = file.tree.children(props.path)
const current = filter() const current = filter()
+38 -75
View File
@@ -1,5 +1,4 @@
import { useFilteredList } from "@opencode-ai/ui/hooks" import { useFilteredList } from "@opencode-ai/ui/hooks"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js" import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createFocusSignal } from "@solid-primitives/active-element" import { createFocusSignal } from "@solid-primitives/active-element"
@@ -254,8 +253,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
applyingHistory: false, applyingHistory: false,
}) })
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
const commentCount = createMemo(() => { const commentCount = createMemo(() => {
if (store.mode === "shell") return 0 if (store.mode === "shell") return 0
return prompt.context.items().filter((item) => !!item.comment?.trim()).length return prompt.context.items().filter((item) => !!item.comment?.trim()).length
@@ -594,6 +591,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setActive: setSlashActive, setActive: setSlashActive,
onInput: slashOnInput, onInput: slashOnInput,
onKeyDown: slashOnKeyDown, onKeyDown: slashOnKeyDown,
refetch: slashRefetch,
} = useFilteredList<SlashCommand>({ } = useFilteredList<SlashCommand>({
items: slashCommands, items: slashCommands,
key: (x) => x?.id, key: (x) => x?.id,
@@ -650,6 +648,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
} }
createEffect(
on(
() => sync.data.command,
() => slashRefetch(),
{ defer: true },
),
)
// Auto-scroll active command into view when navigating with keyboard // Auto-scroll active command into view when navigating with keyboard
createEffect(() => { createEffect(() => {
const activeId = slashActive() const activeId = slashActive()
@@ -950,18 +956,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
readClipboardImage: platform.readClipboardImage, readClipboardImage: platform.readClipboardImage,
}) })
const variants = createMemo(() => ["default", ...local.model.variant.list()])
const accepting = createMemo(() => {
const id = params.id
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
return permission.isAutoAccepting(id, sdk.directory)
})
const { abort, handleSubmit } = createPromptSubmit({ const { abort, handleSubmit } = createPromptSubmit({
info, info,
imageAttachments, imageAttachments,
commentCount, commentCount,
autoAccept: () => accepting(),
mode: () => store.mode, mode: () => store.mode,
working, working,
editor: () => editorRef, editor: () => editorRef,
@@ -1126,6 +1124,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
} }
const variants = createMemo(() => ["default", ...local.model.variant.list()])
const accepting = createMemo(() => {
const id = params.id
if (!id) return false
return permission.isAutoAccepting(id, sdk.directory)
})
return ( return (
<div class="relative size-full _max-h-[320px] flex flex-col gap-0"> <div class="relative size-full _max-h-[320px] flex flex-col gap-0">
<PromptPopover <PromptPopover
@@ -1203,9 +1208,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
aria-multiline="true" aria-multiline="true"
aria-label={placeholder()} aria-label={placeholder()}
contenteditable="true" contenteditable="true"
autocapitalize={store.mode === "normal" ? "sentences" : "off"} autocapitalize="off"
autocorrect={store.mode === "normal" ? "on" : "off"} autocorrect="off"
spellcheck={store.mode === "normal"} spellcheck={false}
onInput={handleInput} onInput={handleInput}
onPaste={handlePaste} onPaste={handlePaste}
onCompositionStart={() => setComposing(true)} onCompositionStart={() => setComposing(true)}
@@ -1245,9 +1250,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div <div
aria-hidden={store.mode !== "normal"} aria-hidden={store.mode !== "normal"}
class="flex items-center gap-1" class="flex items-center gap-1 transition-all duration-200 ease-out"
style={{ classList={{
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none", "opacity-100 translate-y-0 scale-100 pointer-events-auto": store.mode === "normal",
"opacity-0 translate-y-2 scale-95 pointer-events-none": store.mode !== "normal",
}} }}
> >
<TooltipKeybind <TooltipKeybind
@@ -1260,11 +1266,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type="button" type="button"
variant="ghost" variant="ghost"
class="size-8 p-0" class="size-8 p-0"
style={{
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
}}
onClick={pick} onClick={pick}
disabled={store.mode !== "normal"} disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1} tabIndex={store.mode === "normal" ? undefined : -1}
@@ -1302,11 +1303,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
icon={working() ? "stop" : "arrow-up"} icon={working() ? "stop" : "arrow-up"}
variant="primary" variant="primary"
class="size-8" class="size-8"
style={{
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
}}
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")} aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/> />
</Tooltip> </Tooltip>
@@ -1326,11 +1322,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Button <Button
data-action="prompt-permissions" data-action="prompt-permissions"
variant="ghost" variant="ghost"
disabled={!params.id}
onClick={() => { onClick={() => {
if (!params.id) { if (!params.id) return
permission.toggleAutoAcceptDirectory(sdk.directory)
return
}
permission.toggleAutoAccept(params.id, sdk.directory) permission.toggleAutoAccept(params.id, sdk.directory)
}} }}
classList={{ classList={{
@@ -1359,21 +1353,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Show when={store.mode === "normal" || store.mode === "shell"}> <Show when={store.mode === "normal" || store.mode === "shell"}>
<DockTray attach="top"> <DockTray attach="top">
<div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0"> <div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
<div class="flex items-center gap-1.5 min-w-0 flex-1 relative"> <div class="flex items-center gap-1.5 min-w-0 flex-1">
<div <Show when={store.mode === "shell"}>
class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0" <div class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0" style={{ padding: "0 4px 0 8px" }}>
style={{ <span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
padding: "0 4px 0 8px", <div class="size-4 shrink-0" />
opacity: 1 - buttonsSpring(), </div>
transform: `scale(${0.95 + (1 - buttonsSpring()) * 0.05})`, </Show>
filter: `blur(${buttonsSpring() * 2}px)`, <Show when={store.mode === "normal"}>
"pointer-events": buttonsSpring() < 0.5 ? "auto" : "none",
}}
>
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
<div class="size-4 shrink-0" />
</div>
<div class="flex items-center gap-1.5 min-w-0 flex-1">
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
gutter={4} gutter={4}
@@ -1387,13 +1374,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSelect={local.agent.set} onSelect={local.agent.set}
class="capitalize max-w-[160px]" class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular" valueClass="truncate text-13-regular"
triggerStyle={{ triggerStyle={{ height: "28px" }}
height: "28px",
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}}
variant="ghost" variant="ghost"
/> />
</TooltipKeybind> </TooltipKeybind>
@@ -1411,13 +1392,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
variant="ghost" variant="ghost"
size="normal" size="normal"
class="min-w-0 max-w-[320px] text-13-regular group" class="min-w-0 max-w-[320px] text-13-regular group"
style={{ style={{ height: "28px" }}
height: "28px",
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}}
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)} onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
> >
<Show when={local.model.current()?.provider?.id}> <Show when={local.model.current()?.provider?.id}>
@@ -1446,13 +1421,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
triggerProps={{ triggerProps={{
variant: "ghost", variant: "ghost",
size: "normal", size: "normal",
style: { style: { height: "28px" },
height: "28px",
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
},
class: "min-w-0 max-w-[320px] text-13-regular group", class: "min-w-0 max-w-[320px] text-13-regular group",
}} }}
> >
@@ -1484,17 +1453,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)} onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
class="capitalize max-w-[160px]" class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular" valueClass="truncate text-13-regular"
triggerStyle={{ triggerStyle={{ height: "28px" }}
height: "28px",
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}}
variant="ghost" variant="ghost"
/> />
</TooltipKeybind> </TooltipKeybind>
</div> </Show>
</div> </div>
<div class="shrink-0"> <div class="shrink-0">
<RadioGroup <RadioGroup
@@ -5,7 +5,6 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
const createdClients: string[] = [] const createdClients: string[] = []
const createdSessions: string[] = [] const createdSessions: string[] = []
const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
const sentShell: string[] = [] const sentShell: string[] = []
const syncedDirectories: string[] = [] const syncedDirectories: string[] = []
@@ -70,14 +69,6 @@ beforeAll(async () => {
}), }),
})) }))
mock.module("@/context/permission", () => ({
usePermission: () => ({
enableAutoAccept(sessionID: string, directory: string) {
enabledAutoAccept.push({ sessionID, directory })
},
}),
}))
mock.module("@/context/prompt", () => ({ mock.module("@/context/prompt", () => ({
usePrompt: () => ({ usePrompt: () => ({
current: () => promptValue, current: () => promptValue,
@@ -154,7 +145,6 @@ beforeAll(async () => {
beforeEach(() => { beforeEach(() => {
createdClients.length = 0 createdClients.length = 0
createdSessions.length = 0 createdSessions.length = 0
enabledAutoAccept.length = 0
sentShell.length = 0 sentShell.length = 0
syncedDirectories.length = 0 syncedDirectories.length = 0
selected = "/repo/worktree-a" selected = "/repo/worktree-a"
@@ -166,7 +156,6 @@ describe("prompt submit worktree selection", () => {
info: () => undefined, info: () => undefined,
imageAttachments: () => [], imageAttachments: () => [],
commentCount: () => 0, commentCount: () => 0,
autoAccept: () => false,
mode: () => "shell", mode: () => "shell",
working: () => false, working: () => false,
editor: () => undefined, editor: () => undefined,
@@ -192,31 +181,4 @@ describe("prompt submit worktree selection", () => {
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
}) })
test("applies auto-accept to newly created sessions", async () => {
const submit = createPromptSubmit({
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => true,
mode: () => "shell",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
})
const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event)
expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }])
})
}) })
@@ -8,7 +8,6 @@ import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { usePermission } from "@/context/permission"
import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt" import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
@@ -16,7 +15,6 @@ import { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree" import { Worktree as WorktreeState } from "@/utils/worktree"
import { buildRequestParts } from "./build-request-parts" import { buildRequestParts } from "./build-request-parts"
import { setCursorPosition } from "./editor-dom" import { setCursorPosition } from "./editor-dom"
import { formatServerError } from "@/utils/server-errors"
type PendingPrompt = { type PendingPrompt = {
abort: AbortController abort: AbortController
@@ -29,7 +27,6 @@ type PromptSubmitInput = {
info: Accessor<{ id: string } | undefined> info: Accessor<{ id: string } | undefined>
imageAttachments: Accessor<ImageAttachmentPart[]> imageAttachments: Accessor<ImageAttachmentPart[]>
commentCount: Accessor<number> commentCount: Accessor<number>
autoAccept: Accessor<boolean>
mode: Accessor<"normal" | "shell"> mode: Accessor<"normal" | "shell">
working: Accessor<boolean> working: Accessor<boolean>
editor: () => HTMLDivElement | undefined editor: () => HTMLDivElement | undefined
@@ -59,7 +56,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const sync = useSync() const sync = useSync()
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const local = useLocal() const local = useLocal()
const permission = usePermission()
const prompt = usePrompt() const prompt = usePrompt()
const layout = useLayout() const layout = useLayout()
const language = useLanguage() const language = useLanguage()
@@ -144,7 +140,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const projectDirectory = sdk.directory const projectDirectory = sdk.directory
const isNewSession = !params.id const isNewSession = !params.id
const shouldAutoAccept = isNewSession && input.autoAccept()
const worktreeSelection = input.newSessionWorktree?.() || "main" const worktreeSelection = input.newSessionWorktree?.() || "main"
let sessionDirectory = projectDirectory let sessionDirectory = projectDirectory
@@ -202,7 +197,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return undefined return undefined
}) })
if (session) { if (session) {
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
} }
@@ -287,7 +281,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
.catch((err) => { .catch((err) => {
showToast({ showToast({
title: language.t("prompt.toast.commandSendFailed.title"), title: language.t("prompt.toast.commandSendFailed.title"),
description: formatServerError(err, language.t, language.t("common.requestFailed")), description: errorMessage(err),
}) })
restoreInput() restoreInput()
}) })
@@ -39,7 +39,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const usd = createMemo( const usd = createMemo(
() => () =>
new Intl.NumberFormat(language.intl(), { new Intl.NumberFormat(language.locale(), {
style: "currency", style: "currency",
currency: "USD", currency: "USD",
}), }),
@@ -77,7 +77,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
{(ctx) => ( {(ctx) => (
<> <>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-text-invert-strong">{ctx().total.toLocaleString(language.intl())}</span> <span class="text-text-invert-strong">{ctx().total.toLocaleString(language.locale())}</span>
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span> <span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -128,7 +128,7 @@ export function SessionContextTab() {
const usd = createMemo( const usd = createMemo(
() => () =>
new Intl.NumberFormat(language.intl(), { new Intl.NumberFormat(language.locale(), {
style: "currency", style: "currency",
currency: "USD", currency: "USD",
}), }),
@@ -136,7 +136,7 @@ export function SessionContextTab() {
const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all)) const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
const ctx = createMemo(() => metrics().context) const ctx = createMemo(() => metrics().context)
const formatter = createMemo(() => createSessionContextFormatter(language.intl())) const formatter = createMemo(() => createSessionContextFormatter(language.locale()))
const cost = createMemo(() => { const cost = createMemo(() => {
return usd().format(metrics().totalCost) return usd().format(metrics().totalCost)
@@ -200,7 +200,7 @@ export function SessionContextTab() {
const stats = [ const stats = [
{ label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" }, { label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" },
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.intl()) }, { label: "context.stats.messages", value: () => counts().all.toLocaleString(language.locale()) },
{ label: "context.stats.provider", value: providerLabel }, { label: "context.stats.provider", value: providerLabel },
{ label: "context.stats.model", value: modelLabel }, { label: "context.stats.model", value: modelLabel },
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) }, { label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
@@ -213,8 +213,8 @@ export function SessionContextTab() {
label: "context.stats.cacheTokens", label: "context.stats.cacheTokens",
value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`, value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`,
}, },
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) }, { label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.locale()) },
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) }, { label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.locale()) },
{ label: "context.stats.totalCost", value: cost }, { label: "context.stats.totalCost", value: cost },
{ label: "context.stats.sessionCreated", value: () => formatter().time(info()?.time.created) }, { label: "context.stats.sessionCreated", value: () => formatter().time(info()?.time.created) },
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) }, { label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
@@ -307,7 +307,7 @@ export function SessionContextTab() {
<div class="flex items-center gap-1 text-11-regular text-text-weak"> <div class="flex items-center gap-1 text-11-regular text-text-weak">
<div class="size-2 rounded-sm" style={{ "background-color": BREAKDOWN_COLOR[segment.key] }} /> <div class="size-2 rounded-sm" style={{ "background-color": BREAKDOWN_COLOR[segment.key] }} />
<div>{breakdownLabel(segment.key)}</div> <div>{breakdownLabel(segment.key)}</div>
<div class="text-text-weaker">{segment.percent.toLocaleString(language.intl())}%</div> <div class="text-text-weaker">{segment.percent.toLocaleString(language.locale())}%</div>
</div> </div>
)} )}
</For> </For>
@@ -138,12 +138,12 @@ function useSessionShare(args: {
globalSDK: ReturnType<typeof useGlobalSDK> globalSDK: ReturnType<typeof useGlobalSDK>
currentSession: () => currentSession: () =>
| { | {
id: string
share?: { share?: {
url?: string url?: string
} }
} }
| undefined | undefined
sessionID: () => string | undefined
projectDirectory: () => string projectDirectory: () => string
platform: ReturnType<typeof usePlatform> platform: ReturnType<typeof usePlatform>
}) { }) {
@@ -167,11 +167,11 @@ function useSessionShare(args: {
}) })
const shareSession = () => { const shareSession = () => {
const sessionID = args.sessionID() const session = args.currentSession()
if (!sessionID || state.share) return if (!session || state.share) return
setState("share", true) setState("share", true)
args.globalSDK.client.session args.globalSDK.client.session
.share({ sessionID, directory: args.projectDirectory() }) .share({ sessionID: session.id, directory: args.projectDirectory() })
.catch((error) => { .catch((error) => {
console.error("Failed to share session", error) console.error("Failed to share session", error)
}) })
@@ -181,11 +181,11 @@ function useSessionShare(args: {
} }
const unshareSession = () => { const unshareSession = () => {
const sessionID = args.sessionID() const session = args.currentSession()
if (!sessionID || state.unshare) return if (!session || state.unshare) return
setState("unshare", true) setState("unshare", true)
args.globalSDK.client.session args.globalSDK.client.session
.unshare({ sessionID, directory: args.projectDirectory() }) .unshare({ sessionID: session.id, directory: args.projectDirectory() })
.catch((error) => { .catch((error) => {
console.error("Failed to unshare session", error) console.error("Failed to unshare session", error)
}) })
@@ -243,9 +243,9 @@ export function SessionHeader() {
}) })
const hotkey = createMemo(() => command.keybind("file.open")) const hotkey = createMemo(() => command.keybind("file.open"))
const currentSession = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id))
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled") const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const showShare = createMemo(() => shareEnabled() && !!params.id) const showShare = createMemo(() => shareEnabled() && !!currentSession())
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey)) const view = createMemo(() => layout.view(sessionKey))
const os = createMemo(() => detectOS(platform)) const os = createMemo(() => detectOS(platform))
@@ -306,10 +306,11 @@ export function SessionHeader() {
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0]) const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
const opening = createMemo(() => openRequest.app !== undefined) const opening = createMemo(() => openRequest.app !== undefined)
const selectApp = (app: OpenApp) => { createEffect(() => {
if (!options().some((item) => item.id === app)) return const value = prefs.app
setPrefs("app", app) if (options().some((o) => o.id === value)) return
} setPrefs("app", options()[0]?.id ?? "finder")
})
const openDir = (app: OpenApp) => { const openDir = (app: OpenApp) => {
if (opening() || !canOpen() || !platform.openPath) return if (opening() || !canOpen() || !platform.openPath) return
@@ -346,7 +347,6 @@ export function SessionHeader() {
const share = useSessionShare({ const share = useSessionShare({
globalSDK, globalSDK,
currentSession, currentSession,
sessionID: () => params.id,
projectDirectory, projectDirectory,
platform, platform,
}) })
@@ -458,7 +458,7 @@ export function SessionHeader() {
value={current().id} value={current().id}
onChange={(value) => { onChange={(value) => {
if (!OPEN_APPS.includes(value as OpenApp)) return if (!OPEN_APPS.includes(value as OpenApp)) return
selectApp(value as OpenApp) setPrefs("app", value as OpenApp)
}} }}
> >
<For each={options()}> <For each={options()}>
@@ -51,26 +51,26 @@ export function NewSessionView(props: NewSessionViewProps) {
return ( return (
<div class={ROOT_CLASS}> <div class={ROOT_CLASS}>
<div class="text-20-medium text-text-weaker">{language.t("command.session.new")}</div> <div class="text-20-medium text-text-weaker">{language.t("command.session.new")}</div>
<div class="flex justify-center items-start gap-3 min-h-5"> <div class="flex justify-center items-center gap-3">
<Icon name="folder" size="small" class="mt-0.5 shrink-0" /> <Icon name="folder" size="small" />
<div class="text-12-medium text-text-weak select-text leading-5"> <div class="text-12-medium text-text-weak select-text">
{getDirectory(projectRoot())} {getDirectory(projectRoot())}
<span class="text-text-strong">{getFilename(projectRoot())}</span> <span class="text-text-strong">{getFilename(projectRoot())}</span>
</div> </div>
</div> </div>
<div class="flex justify-center items-start gap-3 min-h-5"> <div class="flex justify-center items-center gap-1">
<Icon name="branch" size="small" class="mt-0.5 shrink-0" /> <Icon name="branch" size="small" />
<div class="text-12-medium text-text-weak select-text leading-5">{label(current())}</div> <div class="text-12-medium text-text-weak select-text ml-2">{label(current())}</div>
</div> </div>
<Show when={sync.project}> <Show when={sync.project}>
{(project) => ( {(project) => (
<div class="flex justify-center items-start gap-3 min-h-5"> <div class="flex justify-center items-center gap-3">
<Icon name="pencil-line" size="small" class="mt-0.5 shrink-0" /> <Icon name="pencil-line" size="small" />
<div class="text-12-medium text-text-weak leading-5"> <div class="text-12-medium text-text-weak">
{language.t("session.new.lastModified")}&nbsp; {language.t("session.new.lastModified")}&nbsp;
<span class="text-text-strong"> <span class="text-text-strong">
{DateTime.fromMillis(project().time.updated ?? project().time.created) {DateTime.fromMillis(project().time.updated ?? project().time.created)
.setLocale(language.intl()) .setLocale(language.locale())
.toRelative()} .toRelative()}
</span> </span>
</div> </div>
@@ -17,7 +17,6 @@ type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[num
const PROVIDER_NOTES = [ const PROVIDER_NOTES = [
{ match: (id: string) => id === "opencode", key: "dialog.provider.opencode.note" }, { match: (id: string) => id === "opencode", key: "dialog.provider.opencode.note" },
{ match: (id: string) => id === "opencode-go", key: "dialog.provider.opencodeGo.tagline" },
{ match: (id: string) => id === "anthropic", key: "dialog.provider.anthropic.note" }, { match: (id: string) => id === "anthropic", key: "dialog.provider.anthropic.note" },
{ match: (id: string) => id.startsWith("github-copilot"), key: "dialog.provider.copilot.note" }, { match: (id: string) => id.startsWith("github-copilot"), key: "dialog.provider.copilot.note" },
{ match: (id: string) => id === "openai", key: "dialog.provider.openai.note" }, { match: (id: string) => id === "openai", key: "dialog.provider.openai.note" },
@@ -182,11 +181,21 @@ export const SettingsProviders: Component = () => {
<div class="flex items-center gap-x-3"> <div class="flex items-center gap-x-3">
<ProviderIcon id={item.id} class="size-5 shrink-0 icon-strong-base" /> <ProviderIcon id={item.id} class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong">{item.name}</span> <span class="text-14-medium text-text-strong">{item.name}</span>
<Show when={item.id === "opencode"}>
<span class="text-14-regular text-text-weak">
{language.t("dialog.provider.opencode.tagline")}
</span>
</Show>
<Show when={item.id === "opencode"}> <Show when={item.id === "opencode"}>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag> <Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show> </Show>
<Show when={item.id === "opencode-go"}> <Show when={item.id === "opencode-go"}>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag> <>
<span class="text-14-regular text-text-weak">
{language.t("dialog.provider.opencodeGo.tagline")}
</span>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</>
</Show> </Show>
</div> </div>
<Show when={note(item.id)}> <Show when={note(item.id)}>
+15 -12
View File
@@ -202,26 +202,29 @@ export function StatusPopover() {
triggerAs={Button} triggerAs={Button}
triggerProps={{ triggerProps={{
variant: "ghost", variant: "ghost",
class: "titlebar-icon w-6 h-6 p-0 box-border", class:
"aria-label": language.t("status.popover.trigger"), "rounded-md h-[24px] pr-3 pl-0.5 gap-2 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-base-active",
style: { scale: 1 }, style: { scale: 1 },
}} }}
trigger={ trigger={
<div class="flex size-4 items-center justify-center"> <div class="flex items-center gap-0.5">
<div <div class="size-4 flex items-center justify-center">
classList={{ <div
"size-1.5 rounded-full": true, classList={{
"bg-icon-success-base": overallHealthy(), "size-1.5 rounded-full": true,
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined, "bg-icon-success-base": overallHealthy(),
"bg-border-weak-base": server.healthy() === undefined, "bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
}} "bg-border-weak-base": server.healthy() === undefined,
/> }}
/>
</div>
<span class="text-12-regular text-text-strong">{language.t("status.popover.trigger")}</span>
</div> </div>
} }
class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl" class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
gutter={4} gutter={4}
placement="bottom-end" placement="bottom-end"
shift={-168} shift={-136}
> >
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]"> <div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
<Tabs <Tabs
+26 -26
View File
@@ -1,7 +1,7 @@
import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme" import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web" import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js" import { type ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
import { SerializeAddon } from "@/addons/serialize" import { SerializeAddon } from "@/addons/serialize"
import { matchKeybind, parseKeybind } from "@/context/command" import { matchKeybind, parseKeybind } from "@/context/command"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -18,7 +18,7 @@ const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
export interface TerminalProps extends ComponentProps<"div"> { export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY pty: LocalPTY
onSubmit?: () => void onSubmit?: () => void
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void onCleanup?: (pty: LocalPTY) => void
onConnect?: () => void onConnect?: () => void
onConnectError?: (error: unknown) => void onConnectError?: (error: unknown) => void
} }
@@ -126,8 +126,8 @@ const persistTerminal = (input: {
term: Term | undefined term: Term | undefined
addon: SerializeAddon | undefined addon: SerializeAddon | undefined
cursor: number cursor: number
id: string pty: LocalPTY
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void onCleanup?: (pty: LocalPTY) => void
}) => { }) => {
if (!input.addon || !input.onCleanup || !input.term) return if (!input.addon || !input.onCleanup || !input.term) return
const buffer = (() => { const buffer = (() => {
@@ -140,7 +140,7 @@ const persistTerminal = (input: {
})() })()
input.onCleanup({ input.onCleanup({
id: input.id, ...input.pty,
buffer, buffer,
cursor: input.cursor, cursor: input.cursor,
rows: input.term.rows, rows: input.term.rows,
@@ -158,19 +158,6 @@ export const Terminal = (props: TerminalProps) => {
const server = useServer() const server = useServer()
let container!: HTMLDivElement let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"]) const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"])
const id = local.pty.id
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
const restoreSize =
restore &&
typeof local.pty.cols === "number" &&
Number.isSafeInteger(local.pty.cols) &&
local.pty.cols > 0 &&
typeof local.pty.rows === "number" &&
Number.isSafeInteger(local.pty.rows) &&
local.pty.rows > 0
? { cols: local.pty.cols, rows: local.pty.rows }
: undefined
const scrollY = typeof local.pty.scrollY === "number" ? local.pty.scrollY : undefined
let ws: WebSocket | undefined let ws: WebSocket | undefined
let term: Term | undefined let term: Term | undefined
let ghostty: Ghostty let ghostty: Ghostty
@@ -203,7 +190,7 @@ export const Terminal = (props: TerminalProps) => {
const pushSize = (cols: number, rows: number) => { const pushSize = (cols: number, rows: number) => {
return sdk.client.pty return sdk.client.pty
.update({ .update({
ptyID: id, ptyID: local.pty.id,
size: { cols, rows }, size: { cols, rows },
}) })
.catch((err) => { .catch((err) => {
@@ -232,7 +219,7 @@ export const Terminal = (props: TerminalProps) => {
} }
} }
const terminalColors = createMemo(getTerminalColors) const [terminalColors, setTerminalColors] = createSignal<TerminalColors>(getTerminalColors())
const scheduleFit = () => { const scheduleFit = () => {
if (disposed) return if (disposed) return
@@ -272,7 +259,8 @@ export const Terminal = (props: TerminalProps) => {
} }
createEffect(() => { createEffect(() => {
const colors = terminalColors() const colors = getTerminalColors()
setTerminalColors(colors)
if (!term) return if (!term) return
setOptionIfSupported(term, "theme", colors) setOptionIfSupported(term, "theme", colors)
}) })
@@ -332,6 +320,18 @@ export const Terminal = (props: TerminalProps) => {
const mod = loaded.mod const mod = loaded.mod
const g = loaded.ghostty const g = loaded.ghostty
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
const restoreSize =
restore &&
typeof local.pty.cols === "number" &&
Number.isSafeInteger(local.pty.cols) &&
local.pty.cols > 0 &&
typeof local.pty.rows === "number" &&
Number.isSafeInteger(local.pty.rows) &&
local.pty.rows > 0
? { cols: local.pty.cols, rows: local.pty.rows }
: undefined
const t = new mod.Terminal({ const t = new mod.Terminal({
cursorBlink: true, cursorBlink: true,
cursorStyle: "bar", cursorStyle: "bar",
@@ -428,14 +428,14 @@ export const Terminal = (props: TerminalProps) => {
await write(restore) await write(restore)
fit.fit() fit.fit()
scheduleSize(t.cols, t.rows) scheduleSize(t.cols, t.rows)
if (scrollY !== undefined) t.scrollToLine(scrollY) if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
startResize() startResize()
} else { } else {
fit.fit() fit.fit()
scheduleSize(t.cols, t.rows) scheduleSize(t.cols, t.rows)
if (restore) { if (restore) {
await write(restore) await write(restore)
if (scrollY !== undefined) t.scrollToLine(scrollY) if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
} }
startResize() startResize()
} }
@@ -447,9 +447,9 @@ export const Terminal = (props: TerminalProps) => {
const once = { value: false } const once = { value: false }
let closing = false let closing = false
const url = new URL(sdk.url + `/pty/${id}/connect`) const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
url.searchParams.set("directory", sdk.directory) url.searchParams.set("directory", sdk.directory)
url.searchParams.set("cursor", String(start !== undefined ? start : restore ? -1 : 0)) url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
url.protocol = url.protocol === "https:" ? "wss:" : "ws:" url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
url.username = server.current?.http.username ?? "" url.username = server.current?.http.username ?? ""
url.password = server.current?.http.password ?? "" url.password = server.current?.http.password ?? ""
@@ -543,7 +543,7 @@ export const Terminal = (props: TerminalProps) => {
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000) if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000)
const finalize = () => { const finalize = () => {
persistTerminal({ term, addon: serializeAddon, cursor, id, onCleanup: props.onCleanup }) persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
cleanup() cleanup()
} }
-2
View File
@@ -157,7 +157,6 @@ export function Titlebar() {
<header <header
class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center" class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
style={{ "min-height": minHeight() }} style={{ "min-height": minHeight() }}
data-tauri-drag-region
onMouseDown={drag} onMouseDown={drag}
onDblClick={maximize} onDblClick={maximize}
> >
@@ -277,7 +276,6 @@ export function Titlebar() {
"flex items-center min-w-0 justify-end": true, "flex items-center min-w-0 justify-end": true,
"pr-2": !windows(), "pr-2": !windows(),
}} }}
data-tauri-drag-region
onMouseDown={drag} onMouseDown={drag}
> >
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" /> <div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
+46 -61
View File
@@ -11,6 +11,7 @@ import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
import { import {
createContext, createContext,
createEffect,
getOwner, getOwner,
Match, Match,
onCleanup, onCleanup,
@@ -34,6 +35,7 @@ import { trimSessions } from "./global-sync/session-trim"
import type { ProjectMeta } from "./global-sync/types" import type { ProjectMeta } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types"
import { sanitizeProject } from "./global-sync/utils" import { sanitizeProject } from "./global-sync/utils"
import { usePlatform } from "./platform"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
type GlobalStore = { type GlobalStore = {
@@ -52,6 +54,7 @@ type GlobalStore = {
function createGlobalSync() { function createGlobalSync() {
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
const platform = usePlatform()
const language = useLanguage() const language = useLanguage()
const owner = getOwner() const owner = getOwner()
if (!owner) throw new Error("GlobalSync must be created within owner") if (!owner) throw new Error("GlobalSync must be created within owner")
@@ -61,7 +64,7 @@ function createGlobalSync() {
const sessionLoads = new Map<string, Promise<void>>() const sessionLoads = new Map<string, Promise<void>>()
const sessionMeta = new Map<string, { limit: number }>() const sessionMeta = new Map<string, { limit: number }>()
const [projectCache, setProjectCache, projectInit] = persisted( const [projectCache, setProjectCache, , projectCacheReady] = persisted(
Persist.global("globalSync.project", ["globalSync.project.v1"]), Persist.global("globalSync.project", ["globalSync.project.v1"]),
createStore({ value: [] as Project[] }), createStore({ value: [] as Project[] }),
) )
@@ -77,57 +80,6 @@ function createGlobalSync() {
reload: undefined, reload: undefined,
}) })
let active = true
let projectWritten = false
onCleanup(() => {
active = false
})
const cacheProjects = () => {
setProjectCache(
"value",
untrack(() => globalStore.project.map(sanitizeProject)),
)
}
const setProjects = (next: Project[] | ((draft: Project[]) => void)) => {
projectWritten = true
if (typeof next === "function") {
setGlobalStore("project", produce(next))
cacheProjects()
return
}
setGlobalStore("project", next)
cacheProjects()
}
const setBootStore = ((...input: unknown[]) => {
if (input[0] === "project" && Array.isArray(input[1])) {
setProjects(input[1] as Project[])
return input[1]
}
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
}) as typeof setGlobalStore
const set = ((...input: unknown[]) => {
if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) {
setProjects(input[1] as Project[] | ((draft: Project[]) => void))
return input[1]
}
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
}) as typeof setGlobalStore
if (projectInit instanceof Promise) {
void projectInit.then(() => {
if (!active) return
if (projectWritten) return
const cached = projectCache.value
if (cached.length === 0) return
setGlobalStore("project", cached)
})
}
const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => { const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
if (!sessionID) return if (!sessionID) return
if (!todos) { if (!todos) {
@@ -175,6 +127,30 @@ function createGlobalSync() {
return sdk return sdk
} }
createEffect(() => {
if (!projectCacheReady()) return
if (globalStore.project.length !== 0) return
const cached = projectCache.value
if (cached.length === 0) return
setGlobalStore("project", cached)
})
createEffect(() => {
if (!projectCacheReady()) return
const projects = globalStore.project
if (projects.length === 0) {
const cachedLength = untrack(() => projectCache.value.length)
if (cachedLength !== 0) return
}
setProjectCache("value", projects.map(sanitizeProject))
})
createEffect(() => {
if (globalStore.reload !== "complete") return
setGlobalStore("reload", undefined)
queue.refresh()
})
async function loadSessions(directory: string) { async function loadSessions(directory: string) {
const pending = sessionLoads.get(directory) const pending = sessionLoads.get(directory)
if (pending) return pending if (pending) return pending
@@ -228,7 +204,10 @@ function createGlobalSync() {
showToast({ showToast({
variant: "error", variant: "error",
title: language.t("toast.session.listFailed.title", { project }), title: language.t("toast.session.listFailed.title", { project }),
description: formatServerError(err, language.t), description: formatServerError(err, {
unknown: language.t("error.chain.unknown"),
invalidConfiguration: language.t("error.server.invalidConfiguration"),
}),
}) })
}) })
@@ -258,7 +237,8 @@ function createGlobalSync() {
setStore: child[1], setStore: child[1],
vcsCache: cache, vcsCache: cache,
loadSessions, loadSessions,
translate: language.t, unknownError: language.t("error.chain.unknown"),
invalidConfigurationError: language.t("error.server.invalidConfiguration"),
}) })
})() })()
@@ -279,7 +259,13 @@ function createGlobalSync() {
event, event,
project: globalStore.project, project: globalStore.project,
refresh: queue.refresh, refresh: queue.refresh,
setGlobalProject: setProjects, setGlobalProject(next) {
if (typeof next === "function") {
setGlobalStore("project", produce(next))
return
}
setGlobalStore("project", next)
},
}) })
if (event.type === "server.connected" || event.type === "global.disposed") { if (event.type === "server.connected" || event.type === "global.disposed") {
for (const directory of Object.keys(children.children)) { for (const directory of Object.keys(children.children)) {
@@ -327,9 +313,10 @@ function createGlobalSync() {
url: globalSDK.url, url: globalSDK.url,
}), }),
requestFailedTitle: language.t("common.requestFailed"), requestFailedTitle: language.t("common.requestFailed"),
translate: language.t, unknownError: language.t("error.chain.unknown"),
invalidConfigurationError: language.t("error.server.invalidConfiguration"),
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
setGlobalStore: setBootStore, setGlobalStore,
}) })
} }
@@ -353,9 +340,7 @@ function createGlobalSync() {
.update({ config }) .update({ config })
.then(bootstrap) .then(bootstrap)
.then(() => { .then(() => {
queue.refresh() setGlobalStore("reload", "complete")
setGlobalStore("reload", undefined)
queue.refresh()
}) })
.catch((error) => { .catch((error) => {
setGlobalStore("reload", undefined) setGlobalStore("reload", undefined)
@@ -365,7 +350,7 @@ function createGlobalSync() {
return { return {
data: globalStore, data: globalStore,
set, set: setGlobalStore,
get ready() { get ready() {
return globalStore.ready return globalStore.ready
}, },
@@ -36,7 +36,8 @@ export async function bootstrapGlobal(input: {
connectErrorTitle: string connectErrorTitle: string
connectErrorDescription: string connectErrorDescription: string
requestFailedTitle: string requestFailedTitle: string
translate: (key: string, vars?: Record<string, string | number>) => string unknownError: string
invalidConfigurationError: string
formatMoreCount: (count: number) => string formatMoreCount: (count: number) => string
setGlobalStore: SetStoreFunction<GlobalStore> setGlobalStore: SetStoreFunction<GlobalStore>
}) { }) {
@@ -90,7 +91,10 @@ export async function bootstrapGlobal(input: {
const results = await Promise.allSettled(tasks) const results = await Promise.allSettled(tasks)
const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason) const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
if (errors.length) { if (errors.length) {
const message = formatServerError(errors[0], input.translate) const message = formatServerError(errors[0], {
unknown: input.unknownError,
invalidConfiguration: input.invalidConfigurationError,
})
const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : "" const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : ""
showToast({ showToast({
variant: "error", variant: "error",
@@ -118,7 +122,8 @@ export async function bootstrapDirectory(input: {
setStore: SetStoreFunction<State> setStore: SetStoreFunction<State>
vcsCache: VcsCache vcsCache: VcsCache
loadSessions: (directory: string) => Promise<void> | void loadSessions: (directory: string) => Promise<void> | void
translate: (key: string, vars?: Record<string, string | number>) => string unknownError: string
invalidConfigurationError: string
}) { }) {
if (input.store.status !== "complete") input.setStore("status", "loading") if (input.store.status !== "complete") input.setStore("status", "loading")
@@ -140,7 +145,10 @@ export async function bootstrapDirectory(input: {
showToast({ showToast({
variant: "error", variant: "error",
title: `Failed to reload ${project}`, title: `Failed to reload ${project}`,
description: formatServerError(err, input.translate), description: formatServerError(err, {
unknown: input.unknownError,
invalidConfiguration: input.invalidConfigurationError,
}),
}) })
input.setStore("status", "partial") input.setStore("status", "partial")
return return
@@ -1,4 +1,4 @@
import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js" import { createRoot, createEffect, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import type { VcsInfo } from "@opencode-ai/sdk/v2/client" import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
@@ -131,7 +131,8 @@ export function createChildStoreManager(input: {
) )
if (!vcs) throw new Error("Failed to create persisted cache") if (!vcs) throw new Error("Failed to create persisted cache")
const vcsStore = vcs[0] const vcsStore = vcs[0]
vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcs[3] }) const vcsReady = vcs[3]
vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcsReady })
const meta = runWithOwner(input.owner, () => const meta = runWithOwner(input.owner, () =>
persisted( persisted(
@@ -153,12 +154,10 @@ export function createChildStoreManager(input: {
const init = () => const init = () =>
createRoot((dispose) => { createRoot((dispose) => {
const initialMeta = meta[0].value
const initialIcon = icon[0].value
const child = createStore<State>({ const child = createStore<State>({
project: "", project: "",
projectMeta: initialMeta, projectMeta: meta[0].value,
icon: initialIcon, icon: icon[0].value,
provider: { all: [], connected: [], default: {} }, provider: { all: [], connected: [], default: {} },
config: {}, config: {},
path: { state: "", config: "", worktree: "", directory: "", home: "" }, path: { state: "", config: "", worktree: "", directory: "", home: "" },
@@ -182,27 +181,16 @@ export function createChildStoreManager(input: {
children[directory] = child children[directory] = child
disposers.set(directory, dispose) disposers.set(directory, dispose)
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => { createEffect(() => {
if (!(init instanceof Promise)) return if (!vcsReady()) return
void init.then(() => {
if (children[directory] !== child) return
run()
})
}
onPersistedInit(vcs[2], () => {
const cached = vcsStore.value const cached = vcsStore.value
if (!cached?.branch) return if (!cached?.branch) return
child[1]("vcs", (value) => value ?? cached) child[1]("vcs", (value) => value ?? cached)
}) })
createEffect(() => {
onPersistedInit(meta[2], () => {
if (child[0].projectMeta !== initialMeta) return
child[1]("projectMeta", meta[0].value) child[1]("projectMeta", meta[0].value)
}) })
createEffect(() => {
onPersistedInit(icon[2], () => {
if (child[0].icon !== initialIcon) return
child[1]("icon", icon[0].value) child[1]("icon", icon[0].value)
}) })
}) })
-24
View File
@@ -84,26 +84,6 @@ const LOCALES: readonly Locale[] = [
"tr", "tr",
] ]
const INTL: Record<Locale, string> = {
en: "en",
zh: "zh-Hans",
zht: "zh-Hant",
ko: "ko",
de: "de",
es: "es",
fr: "fr",
da: "da",
ja: "ja",
pl: "pl",
ru: "ru",
ar: "ar",
no: "nb-NO",
br: "pt-BR",
th: "th",
bs: "bs",
tr: "tr",
}
const LABEL_KEY: Record<Locale, keyof Dictionary> = { const LABEL_KEY: Record<Locale, keyof Dictionary> = {
en: "language.en", en: "language.en",
zh: "language.zh", zh: "language.zh",
@@ -146,7 +126,6 @@ const DICT: Record<Locale, Dictionary> = {
} }
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [ const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
{ locale: "en", match: (language) => language.startsWith("en") },
{ locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") }, { locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") },
{ locale: "zh", match: (language) => language.startsWith("zh") }, { locale: "zh", match: (language) => language.startsWith("zh") },
{ locale: "ko", match: (language) => language.startsWith("ko") }, { locale: "ko", match: (language) => language.startsWith("ko") },
@@ -218,8 +197,6 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
) )
const locale = createMemo<Locale>(() => normalizeLocale(store.locale)) const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
console.log("locale", locale())
const intl = createMemo(() => INTL[locale()])
const dict = createMemo<Dictionary>(() => DICT[locale()]) const dict = createMemo<Dictionary>(() => DICT[locale()])
@@ -236,7 +213,6 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
return { return {
ready, ready,
locale, locale,
intl,
locales: LOCALES, locales: LOCALES,
label, label,
t, t,
+5 -77
View File
@@ -7,10 +7,8 @@ import { useServer } from "./server"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
import { Project } from "@opencode-ai/sdk/v2" import { Project } from "@opencode-ai/sdk/v2"
import { Persist, persisted, removePersisted } from "@/utils/persist" import { Persist, persisted, removePersisted } from "@/utils/persist"
import { decode64 } from "@/utils/base64"
import { same } from "@/utils/same" import { same } from "@/utils/same"
import { createScrollPersistence, type SessionScroll } from "./layout-scroll" import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
import { createPathHelpers } from "./file/path"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
const DEFAULT_PANEL_WIDTH = 344 const DEFAULT_PANEL_WIDTH = 344
@@ -98,38 +96,6 @@ function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string):
return { all, active: tab } return { all, active: tab }
} }
const sessionPath = (key: string) => {
const dir = key.split("/")[0]
if (!dir) return
const root = decode64(dir)
if (!root) return
return createPathHelpers(() => root)
}
const normalizeSessionTab = (path: ReturnType<typeof createPathHelpers> | undefined, tab: string) => {
if (!tab.startsWith("file://")) return tab
if (!path) return tab
return path.tab(tab)
}
const normalizeSessionTabList = (path: ReturnType<typeof createPathHelpers> | undefined, all: string[]) => {
const seen = new Set<string>()
return all.flatMap((tab) => {
const value = normalizeSessionTab(path, tab)
if (seen.has(value)) return []
seen.add(value)
return [value]
})
}
const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
const path = sessionPath(key)
return {
all: normalizeSessionTabList(path, tabs.all),
active: tabs.active ? normalizeSessionTab(path, tabs.active) : tabs.active,
}
}
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout", name: "Layout",
init: () => { init: () => {
@@ -181,46 +147,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
} }
})() })()
const sessionTabs = value.sessionTabs if (migratedSidebar === sidebar && migratedReview === review && migratedFileTree === fileTree) return value
const migratedSessionTabs = (() => {
if (!isRecord(sessionTabs)) return sessionTabs
let changed = false
const next = Object.fromEntries(
Object.entries(sessionTabs).map(([key, tabs]) => {
if (!isRecord(tabs) || !Array.isArray(tabs.all)) return [key, tabs]
const current = {
all: tabs.all.filter((tab): tab is string => typeof tab === "string"),
active: typeof tabs.active === "string" ? tabs.active : undefined,
}
const normalized = normalizeStoredSessionTabs(key, current)
if (current.all.length !== tabs.all.length) changed = true
if (!same(current.all, normalized.all) || current.active !== normalized.active) changed = true
if (tabs.active !== undefined && typeof tabs.active !== "string") changed = true
return [key, normalized]
}),
)
if (!changed) return sessionTabs
return next
})()
if (
migratedSidebar === sidebar &&
migratedReview === review &&
migratedFileTree === fileTree &&
migratedSessionTabs === sessionTabs
) {
return value
}
return { return {
...value, ...value,
sidebar: migratedSidebar, sidebar: migratedSidebar,
review: migratedReview, review: migratedReview,
fileTree: migratedFileTree, fileTree: migratedFileTree,
sessionTabs: migratedSessionTabs,
} }
} }
@@ -813,26 +745,22 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}, },
tabs(sessionKey: string | Accessor<string>) { tabs(sessionKey: string | Accessor<string>) {
const key = createSessionKeyReader(sessionKey, ensureKey) const key = createSessionKeyReader(sessionKey, ensureKey)
const path = createMemo(() => sessionPath(key()))
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] }) const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
const normalize = (tab: string) => normalizeSessionTab(path(), tab)
const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all)
return { return {
tabs, tabs,
active: createMemo(() => tabs().active), active: createMemo(() => tabs().active),
all: createMemo(() => tabs().all.filter((tab) => tab !== "review")), all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
setActive(tab: string | undefined) { setActive(tab: string | undefined) {
const session = key() const session = key()
const next = tab ? normalize(tab) : tab
if (!store.sessionTabs[session]) { if (!store.sessionTabs[session]) {
setStore("sessionTabs", session, { all: [], active: next }) setStore("sessionTabs", session, { all: [], active: tab })
} else { } else {
setStore("sessionTabs", session, "active", next) setStore("sessionTabs", session, "active", tab)
} }
}, },
setAll(all: string[]) { setAll(all: string[]) {
const session = key() const session = key()
const next = normalizeAll(all).filter((tab) => tab !== "review") const next = all.filter((tab) => tab !== "review")
if (!store.sessionTabs[session]) { if (!store.sessionTabs[session]) {
setStore("sessionTabs", session, { all: next, active: undefined }) setStore("sessionTabs", session, { all: next, active: undefined })
} else { } else {
@@ -841,7 +769,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}, },
async open(tab: string) { async open(tab: string) {
const session = key() const session = key()
const next = nextSessionTabsForOpen(store.sessionTabs[session], normalize(tab)) const next = nextSessionTabsForOpen(store.sessionTabs[session], tab)
setStore("sessionTabs", session, next) setStore("sessionTabs", session, next)
}, },
close(tab: string) { close(tab: string) {
+10 -20
View File
@@ -35,8 +35,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const agent = (() => { const agent = (() => {
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden)) const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
const models = useModels()
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
current?: string current?: string
}>({ }>({
@@ -55,17 +53,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
setStore("current", undefined) setStore("current", undefined)
return return
} }
const match = name ? available.find((x) => x.name === name) : undefined if (name && available.some((x) => x.name === name)) {
const value = match ?? available[0] setStore("current", name)
if (!value) return return
setStore("current", value.name) }
if (!value.model) return setStore("current", available[0].name)
setModel({
providerID: value.model.providerID,
modelID: value.model.modelID,
})
if (value.variant)
models.variant.set({ providerID: value.model.providerID, modelID: value.model.modelID }, value.variant)
}, },
move(direction: 1 | -1) { move(direction: 1 | -1) {
const available = list() const available = list()
@@ -79,13 +71,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const value = available[next] const value = available[next]
if (!value) return if (!value) return
setStore("current", value.name) setStore("current", value.name)
if (!value.model) return if (value.model)
setModel({ setModel({
providerID: value.model.providerID, providerID: value.model.providerID,
modelID: value.model.modelID, modelID: value.model.modelID,
}) })
if (value.variant)
models.variant.set({ providerID: value.model.providerID, modelID: value.model.modelID }, value.variant)
}, },
} }
})() })()
+28 -3
View File
@@ -132,6 +132,28 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list)) const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
const meta = { pruned: false, disposed: false } const meta = { pruned: false, disposed: false }
const idleSuppressMs = 5000
const idleSuppressedUntilBySession = new Map<string, number>()
const idleSuppressKey = (directory: string, sessionID?: string) => {
if (!sessionID) return
return `${directory}:${sessionID}`
}
const markIdleSuppressed = (directory: string, sessionID?: string) => {
const key = idleSuppressKey(directory, sessionID)
if (!key) return
idleSuppressedUntilBySession.set(key, Date.now() + idleSuppressMs)
}
const shouldSuppressIdle = (directory: string, sessionID?: string) => {
const key = idleSuppressKey(directory, sessionID)
if (!key) return false
const until = idleSuppressedUntilBySession.get(key)
if (until === undefined) return false
idleSuppressedUntilBySession.delete(key)
return until > Date.now()
}
const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => { const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => {
setIndex(scope, "unseen", key, unseen) setIndex(scope, "unseen", key, unseen)
@@ -290,12 +312,15 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
if (event.type !== "session.idle" && event.type !== "session.error") return if (event.type !== "session.idle" && event.type !== "session.error") return
const directory = e.name const directory = e.name
const time = Date.now() if (event.type === "session.error") {
markIdleSuppressed(directory, event.properties.sessionID)
}
if (event.type === "session.idle") { if (event.type === "session.idle") {
handleSessionIdle(directory, event, time) if (shouldSuppressIdle(directory, event.properties.sessionID)) return
handleSessionIdle(directory, event, Date.now())
return return
} }
handleSessionError(directory, event, time) handleSessionError(directory, event, Date.now())
}) })
onCleanup(() => { onCleanup(() => {
meta.disposed = true meta.disposed = true
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond" import { autoRespondsPermission } from "./permission-auto-respond"
const session = (input: { id: string; parentID?: string }) => const session = (input: { id: string; parentID?: string }) =>
({ ({
@@ -31,13 +31,13 @@ describe("autoRespondsPermission", () => {
expect(autoRespondsPermission({ root: true }, sessions, permission("child"), "/tmp/project")).toBe(true) expect(autoRespondsPermission({ root: true }, sessions, permission("child"), "/tmp/project")).toBe(true)
}) })
test("defaults to requiring approval when no lineage override exists", () => { test("defaults to auto-accept when no lineage override exists", () => {
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" }), session({ id: "other" })] const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" }), session({ id: "other" })]
const autoAccept = { const autoAccept = {
other: true, other: true,
} }
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), "/tmp/project")).toBe(false) expect(autoRespondsPermission(autoAccept, sessions, permission("child"), "/tmp/project")).toBe(true)
}) })
test("inherits a parent session's false override", () => { test("inherits a parent session's false override", () => {
@@ -60,43 +60,4 @@ describe("autoRespondsPermission", () => {
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true) expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
}) })
test("falls back to directory-level auto-accept", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/*`]: true,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true)
})
test("session-level override takes precedence over directory-level", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/*`]: true,
[`${base64Encode(directory)}/root`]: false,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false)
})
})
describe("isDirectoryAutoAccepting", () => {
test("returns true when directory key is set", () => {
const directory = "/tmp/project"
const autoAccept = { [`${base64Encode(directory)}/*`]: true }
expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(true)
})
test("returns false when directory key is not set", () => {
expect(isDirectoryAutoAccepting({}, "/tmp/project")).toBe(false)
})
test("returns false when directory key is explicitly false", () => {
const directory = "/tmp/project"
const autoAccept = { [`${base64Encode(directory)}/*`]: false }
expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(false)
})
}) })
@@ -5,19 +5,9 @@ export function acceptKey(sessionID: string, directory?: string) {
return `${base64Encode(directory)}/${sessionID}` return `${base64Encode(directory)}/${sessionID}`
} }
export function directoryAcceptKey(directory: string) {
return `${base64Encode(directory)}/*`
}
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) { function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
const key = acceptKey(sessionID, directory) const key = acceptKey(sessionID, directory)
const directoryKey = directory ? directoryAcceptKey(directory) : undefined return autoAccept[key] ?? autoAccept[sessionID]
return autoAccept[key] ?? autoAccept[sessionID] ?? (directoryKey ? autoAccept[directoryKey] : undefined)
}
export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
const key = directoryAcceptKey(directory)
return autoAccept[key] ?? false
} }
function sessionLineage(session: { id: string; parentID?: string }[], sessionID: string) { function sessionLineage(session: { id: string; parentID?: string }[], sessionID: string) {
@@ -47,5 +37,5 @@ export function autoRespondsPermission(
const value = sessionLineage(session, permission.sessionID) const value = sessionLineage(session, permission.sessionID)
.map((id) => accepted(autoAccept, id, directory)) .map((id) => accepted(autoAccept, id, directory))
.find((item): item is boolean => item !== undefined) .find((item): item is boolean => item !== undefined)
return value ?? false return value ?? true
} }
+2 -73
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, onCleanup } from "solid-js" import { createMemo, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client" import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
@@ -7,12 +7,7 @@ import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "./global-sync" import { useGlobalSync } from "./global-sync"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { import { acceptKey, autoRespondsPermission } from "./permission-auto-respond"
acceptKey,
directoryAcceptKey,
isDirectoryAutoAccepting,
autoRespondsPermission,
} from "./permission-auto-respond"
type PermissionRespondFn = (input: { type PermissionRespondFn = (input: {
sessionID: string sessionID: string
@@ -81,25 +76,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}), }),
) )
// When config has permission: "allow", auto-enable directory-level auto-accept
createEffect(() => {
if (!ready()) return
const directory = decode64(params.dir)
if (!directory) return
const [childStore] = globalSync.child(directory)
const perm = childStore.config.permission
if (typeof perm === "string" && perm === "allow") {
const key = directoryAcceptKey(directory)
if (store.autoAccept[key] === undefined) {
setStore(
produce((draft) => {
draft.autoAccept[key] = true
}),
)
}
}
})
const MAX_RESPONDED = 1000 const MAX_RESPONDED = 1000
const RESPONDED_TTL_MS = 60 * 60 * 1000 const RESPONDED_TTL_MS = 60 * 60 * 1000
const responded = new Map<string, number>() const responded = new Map<string, number>()
@@ -143,10 +119,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory) return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
} }
function isAutoAcceptingDirectory(directory: string) {
return isDirectoryAutoAccepting(store.autoAccept, directory)
}
function shouldAutoRespond(permission: PermissionRequest, directory?: string) { function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
const session = directory ? globalSync.child(directory, { bootstrap: false })[0].session : [] const session = directory ? globalSync.child(directory, { bootstrap: false })[0].session : []
return autoRespondsPermission(store.autoAccept, session, permission, directory) return autoRespondsPermission(store.autoAccept, session, permission, directory)
@@ -170,36 +142,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}) })
onCleanup(unsubscribe) onCleanup(unsubscribe)
function enableDirectory(directory: string) {
const key = directoryAcceptKey(directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = true
}),
)
globalSDK.client.permission
.list({ directory })
.then((x) => {
if (!isAutoAcceptingDirectory(directory)) return
for (const perm of x.data ?? []) {
if (!perm?.id) continue
if (!shouldAutoRespond(perm, directory)) continue
respondOnce(perm, directory)
}
})
.catch(() => undefined)
}
function disableDirectory(directory: string) {
const key = directoryAcceptKey(directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = false
}),
)
}
function enable(sessionID: string, directory: string) { function enable(sessionID: string, directory: string) {
const key = acceptKey(sessionID, directory) const key = acceptKey(sessionID, directory)
const version = bumpEnableVersion(sessionID, directory) const version = bumpEnableVersion(sessionID, directory)
@@ -243,7 +185,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
return shouldAutoRespond(permission, directory) return shouldAutoRespond(permission, directory)
}, },
isAutoAccepting, isAutoAccepting,
isAutoAcceptingDirectory,
toggleAutoAccept(sessionID: string, directory: string) { toggleAutoAccept(sessionID: string, directory: string) {
if (isAutoAccepting(sessionID, directory)) { if (isAutoAccepting(sessionID, directory)) {
disable(sessionID, directory) disable(sessionID, directory)
@@ -252,13 +193,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
enable(sessionID, directory) enable(sessionID, directory)
}, },
toggleAutoAcceptDirectory(directory: string) {
if (isAutoAcceptingDirectory(directory)) {
disableDirectory(directory)
return
}
enableDirectory(directory)
},
enableAutoAccept(sessionID: string, directory: string) { enableAutoAccept(sessionID: string, directory: string) {
if (isAutoAccepting(sessionID, directory)) return if (isAutoAccepting(sessionID, directory)) return
enable(sessionID, directory) enable(sessionID, directory)
@@ -267,11 +201,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
disable(sessionID, directory) disable(sessionID, directory)
}, },
permissionsEnabled, permissionsEnabled,
isPermissionAllowAll(directory: string) {
const [childStore] = globalSync.child(directory)
const perm = childStore.config.permission
return typeof perm === "string" && perm === "allow"
},
} }
}, },
}) })
+1 -3
View File
@@ -511,13 +511,11 @@ export const dict = {
"session.review.change.other": "Changes", "session.review.change.other": "Changes",
"session.review.loadingChanges": "Loading changes...", "session.review.loadingChanges": "Loading changes...",
"session.review.empty": "No changes in this session yet", "session.review.empty": "No changes in this session yet",
"session.review.noVcs": "No Git Version Control System detected, changes not displayed", "session.review.noVcs": "No git VCS detected, so session changes will not be detected",
"session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
"session.review.noChanges": "No changes", "session.review.noChanges": "No changes",
"session.files.selectToOpen": "Select a file to open", "session.files.selectToOpen": "Select a file to open",
"session.files.all": "All files", "session.files.all": "All files",
"session.files.empty": "No files",
"session.files.binaryContent": "Binary file (content cannot be displayed)", "session.files.binaryContent": "Binary file (content cannot be displayed)",
"session.messages.renderEarlier": "Render earlier messages", "session.messages.renderEarlier": "Render earlier messages",
+7 -7
View File
@@ -119,7 +119,7 @@ export const dict = {
"dialog.model.manage.description": "Model seçicide hangi modellerin görüneceğini özelleştirin.", "dialog.model.manage.description": "Model seçicide hangi modellerin görüneceğini özelleştirin.",
"dialog.model.manage.provider.toggle": "Tüm {{provider}} modellerini aç/kapat", "dialog.model.manage.provider.toggle": "Tüm {{provider}} modellerini aç/kapat",
"dialog.model.unpaid.freeModels.title": "OpenCode tarafından sunulan ücretsiz modeller", "dialog.model.unpaid.freeModels.title": "OpenCode'un sunduğu ücretsiz modeller",
"dialog.model.unpaid.addMore.title": "Popüler sağlayıcılardan daha fazla model ekleyin", "dialog.model.unpaid.addMore.title": "Popüler sağlayıcılardan daha fazla model ekleyin",
"dialog.provider.viewAll": "Daha fazla sağlayıcı göster", "dialog.provider.viewAll": "Daha fazla sağlayıcı göster",
@@ -195,7 +195,7 @@ export const dict = {
"provider.custom.error.baseURL.required": "Temel URL gerekli", "provider.custom.error.baseURL.required": "Temel URL gerekli",
"provider.custom.error.baseURL.format": "http:// veya https:// ile başlamalı", "provider.custom.error.baseURL.format": "http:// veya https:// ile başlamalı",
"provider.custom.error.required": "Gerekli", "provider.custom.error.required": "Gerekli",
"provider.custom.error.duplicate": "Tekrar", "provider.custom.error.duplicate": "Çakışma",
"provider.disconnect.toast.disconnected.title": "{{provider}} bağlantısı kesildi", "provider.disconnect.toast.disconnected.title": "{{provider}} bağlantısı kesildi",
"provider.disconnect.toast.disconnected.description": "{{provider}} modelleri artık kullanılabilir değil.", "provider.disconnect.toast.disconnected.description": "{{provider}} modelleri artık kullanılabilir değil.",
@@ -252,7 +252,7 @@ export const dict = {
"prompt.example.10": "API dokümantasyonu oluştur", "prompt.example.10": "API dokümantasyonu oluştur",
"prompt.example.11": "Veritabanı sorgularını optimize et", "prompt.example.11": "Veritabanı sorgularını optimize et",
"prompt.example.12": "Girdi doğrulama ekle", "prompt.example.12": "Girdi doğrulama ekle",
"prompt.example.13": "İçin yeni bir bileşen oluştur...", "prompt.example.13": "... için yeni bir bileşen oluştur",
"prompt.example.14": "Bu projeyi nasıl dağıtabilirim?", "prompt.example.14": "Bu projeyi nasıl dağıtabilirim?",
"prompt.example.15": "Kodumu en iyi uygulamalar için incele", "prompt.example.15": "Kodumu en iyi uygulamalar için incele",
"prompt.example.16": "Bu fonksiyona hata yönetimi ekle", "prompt.example.16": "Bu fonksiyona hata yönetimi ekle",
@@ -263,13 +263,13 @@ export const dict = {
"prompt.example.21": "Bir göç betiği yazmama yardım et", "prompt.example.21": "Bir göç betiği yazmama yardım et",
"prompt.example.22": "Bu uç nokta için önbellekleme uygula", "prompt.example.22": "Bu uç nokta için önbellekleme uygula",
"prompt.example.23": "Bu listeye sayfalama ekle", "prompt.example.23": "Bu listeye sayfalama ekle",
"prompt.example.24": "İçin bir CLI komutu oluştur...", "prompt.example.24": "... için bir CLI komutu oluştur",
"prompt.example.25": "Ortam değişkenleri burada nasıl çalışıyor?", "prompt.example.25": "Ortam değişkenleri burada nasıl çalışıyor?",
"prompt.popover.emptyResults": "Eşleşen sonuç yok", "prompt.popover.emptyResults": "Eşleşen sonuç yok",
"prompt.popover.emptyCommands": "Eşleşen komut yok", "prompt.popover.emptyCommands": "Eşleşen komut yok",
"prompt.dropzone.label": "Görsel veya PDF'leri buraya bırakın", "prompt.dropzone.label": "Görsel veya PDF'leri buraya bırakın",
"prompt.dropzone.file.label": "@bahsetmek için dosyayı bırakın", "prompt.dropzone.file.label": "Dosyayı referans göstermek için bırakın",
"prompt.slash.badge.custom": "özel", "prompt.slash.badge.custom": "özel",
"prompt.slash.badge.skill": "beceri", "prompt.slash.badge.skill": "beceri",
"prompt.slash.badge.mcp": "mcp", "prompt.slash.badge.mcp": "mcp",
@@ -343,7 +343,7 @@ export const dict = {
"dialog.project.edit.icon.recommended": "Önerilen: 128x128px", "dialog.project.edit.icon.recommended": "Önerilen: 128x128px",
"dialog.project.edit.color": "Renk", "dialog.project.edit.color": "Renk",
"dialog.project.edit.color.select": "{{color}} rengini seç", "dialog.project.edit.color.select": "{{color}} rengini seç",
"dialog.project.edit.worktree.startup": "Çalışma alanı başlatma betiği", "dialog.project.edit.worktree.startup": "Çalışma ağacı başlatma betiği",
"dialog.project.edit.worktree.startup.description": "Yeni bir çalışma alanı (worktree) oluşturduktan sonra çalışır.", "dialog.project.edit.worktree.startup.description": "Yeni bir çalışma alanı (worktree) oluşturduktan sonra çalışır.",
"dialog.project.edit.worktree.startup.placeholder": "örneğin bun install", "dialog.project.edit.worktree.startup.placeholder": "örneğin bun install",
@@ -454,7 +454,7 @@ export const dict = {
"error.page.version": "Sürüm: {{version}}", "error.page.version": "Sürüm: {{version}}",
"error.dev.rootNotFound": "error.dev.rootNotFound":
"Kök eleman bulunamadı. index.html dosyanıza eklemeyi unuttunuz mu? Ya da id özelliği yanlış mı yazıldı?", "Kök eleman bulunamadı. index.html dosyanıza eklemeyi unuttunuz mu? Ya da ID özelliği yanlış mı yazıldı?",
"error.globalSync.connectFailed": "Sunucuya bağlanılamadı. `{{url}}` adresinde çalışan bir sunucu var mı?", "error.globalSync.connectFailed": "Sunucuya bağlanılamadı. `{{url}}` adresinde çalışan bir sunucu var mı?",
"directory.error.invalidUrl": "URL'de geçersiz dizin.", "directory.error.invalidUrl": "URL'de geçersiz dizin.",
-28
View File
@@ -1,29 +1 @@
@import "@opencode-ai/ui/styles/tailwind"; @import "@opencode-ai/ui/styles/tailwind";
@layer components {
[data-component="getting-started"] {
container-type: inline-size;
container-name: getting-started;
}
[data-component="getting-started-actions"] {
display: flex;
flex-direction: column;
gap: 0.75rem; /* gap-3 */
}
[data-component="getting-started-actions"] > [data-component="button"] {
width: 100%;
}
@container getting-started (min-width: 17rem) {
[data-component="getting-started-actions"] {
flex-direction: row;
align-items: center;
}
[data-component="getting-started-actions"] > [data-component="button"] {
width: auto;
}
}
}
+25 -58
View File
@@ -1,27 +1,26 @@
import { batch, createEffect, createMemo, Show, type ParentProps } from "solid-js" import { createEffect, createMemo, Show, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useLocation, useNavigate, useParams } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { SDKProvider } from "@/context/sdk" import { SDKProvider } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync" import { SyncProvider, useSync } from "@/context/sync"
import { LocalProvider } from "@/context/local" import { LocalProvider } from "@/context/local"
import { useGlobalSDK } from "@/context/global-sdk"
import { DataProvider } from "@opencode-ai/ui/context" import { DataProvider } from "@opencode-ai/ui/context"
import { base64Encode } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
const params = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const sync = useSync() const sync = useSync()
const slug = createMemo(() => base64Encode(props.directory))
return ( return (
<DataProvider <DataProvider
data={sync.data} data={sync.data}
directory={props.directory} directory={props.directory}
onNavigateToSession={(sessionID: string) => navigate(`/${slug()}/session/${sessionID}`)} onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)}
onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`} onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`}
> >
<LocalProvider>{props.children}</LocalProvider> <LocalProvider>{props.children}</LocalProvider>
</DataProvider> </DataProvider>
@@ -31,63 +30,31 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
export default function Layout(props: ParentProps) { export default function Layout(props: ParentProps) {
const params = useParams() const params = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
const language = useLanguage() const language = useLanguage()
const globalSDK = useGlobalSDK() const [store, setStore] = createStore({ invalid: "" })
const directory = createMemo(() => decode64(params.dir) ?? "") const directory = createMemo(() => {
const [state, setState] = createStore({ invalid: "", resolved: "" }) return decode64(params.dir) ?? ""
})
createEffect(() => { createEffect(() => {
if (!params.dir) return if (!params.dir) return
const raw = directory() if (directory()) return
if (!raw) { if (store.invalid === params.dir) return
if (state.invalid === params.dir) return setStore("invalid", params.dir)
setState("invalid", params.dir) showToast({
showToast({ variant: "error",
variant: "error", title: language.t("common.requestFailed"),
title: language.t("common.requestFailed"), description: language.t("directory.error.invalidUrl"),
description: language.t("directory.error.invalidUrl"), })
}) navigate("/", { replace: true })
navigate("/", { replace: true })
return
}
const current = params.dir
globalSDK
.createClient({
directory: raw,
throwOnError: true,
})
.path.get()
.then((x) => {
if (params.dir !== current) return
const next = x.data?.directory ?? raw
batch(() => {
setState("invalid", "")
setState("resolved", next)
})
if (next === raw) return
const path = location.pathname.slice(current.length + 1)
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
})
.catch(() => {
if (params.dir !== current) return
batch(() => {
setState("invalid", "")
setState("resolved", raw)
})
})
}) })
return ( return (
<Show when={state.resolved}> <Show when={directory()}>
{(resolved) => ( <SDKProvider directory={directory}>
<SDKProvider directory={resolved}> <SyncProvider>
<SyncProvider> <DirectoryDataProvider directory={directory()}>{props.children}</DirectoryDataProvider>
<DirectoryDataProvider directory={resolved()}>{props.children}</DirectoryDataProvider> </SyncProvider>
</SyncProvider> </SDKProvider>
</SDKProvider>
)}
</Show> </Show>
) )
} }
+216 -419
View File
@@ -10,8 +10,9 @@ import {
ParentProps, ParentProps,
Show, Show,
untrack, untrack,
type JSX,
} from "solid-js" } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router" import { A, useNavigate, useParams } from "@solidjs/router"
import { useLayout, LocalProject } from "@/context/layout" import { useLayout, LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
@@ -19,8 +20,9 @@ import { base64Encode } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
@@ -40,9 +42,7 @@ import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry" import { retry } from "@opencode-ai/util/retry"
import { playSound, soundSrc } from "@/utils/sound" import { playSound, soundSrc } from "@/utils/sound"
import { createAim } from "@/utils/aim" import { createAim } from "@/utils/aim"
import { setNavigate } from "@/utils/notification-click"
import { Worktree as WorktreeState } from "@/utils/worktree" import { Worktree as WorktreeState } from "@/utils/worktree"
import { setSessionHandoff } from "@/pages/session/handoff"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
@@ -57,20 +57,16 @@ import { Titlebar } from "@/components/titlebar"
import { useServer } from "@/context/server" import { useServer } from "@/context/server"
import { useLanguage, type Locale } from "@/context/language" import { useLanguage, type Locale } from "@/context/language"
import { import {
childMapByParent,
displayName, displayName,
effectiveWorkspaceOrder,
errorMessage, errorMessage,
getDraggableId, getDraggableId,
latestRootSession, latestRootSession,
sortedRootSessions, sortedRootSessions,
syncWorkspaceOrder,
workspaceKey, workspaceKey,
} from "./layout/helpers" } from "./layout/helpers"
import { import { collectOpenProjectDeepLinks, deepLinkEvent, drainPendingDeepLinks } from "./layout/deep-links"
collectNewSessionDeepLinks,
collectOpenProjectDeepLinks,
deepLinkEvent,
drainPendingDeepLinks,
} from "./layout/deep-links"
import { createInlineEditorController } from "./layout/inline-editor" import { createInlineEditorController } from "./layout/inline-editor"
import { import {
LocalWorkspace, LocalWorkspace,
@@ -93,7 +89,6 @@ export default function Layout(props: ParentProps) {
workspaceName: {} as Record<string, string>, workspaceName: {} as Record<string, string>,
workspaceBranchName: {} as Record<string, Record<string, string>>, workspaceBranchName: {} as Record<string, Record<string, string>>,
workspaceExpanded: {} as Record<string, boolean>, workspaceExpanded: {} as Record<string, boolean>,
gettingStartedDismissed: false,
}), }),
) )
@@ -112,7 +107,6 @@ export default function Layout(props: ParentProps) {
const notification = useNotification() const notification = useNotification()
const permission = usePermission() const permission = usePermission()
const navigate = useNavigate() const navigate = useNavigate()
setNavigate(navigate)
const providers = useProviders() const providers = useProviders()
const dialog = useDialog() const dialog = useDialog()
const command = useCommand() const command = useCommand()
@@ -155,8 +149,6 @@ export default function Layout(props: ParentProps) {
const isBusy = (directory: string) => !!state.busyWorkspaces[workspaceKey(directory)] const isBusy = (directory: string) => !!state.busyWorkspaces[workspaceKey(directory)]
const navLeave = { current: undefined as number | undefined } const navLeave = { current: undefined as number | undefined }
const [sortNow, setSortNow] = createSignal(Date.now()) const [sortNow, setSortNow] = createSignal(Date.now())
const [sizing, setSizing] = createSignal(false)
let sizet: number | undefined
let sortNowInterval: ReturnType<typeof setInterval> | undefined let sortNowInterval: ReturnType<typeof setInterval> | undefined
const sortNowTimeout = setTimeout( const sortNowTimeout = setTimeout(
() => { () => {
@@ -169,7 +161,7 @@ export default function Layout(props: ParentProps) {
const aim = createAim({ const aim = createAim({
enabled: () => !layout.sidebar.opened(), enabled: () => !layout.sidebar.opened(),
active: () => state.hoverProject, active: () => state.hoverProject,
el: () => state.nav?.querySelector<HTMLElement>("[data-component='sidebar-rail']") ?? state.nav, el: () => state.nav,
onActivate: (directory) => { onActivate: (directory) => {
globalSync.child(directory) globalSync.child(directory)
setState("hoverProject", directory) setState("hoverProject", directory)
@@ -181,23 +173,9 @@ export default function Layout(props: ParentProps) {
if (navLeave.current !== undefined) clearTimeout(navLeave.current) if (navLeave.current !== undefined) clearTimeout(navLeave.current)
clearTimeout(sortNowTimeout) clearTimeout(sortNowTimeout)
if (sortNowInterval) clearInterval(sortNowInterval) if (sortNowInterval) clearInterval(sortNowInterval)
if (sizet !== undefined) clearTimeout(sizet)
if (peekt !== undefined) clearTimeout(peekt)
aim.reset() aim.reset()
}) })
onMount(() => {
const stop = () => setSizing(false)
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)
})
})
const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined) const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering()) const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering())
const setHoverProject = (value: string | undefined) => { const setHoverProject = (value: string | undefined) => {
@@ -208,54 +186,12 @@ export default function Layout(props: ParentProps) {
const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined)) const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined))
const setHoverSession = (id: string | undefined) => setState("hoverSession", id) const setHoverSession = (id: string | undefined) => setState("hoverSession", id)
const disarm = () => {
if (navLeave.current === undefined) return
clearTimeout(navLeave.current)
navLeave.current = undefined
}
const arm = () => {
if (layout.sidebar.opened()) return
if (state.hoverProject === undefined) return
disarm()
navLeave.current = window.setTimeout(() => {
navLeave.current = undefined
setHoverProject(undefined)
setState("hoverSession", undefined)
}, 300)
}
const [peek, setPeek] = createSignal<LocalProject | undefined>(undefined)
const [peeked, setPeeked] = createSignal(false)
let peekt: number | undefined
const hoverProjectData = createMemo(() => { const hoverProjectData = createMemo(() => {
const id = state.hoverProject const id = state.hoverProject
if (!id) return if (!id) return
return layout.projects.list().find((project) => project.worktree === id) return layout.projects.list().find((project) => project.worktree === id)
}) })
createEffect(() => {
const p = hoverProjectData()
if (p) {
if (peekt !== undefined) {
clearTimeout(peekt)
peekt = undefined
}
setPeek(p)
setPeeked(true)
return
}
setPeeked(false)
if (peek() === undefined) return
if (peekt !== undefined) clearTimeout(peekt)
peekt = window.setTimeout(() => {
peekt = undefined
setPeek(undefined)
}, 180)
})
createEffect(() => { createEffect(() => {
if (!layout.sidebar.opened()) return if (!layout.sidebar.opened()) return
setHoverProject(undefined) setHoverProject(undefined)
@@ -545,6 +481,21 @@ export default function Layout(props: ParentProps) {
return projects.find((p) => p.worktree === root) return projects.find((p) => p.worktree === root)
}) })
createEffect(
on(
() => ({ ready: pageReady(), project: currentProject() }),
(value) => {
if (!value.ready) return
const project = value.project
if (!project) return
const last = server.projects.last()
if (last === project.worktree) return
server.projects.touch(project.worktree)
},
{ defer: true },
),
)
createEffect( createEffect(
on( on(
() => ({ ready: pageReady(), layoutReady: layoutReady(), dir: params.dir, list: layout.projects.list() }), () => ({ ready: pageReady(), layoutReady: layoutReady(), dir: params.dir, list: layout.projects.list() }),
@@ -603,17 +554,29 @@ export default function Layout(props: ParentProps) {
return layout.sidebar.workspaces(project.worktree)() return layout.sidebar.workspaces(project.worktree)()
}) })
const visibleSessionDirs = createMemo(() => { createEffect(() => {
if (!pageReady()) return
if (!layoutReady()) return
const project = currentProject() const project = currentProject()
if (!project) return [] as string[] if (!project) return
if (!workspaceSetting()) return [project.worktree]
const activeDir = currentDir() const local = project.worktree
return workspaceIds(project).filter((directory) => { const dirs = [project.worktree, ...(project.sandboxes ?? [])]
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree const existing = store.workspaceOrder[project.worktree]
const active = directory === activeDir const merged = syncWorkspaceOrder(local, dirs, existing)
return expanded || active if (!existing) {
}) setStore("workspaceOrder", project.worktree, merged)
return
}
if (merged.length !== existing.length) {
setStore("workspaceOrder", project.worktree, merged)
return
}
if (merged.some((d, i) => d !== existing[i])) {
setStore("workspaceOrder", project.worktree, merged)
}
}) })
createEffect(() => { createEffect(() => {
@@ -630,17 +593,25 @@ export default function Layout(props: ParentProps) {
}) })
const currentSessions = createMemo(() => { const currentSessions = createMemo(() => {
const project = currentProject()
if (!project) return [] as Session[]
const now = Date.now() const now = Date.now()
const dirs = visibleSessionDirs() if (workspaceSetting()) {
if (dirs.length === 0) return [] as Session[] const dirs = workspaceIds(project)
const activeDir = currentDir()
const result: Session[] = [] const result: Session[] = []
for (const dir of dirs) { for (const dir of dirs) {
const [dirStore] = globalSync.child(dir, { bootstrap: true }) const expanded = store.workspaceExpanded[dir] ?? dir === project.worktree
const dirSessions = sortedRootSessions(dirStore, now) const active = dir === activeDir
result.push(...dirSessions) if (!expanded && !active) continue
const [dirStore] = globalSync.child(dir, { bootstrap: true })
const dirSessions = sortedRootSessions(dirStore, now)
result.push(...dirSessions)
}
return result
} }
return result const [projectStore] = globalSync.child(project.worktree)
return sortedRootSessions(projectStore, now)
}) })
type PrefetchQueue = { type PrefetchQueue = {
@@ -855,6 +826,7 @@ export default function Layout(props: ParentProps) {
} }
navigateToSession(session) navigateToSession(session)
queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
} }
function navigateSessionByUnseen(offset: number) { function navigateSessionByUnseen(offset: number) {
@@ -889,6 +861,7 @@ export default function Layout(props: ParentProps) {
} }
navigateToSession(session) navigateToSession(session)
queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
return return
} }
} }
@@ -1121,96 +1094,34 @@ export default function Layout(props: ParentProps) {
return meta?.worktree ?? directory return meta?.worktree ?? directory
} }
function activeProjectRoot(directory: string) {
return currentProject()?.worktree ?? projectRoot(directory)
}
function touchProjectRoute() {
const root = currentProject()?.worktree
if (!root) return
if (server.projects.last() !== root) server.projects.touch(root)
return root
}
function rememberSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
setStore("lastProjectSession", root, { directory, id, at: Date.now() })
return root
}
function clearLastProjectSession(root: string) {
if (!store.lastProjectSession[root]) return
setStore(
"lastProjectSession",
produce((draft) => {
delete draft[root]
}),
)
}
function syncSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
rememberSessionRoute(directory, id, root)
notification.session.markViewed(id)
const expanded = untrack(() => store.workspaceExpanded[directory])
if (expanded === false) {
setStore("workspaceExpanded", directory, true)
}
requestAnimationFrame(() => scrollToSession(id, `${directory}:${id}`))
return root
}
async function navigateToProject(directory: string | undefined) { async function navigateToProject(directory: string | undefined) {
if (!directory) return if (!directory) return
const root = projectRoot(directory) const root = projectRoot(directory)
server.projects.touch(root) server.projects.touch(root)
const project = layout.projects.list().find((item) => item.worktree === root) const project = layout.projects.list().find((item) => item.worktree === root)
let dirs = project const dirs = Array.from(new Set([root, ...(store.workspaceOrder[root] ?? []), ...(project?.sandboxes ?? [])]))
? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
: [root]
const canOpen = (value: string | undefined) => {
if (!value) return false
return dirs.some((item) => workspaceKey(item) === workspaceKey(value))
}
const refreshDirs = async (target?: string) => {
if (!target || target === root || canOpen(target)) return canOpen(target)
const listed = await globalSDK.client.worktree
.list({ directory: root })
.then((x) => x.data ?? [])
.catch(() => [] as string[])
dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
return canOpen(target)
}
const openSession = async (target: { directory: string; id: string }) => { const openSession = async (target: { directory: string; id: string }) => {
if (!canOpen(target.directory)) return false
const [data] = globalSync.child(target.directory, { bootstrap: false })
if (data.session.some((item) => item.id === target.id)) {
setStore("lastProjectSession", root, { directory: target.directory, id: target.id, at: Date.now() })
navigateWithSidebarReset(`/${base64Encode(target.directory)}/session/${target.id}`)
return true
}
const resolved = await globalSDK.client.session const resolved = await globalSDK.client.session
.get({ sessionID: target.id }) .get({ sessionID: target.id })
.then((x) => x.data) .then((x) => x.data)
.catch(() => undefined) .catch(() => undefined)
if (!resolved?.directory) return false const next = resolved?.directory ? resolved : target
if (!canOpen(resolved.directory)) return false setStore("lastProjectSession", root, { directory: next.directory, id: next.id, at: Date.now() })
setStore("lastProjectSession", root, { directory: resolved.directory, id: resolved.id, at: Date.now() }) navigateWithSidebarReset(`/${base64Encode(next.directory)}/session/${next.id}`)
navigateWithSidebarReset(`/${base64Encode(resolved.directory)}/session/${resolved.id}`)
return true
} }
const projectSession = store.lastProjectSession[root] const projectSession = store.lastProjectSession[root]
if (projectSession?.id) { if (projectSession?.id) {
await refreshDirs(projectSession.directory) await openSession(projectSession)
const opened = await openSession(projectSession) return
if (opened) return
clearLastProjectSession(root)
} }
const latest = latestRootSession( const latest = latestRootSession(
dirs.map((item) => globalSync.child(item, { bootstrap: false })[0]), dirs.map((item) => globalSync.child(item, { bootstrap: false })[0]),
Date.now(), Date.now(),
) )
if (latest && (await openSession(latest))) { if (latest) {
await openSession(latest)
return return
} }
@@ -1226,7 +1137,8 @@ export default function Layout(props: ParentProps) {
), ),
Date.now(), Date.now(),
) )
if (fetched && (await openSession(fetched))) { if (fetched) {
await openSession(fetched)
return return
} }
@@ -1245,20 +1157,9 @@ export default function Layout(props: ParentProps) {
const handleDeepLinks = (urls: string[]) => { const handleDeepLinks = (urls: string[]) => {
if (!server.isLocal()) return if (!server.isLocal()) return
for (const directory of collectOpenProjectDeepLinks(urls)) { for (const directory of collectOpenProjectDeepLinks(urls)) {
openProject(directory) openProject(directory)
} }
for (const link of collectNewSessionDeepLinks(urls)) {
openProject(link.directory, false)
const slug = base64Encode(link.directory)
if (link.prompt) {
setSessionHandoff(slug, { prompt: link.prompt })
}
const href = link.prompt ? `/${slug}/session?prompt=${encodeURIComponent(link.prompt)}` : `/${slug}/session`
navigateWithSidebarReset(href)
}
} }
onMount(() => { onMount(() => {
@@ -1294,28 +1195,11 @@ export default function Layout(props: ParentProps) {
} }
function closeProject(directory: string) { function closeProject(directory: string) {
const list = layout.projects.list() const index = layout.projects.list().findIndex((x) => x.worktree === directory)
const index = list.findIndex((x) => x.worktree === directory) const next = layout.projects.list()[index + 1]
const active = currentProject()?.worktree === directory
if (index === -1) return
const next = list[index + 1]
if (!active) {
layout.projects.close(directory)
return
}
if (!next) {
layout.projects.close(directory)
navigate("/")
return
}
navigateWithSidebarReset(`/${base64Encode(next.worktree)}/session`)
layout.projects.close(directory) layout.projects.close(directory)
queueMicrotask(() => { if (next) navigateToProject(next.worktree)
void navigateToProject(next.worktree) else navigate("/")
})
} }
function toggleProjectWorkspaces(project: LocalProject) { function toggleProjectWorkspaces(project: LocalProject) {
@@ -1356,17 +1240,9 @@ export default function Layout(props: ParentProps) {
} }
} }
const deleteWorkspace = async (root: string, directory: string, leaveDeletedWorkspace = false) => { const deleteWorkspace = async (root: string, directory: string) => {
if (directory === root) return if (directory === root) return
const current = currentDir()
const currentKey = workspaceKey(current)
const deletedKey = workspaceKey(directory)
const shouldLeave = leaveDeletedWorkspace || (!!params.dir && currentKey === deletedKey)
if (!leaveDeletedWorkspace && shouldLeave) {
navigateWithSidebarReset(`/${base64Encode(root)}/session`)
}
setBusy(directory, true) setBusy(directory, true)
const result = await globalSDK.client.worktree const result = await globalSDK.client.worktree
@@ -1384,10 +1260,6 @@ export default function Layout(props: ParentProps) {
if (!result) return if (!result) return
if (workspaceKey(store.lastProjectSession[root]?.directory ?? "") === workspaceKey(directory)) {
clearLastProjectSession(root)
}
globalSync.set( globalSync.set(
"project", "project",
produce((draft) => { produce((draft) => {
@@ -1401,18 +1273,8 @@ export default function Layout(props: ParentProps) {
layout.projects.close(directory) layout.projects.close(directory)
layout.projects.open(root) layout.projects.open(root)
if (shouldLeave) return if (params.dir && currentDir() === directory) {
navigateToProject(root)
const nextCurrent = currentDir()
const nextKey = workspaceKey(nextCurrent)
const project = layout.projects.list().find((item) => item.worktree === root)
const dirs = project
? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
: [root]
const valid = dirs.some((item) => workspaceKey(item) === nextKey)
if (params.dir && projectRoot(nextCurrent) === root && !valid) {
navigateWithSidebarReset(`/${base64Encode(root)}/session`)
} }
} }
@@ -1515,12 +1377,8 @@ export default function Layout(props: ParentProps) {
}) })
const handleDelete = () => { const handleDelete = () => {
const leaveDeletedWorkspace = !!params.dir && workspaceKey(currentDir()) === workspaceKey(props.directory)
if (leaveDeletedWorkspace) {
navigateWithSidebarReset(`/${base64Encode(props.root)}/session`)
}
dialog.close() dialog.close()
void deleteWorkspace(props.root, props.directory, leaveDeletedWorkspace) void deleteWorkspace(props.root, props.directory)
} }
const description = () => { const description = () => {
@@ -1628,42 +1486,26 @@ export default function Layout(props: ParentProps) {
) )
} }
const activeRoute = {
session: "",
sessionProject: "",
}
createEffect( createEffect(
on( on(
() => [pageReady(), params.dir, params.id, currentProject()?.worktree] as const, () => ({ ready: pageReady(), dir: params.dir, id: params.id }),
([ready, dir, id]) => { (value) => {
if (!ready || !dir) { if (!value.ready) return
activeRoute.session = "" const dir = value.dir
activeRoute.sessionProject = "" const id = value.id
return if (!dir || !id) return
}
const directory = decode64(dir) const directory = decode64(dir)
if (!directory) return if (!directory) return
const at = Date.now()
const root = touchProjectRoute() ?? activeProjectRoot(directory) setStore("lastProjectSession", projectRoot(directory), { directory, id, at })
notification.session.markViewed(id)
if (!id) { const expanded = untrack(() => store.workspaceExpanded[directory])
activeRoute.session = "" if (expanded === false) {
activeRoute.sessionProject = "" setStore("workspaceExpanded", directory, true)
return
} }
requestAnimationFrame(() => scrollToSession(id, `${directory}:${id}`))
const session = `${dir}/${id}`
if (session !== activeRoute.session) {
activeRoute.session = session
activeRoute.sessionProject = syncSessionRoute(directory, id, root)
return
}
if (root === activeRoute.sessionProject) return
activeRoute.sessionProject = rememberSessionRoute(directory, id, root)
}, },
{ defer: true },
), ),
) )
@@ -1674,29 +1516,40 @@ export default function Layout(props: ParentProps) {
const loadedSessionDirs = new Set<string>() const loadedSessionDirs = new Set<string>()
createEffect( createEffect(() => {
on( const project = currentProject()
visibleSessionDirs, const workspaces = workspaceSetting()
(dirs) => { const next = new Set<string>()
if (dirs.length === 0) { if (!project) {
loadedSessionDirs.clear() loadedSessionDirs.clear()
return return
} }
const next = new Set(dirs) if (workspaces) {
for (const directory of next) { const activeDir = currentDir()
if (loadedSessionDirs.has(directory)) continue const dirs = [project.worktree, ...(project.sandboxes ?? [])]
globalSync.project.loadSessions(directory) for (const directory of dirs) {
} const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
const active = directory === activeDir
if (!expanded && !active) continue
next.add(directory)
}
}
loadedSessionDirs.clear() if (!workspaces) {
for (const directory of next) { next.add(project.worktree)
loadedSessionDirs.add(directory) }
}
}, for (const directory of next) {
{ defer: true }, if (loadedSessionDirs.has(directory)) continue
), globalSync.project.loadSessions(directory)
) }
loadedSessionDirs.clear()
for (const directory of next) {
loadedSessionDirs.add(directory)
}
})
function handleDragStart(event: unknown) { function handleDragStart(event: unknown) {
const id = getDraggableId(event) const id = getDraggableId(event)
@@ -1730,11 +1583,14 @@ export default function Layout(props: ParentProps) {
const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined
const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree]) const existing = store.workspaceOrder[project.worktree]
if (pending && extra) return [local, extra, ...ordered.filter((item) => item !== local)] if (!existing) return extra ? [...dirs, extra] : dirs
if (!extra) return ordered
if (pending) return ordered const merged = syncWorkspaceOrder(local, dirs, existing)
return [...ordered, extra] if (pending && extra) return [local, extra, ...merged.filter((directory) => directory !== local)]
if (!extra) return merged
if (pending) return merged
return [...merged, extra]
} }
const sidebarProject = createMemo(() => { const sidebarProject = createMemo(() => {
@@ -1767,11 +1623,7 @@ export default function Layout(props: ParentProps) {
const [item] = result.splice(fromIndex, 1) const [item] = result.splice(fromIndex, 1)
if (!item) return if (!item) return
result.splice(toIndex, 0, item) result.splice(toIndex, 0, item)
setStore( setStore("workspaceOrder", project.worktree, result)
"workspaceOrder",
project.worktree,
result.filter((directory) => workspaceKey(directory) !== workspaceKey(project.worktree)),
)
} }
function handleWorkspaceDragEnd() { function handleWorkspaceDragEnd() {
@@ -1809,9 +1661,10 @@ export default function Layout(props: ParentProps) {
const existing = prev ?? [] const existing = prev ?? []
const next = existing.filter((item) => { const next = existing.filter((item) => {
const id = workspaceKey(item) const id = workspaceKey(item)
return id !== root && id !== key if (id === root) return false
return id !== key
}) })
return [created.directory, ...next] return [local, created.directory, ...next]
}) })
globalSync.child(created.directory) globalSync.child(created.directory)
@@ -1877,8 +1730,7 @@ export default function Layout(props: ParentProps) {
setHoverSession, setHoverSession,
} }
const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean; merged?: boolean }) => { const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean }) => {
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
const projectName = createMemo(() => { const projectName = createMemo(() => {
const project = panelProps.project const project = panelProps.project
if (!project) return "" if (!project) return ""
@@ -1904,17 +1756,10 @@ export default function Layout(props: ParentProps) {
return ( return (
<div <div
classList={{ classList={{
"flex flex-col min-h-0 min-w-0 rounded-tl-[12px] px-2": true, "flex flex-col min-h-0 bg-background-stronger border border-b-0 border-border-weak-base rounded-tl-[12px]": true,
"border border-b-0 border-border-weak-base": !merged(),
"border-l border-t border-border-weaker-base": merged(),
"bg-background-base": merged(),
"bg-background-stronger": !merged(),
"flex-1 min-w-0": panelProps.mobile, "flex-1 min-w-0": panelProps.mobile,
"max-w-full overflow-hidden": panelProps.mobile,
}}
style={{
width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`,
}} }}
style={{ width: panelProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }}
> >
<Show when={panelProps.project}> <Show when={panelProps.project}>
{(p) => ( {(p) => (
@@ -1960,7 +1805,7 @@ export default function Layout(props: ParentProps) {
}} }}
aria-label={language.t("common.moreOptions")} aria-label={language.t("common.moreOptions")}
/> />
<DropdownMenu.Portal> <DropdownMenu.Portal mount={!panelProps.mobile ? state.nav : undefined}>
<DropdownMenu.Content class="mt-1"> <DropdownMenu.Content class="mt-1">
<DropdownMenu.Item onSelect={() => showEditProjectDialog(p())}> <DropdownMenu.Item onSelect={() => showEditProjectDialog(p())}>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
@@ -2007,14 +1852,20 @@ export default function Layout(props: ParentProps) {
fallback={ fallback={
<> <>
<div class="shrink-0 py-4 px-3"> <div class="shrink-0 py-4 px-3">
<Button <TooltipKeybind
size="large" title={language.t("command.session.new")}
icon="plus-small" keybind={command.keybind("session.new")}
class="w-full" placement="top"
onClick={() => navigateWithSidebarReset(`/${base64Encode(p().worktree)}/session`)}
> >
{language.t("command.session.new")} <Button
</Button> size="large"
icon="plus-small"
class="w-full"
onClick={() => navigateWithSidebarReset(`/${base64Encode(p().worktree)}/session`)}
>
{language.t("command.session.new")}
</Button>
</TooltipKeybind>
</div> </div>
<div class="flex-1 min-h-0"> <div class="flex-1 min-h-0">
<LocalWorkspace <LocalWorkspace
@@ -2029,9 +1880,15 @@ export default function Layout(props: ParentProps) {
> >
<> <>
<div class="shrink-0 py-4 px-3"> <div class="shrink-0 py-4 px-3">
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p())}> <TooltipKeybind
{language.t("workspace.new")} title={language.t("workspace.new")}
</Button> keybind={command.keybind("workspace.new")}
placement="top"
>
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p())}>
{language.t("workspace.new")}
</Button>
</TooltipKeybind>
</div> </div>
<div class="relative flex-1 min-h-0"> <div class="relative flex-1 min-h-0">
<DragDropProvider <DragDropProvider
@@ -2079,31 +1936,25 @@ export default function Layout(props: ParentProps) {
</Show> </Show>
<div <div
class="shrink-0 px-3 py-3" class="shrink-0 px-2 py-3 border-t border-border-weak-base"
classList={{ classList={{
hidden: store.gettingStartedDismissed || !(providers.all().length > 0 && providers.paid().length === 0), hidden: !(providers.all().length > 0 && providers.paid().length === 0),
}} }}
> >
<div class="rounded-xl bg-background-base shadow-xs-border-base" data-component="getting-started"> <div class="rounded-md bg-background-base shadow-xs-border-base">
<div class="p-3 flex flex-col gap-6"> <div class="p-3 flex flex-col gap-2">
<div class="flex flex-col gap-2"> <div class="text-12-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
<div class="text-14-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div> <div class="text-text-base">{language.t("sidebar.gettingStarted.line1")}</div>
<div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}> <div class="text-text-base">{language.t("sidebar.gettingStarted.line2")}</div>
{language.t("sidebar.gettingStarted.line1")}
</div>
<div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
{language.t("sidebar.gettingStarted.line2")}
</div>
</div>
<div data-component="getting-started-actions">
<Button size="large" icon="plus-small" onClick={connectProvider}>
{language.t("command.provider.connect")}
</Button>
<Button size="large" variant="ghost" onClick={() => setStore("gettingStartedDismissed", true)}>
Not yet
</Button>
</div>
</div> </div>
<Button
class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-md rounded-t-none shadow-none border-t border-border-weak-base px-3"
size="large"
icon="plus"
onClick={connectProvider}
>
{language.t("command.provider.connect")}
</Button>
</div> </div>
</div> </div>
</div> </div>
@@ -2113,27 +1964,33 @@ export default function Layout(props: ParentProps) {
return ( return (
<div class="relative bg-background-base flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"> <div class="relative bg-background-base flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
<Titlebar /> <Titlebar />
<div class="flex-1 min-h-0 relative overflow-x-hidden"> <div class="flex-1 min-h-0 flex">
<nav <nav
aria-label={language.t("sidebar.nav.projectsAndSessions")} aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-desktop" data-component="sidebar-nav-desktop"
classList={{ classList={{
"hidden xl:block": true, "hidden xl:block": true,
"absolute inset-y-0 left-0": true, "relative shrink-0": true,
"z-10": true,
}} }}
style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }} style={{ width: layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "64px" }}
ref={(el) => { ref={(el) => {
setState("nav", el) setState("nav", el)
}} }}
onMouseEnter={() => { onMouseEnter={() => {
disarm() if (navLeave.current === undefined) return
clearTimeout(navLeave.current)
navLeave.current = undefined
}} }}
onMouseLeave={() => { onMouseLeave={() => {
aim.reset() aim.reset()
if (!sidebarHovering()) return if (!sidebarHovering()) return
arm() if (navLeave.current !== undefined) clearTimeout(navLeave.current)
navLeave.current = window.setTimeout(() => {
navLeave.current = undefined
setHoverProject(undefined)
setState("hoverSession", undefined)
}, 300)
}} }}
> >
<div class="@container w-full h-full contain-strict"> <div class="@container w-full h-full contain-strict">
@@ -2158,38 +2015,28 @@ export default function Layout(props: ParentProps) {
onOpenSettings={openSettings} onOpenSettings={openSettings}
helpLabel={() => language.t("sidebar.help")} helpLabel={() => language.t("sidebar.help")}
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")} onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
renderPanel={() => ( renderPanel={() => <SidebarPanel project={currentProject()} />}
<Show when={currentProject()} keyed>
{(project) => <SidebarPanel project={project} merged />}
</Show>
)}
/> />
</div> </div>
<Show when={!layout.sidebar.opened() ? hoverProjectData()?.worktree : undefined} keyed>
{(worktree) => (
<div class="absolute inset-y-0 left-16 z-50 flex" onMouseEnter={aim.reset}>
<SidebarPanel project={hoverProjectData()} />
</div>
)}
</Show>
<Show when={layout.sidebar.opened()}> <Show when={layout.sidebar.opened()}>
<div onPointerDown={() => setSizing(true)}> <ResizeHandle
<ResizeHandle direction="horizontal"
direction="horizontal" size={layout.sidebar.width()}
size={layout.sidebar.width()} min={244}
min={244} max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64} collapseThreshold={244}
collapseThreshold={244} onResize={layout.sidebar.resize}
onResize={(w) => { onCollapse={layout.sidebar.close}
setSizing(true) />
if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setSizing(false), 120)
layout.sidebar.resize(w)
}}
onCollapse={layout.sidebar.close}
/>
</div>
</Show> </Show>
</nav> </nav>
<div
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
style={{ left: "calc(4rem + 12px)" }}
/>
<div class="xl:hidden"> <div class="xl:hidden">
<div <div
classList={{ classList={{
@@ -2205,7 +2052,7 @@ export default function Layout(props: ParentProps) {
aria-label={language.t("sidebar.nav.projectsAndSessions")} aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-mobile" data-component="sidebar-nav-mobile"
classList={{ classList={{
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true, "@container fixed top-10 bottom-0 left-0 z-50 w-72 bg-background-base transition-transform duration-200 ease-out": true,
"translate-x-0": layout.mobileSidebar.opened(), "translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(), "-translate-x-full": !layout.mobileSidebar.opened(),
}} }}
@@ -2238,66 +2085,16 @@ export default function Layout(props: ParentProps) {
</nav> </nav>
</div> </div>
<div <main
classList={{ classList={{
"absolute inset-0": true, "size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base": true,
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true, "xl:border-l xl:rounded-tl-[12px]": !layout.sidebar.opened(),
"z-20": true,
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
!sizing(),
}}
style={{
"--main-left": layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "4rem",
}} }}
> >
<main <Show when={!autoselecting()} fallback={<div class="size-full" />}>
classList={{ {props.children}
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base xl:border-l xl:rounded-tl-[12px]": true,
}}
>
<Show when={!autoselecting()} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>
</div>
<div
classList={{
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
"opacity-100 translate-x-0 pointer-events-auto": peeked() && !layout.sidebar.opened(),
"opacity-0 -translate-x-2 pointer-events-none": !peeked() || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": peeked() && !layout.sidebar.opened(),
"duration-120 ease-in": !peeked() || layout.sidebar.opened(),
}}
onMouseMove={disarm}
onMouseEnter={() => {
disarm()
aim.reset()
}}
onPointerDown={disarm}
onMouseLeave={() => {
arm()
}}
>
<Show when={peek()} keyed>
{(project) => <SidebarPanel project={project} merged={false} />}
</Show> </Show>
</div> </main>
<div
classList={{
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
"opacity-100 translate-x-0": peeked() && !layout.sidebar.opened(),
"opacity-0 -translate-x-2": !peeked() || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": peeked() && !layout.sidebar.opened(),
"duration-120 ease-in": !peeked() || layout.sidebar.opened(),
}}
style={{ left: `calc(4rem + ${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px)` }}
>
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
</div>
</div> </div>
<Toast.Region /> <Toast.Region />
</div> </div>
+8 -24
View File
@@ -1,17 +1,15 @@
export const deepLinkEvent = "opencode:deep-link" export const deepLinkEvent = "opencode:deep-link"
const parseUrl = (input: string) => { export const parseDeepLink = (input: string) => {
if (!input.startsWith("opencode://")) return if (!input.startsWith("opencode://")) return
if (typeof URL.canParse === "function" && !URL.canParse(input)) return if (typeof URL.canParse === "function" && !URL.canParse(input)) return
try { const url = (() => {
return new URL(input) try {
} catch { return new URL(input)
return } catch {
} return undefined
} }
})()
export const parseDeepLink = (input: string) => {
const url = parseUrl(input)
if (!url) return if (!url) return
if (url.hostname !== "open-project") return if (url.hostname !== "open-project") return
const directory = url.searchParams.get("directory") const directory = url.searchParams.get("directory")
@@ -19,23 +17,9 @@ export const parseDeepLink = (input: string) => {
return directory return directory
} }
export const parseNewSessionDeepLink = (input: string) => {
const url = parseUrl(input)
if (!url) return
if (url.hostname !== "new-session") return
const directory = url.searchParams.get("directory")
if (!directory) return
const prompt = url.searchParams.get("prompt") || undefined
if (!prompt) return { directory }
return { directory, prompt }
}
export const collectOpenProjectDeepLinks = (urls: string[]) => export const collectOpenProjectDeepLinks = (urls: string[]) =>
urls.map(parseDeepLink).filter((directory): directory is string => !!directory) urls.map(parseDeepLink).filter((directory): directory is string => !!directory)
export const collectNewSessionDeepLinks = (urls: string[]) =>
urls.map(parseNewSessionDeepLink).filter((link): link is { directory: string; prompt?: string } => !!link)
type OpenCodeWindow = Window & { type OpenCodeWindow = Window & {
__OPENCODE__?: { __OPENCODE__?: {
deepLinks?: string[] deepLinks?: string[]
+10 -31
View File
@@ -1,14 +1,15 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import {
collectNewSessionDeepLinks,
collectOpenProjectDeepLinks,
drainPendingDeepLinks,
parseDeepLink,
parseNewSessionDeepLink,
} from "./deep-links"
import { displayName, errorMessage, getDraggableId, syncWorkspaceOrder, workspaceKey } from "./helpers"
import { type Session } from "@opencode-ai/sdk/v2/client" import { type Session } from "@opencode-ai/sdk/v2/client"
import { hasProjectPermissions, latestRootSession } from "./helpers" import { collectOpenProjectDeepLinks, drainPendingDeepLinks, parseDeepLink } from "./deep-links"
import {
displayName,
errorMessage,
getDraggableId,
hasProjectPermissions,
latestRootSession,
syncWorkspaceOrder,
workspaceKey,
} from "./helpers"
const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) => const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
({ ({
@@ -61,28 +62,6 @@ describe("layout deep links", () => {
expect(result).toEqual(["/a", "/c"]) expect(result).toEqual(["/a", "/c"])
}) })
test("parses new-session deep links with optional prompt", () => {
expect(parseNewSessionDeepLink("opencode://new-session?directory=/tmp/demo")).toEqual({ directory: "/tmp/demo" })
expect(parseNewSessionDeepLink("opencode://new-session?directory=/tmp/demo&prompt=hello%20world")).toEqual({
directory: "/tmp/demo",
prompt: "hello world",
})
})
test("ignores new-session deep links without directory", () => {
expect(parseNewSessionDeepLink("opencode://new-session")).toBeUndefined()
expect(parseNewSessionDeepLink("opencode://new-session?directory=")).toBeUndefined()
})
test("collects only valid new-session deep links", () => {
const result = collectNewSessionDeepLinks([
"opencode://new-session?directory=/a",
"opencode://open-project?directory=/b",
"opencode://new-session?directory=/c&prompt=ship%20it",
])
expect(result).toEqual([{ directory: "/a" }, { directory: "/c", prompt: "ship it" }])
})
test("drains global deep links once", () => { test("drains global deep links once", () => {
const target = { const target = {
__OPENCODE__: { __OPENCODE__: {
+5 -25
View File
@@ -74,29 +74,9 @@ export const errorMessage = (err: unknown, fallback: string) => {
return fallback return fallback
} }
export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted?: string[]) => { export const syncWorkspaceOrder = (local: string, dirs: string[], existing?: string[]) => {
const root = workspaceKey(local) if (!existing) return dirs
const live = new Map<string, string>() const keep = existing.filter((d) => d !== local && dirs.includes(d))
const missing = dirs.filter((d) => d !== local && !existing.includes(d))
for (const dir of dirs) { return [local, ...missing, ...keep]
const key = workspaceKey(dir)
if (key === root) continue
if (!live.has(key)) live.set(key, dir)
}
if (!persisted?.length) return [local, ...live.values()]
const result = [local]
for (const dir of persisted) {
const key = workspaceKey(dir)
if (key === root) continue
const match = live.get(key)
if (!match) continue
result.push(match)
live.delete(key)
}
return [...result, ...live.values()]
} }
export const syncWorkspaceOrder = effectiveWorkspaceOrder
+26 -17
View File
@@ -1,23 +1,24 @@
import type { Message, Session, TextPart, UserMessage } from "@opencode-ai/sdk/v2/client" import { A, useNavigate, useParams } from "@solidjs/router"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useLayout, type LocalProject, getAvatarColors } from "@/context/layout"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { base64Encode } from "@opencode-ai/util/encode"
import { Avatar } from "@opencode-ai/ui/avatar" import { Avatar } from "@opencode-ai/ui/avatar"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { HoverCard } from "@opencode-ai/ui/hover-card" import { HoverCard } from "@opencode-ai/ui/hover-card"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { MessageNav } from "@opencode-ai/ui/message-nav" import { MessageNav } from "@opencode-ai/ui/message-nav"
import { Spinner } from "@opencode-ai/ui/spinner" import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { base64Encode } from "@opencode-ai/util/encode"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
import { A, useNavigate, useParams } from "@solidjs/router" import { type Message, type Session, type TextPart, type UserMessage } from "@opencode-ai/sdk/v2/client"
import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js" import { For, Match, Show, Switch, createMemo, onCleanup, type Accessor, type JSX } from "solid-js"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { agentColor } from "@/utils/agent" import { agentColor } from "@/utils/agent"
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
import { hasProjectPermissions } from "./helpers" import { hasProjectPermissions } from "./helpers"
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
@@ -136,6 +137,13 @@ const SessionRow = (props: {
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"> <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
{props.session.title} {props.session.title}
</span> </span>
<Show when={props.session.summary}>
{(summary) => (
<div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
<DiffChanges changes={summary()} />
</div>
)}
</Show>
</div> </div>
</A> </A>
) )
@@ -163,6 +171,7 @@ const SessionHoverPreview = (props: {
gutter={16} gutter={16}
shift={-2} shift={-2}
trigger={props.trigger} trigger={props.trigger}
mount={!props.mobile ? props.nav() : undefined}
open={props.hoverSession() === props.session.id} open={props.hoverSession() === props.session.id}
onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)} onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)}
> >
@@ -230,9 +239,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed()) const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
const isActive = createMemo(() => props.session.id === params.id) const isActive = createMemo(() => props.session.id === params.id)
const hoverPrefetch = { const hoverPrefetch = { current: undefined as ReturnType<typeof setTimeout> | undefined }
current: undefined as ReturnType<typeof setTimeout> | undefined,
}
const cancelHoverPrefetch = () => { const cancelHoverPrefetch = () => {
if (hoverPrefetch.current === undefined) return if (hoverPrefetch.current === undefined) return
clearTimeout(hoverPrefetch.current) clearTimeout(hoverPrefetch.current)
@@ -301,15 +308,17 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
setHoverSession={props.setHoverSession} setHoverSession={props.setHoverSession}
messageLabel={messageLabel} messageLabel={messageLabel}
onMessageSelect={(message) => { onMessageSelect={(message) => {
if (!isActive()) if (!isActive()) {
layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id) layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id)
navigate(`${props.slug}/session/${props.session.id}`)
navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`) return
}
window.history.replaceState(null, "", `#message-${message.id}`)
window.dispatchEvent(new HashChangeEvent("hashchange"))
}} }}
trigger={item} trigger={item}
/> />
</Show> </Show>
<div <div
class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`} class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`}
classList={{ classList={{
@@ -137,7 +137,7 @@ const ProjectTile = (props: {
> >
<ProjectIcon project={props.project} notify /> <ProjectIcon project={props.project} notify />
</ContextMenu.Trigger> </ContextMenu.Trigger>
<ContextMenu.Portal> <ContextMenu.Portal mount={!props.mobile ? props.nav() : undefined}>
<ContextMenu.Content> <ContextMenu.Content>
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}> <ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel> <ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
@@ -1,4 +1,4 @@
import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js" import { createMemo, For, Show, type Accessor, type JSX } from "solid-js"
import { import {
DragDropProvider, DragDropProvider,
DragDropSensors, DragDropSensors,
@@ -35,22 +35,10 @@ export const SidebarContent = (props: {
}): JSX.Element => { }): JSX.Element => {
const expanded = createMemo(() => sidebarExpanded(props.mobile, props.opened())) const expanded = createMemo(() => sidebarExpanded(props.mobile, props.opened()))
const placement = () => (props.mobile ? "bottom" : "right") const placement = () => (props.mobile ? "bottom" : "right")
let panel: HTMLDivElement | undefined
createEffect(() => {
const el = panel
if (!el) return
if (expanded()) {
el.removeAttribute("inert")
return
}
el.setAttribute("inert", "")
})
return ( return (
<div class="flex h-full w-full min-w-0 overflow-hidden"> <div class="flex h-full w-full overflow-hidden">
<div <div
data-component="sidebar-rail"
class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden" class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden"
onMouseMove={props.aimMove} onMouseMove={props.aimMove}
> >
@@ -112,15 +100,7 @@ export const SidebarContent = (props: {
</div> </div>
</div> </div>
<div <Show when={expanded()}>{props.renderPanel()}</Show>
ref={(el) => {
panel = el
}}
classList={{ "flex h-full min-h-0 min-w-0 overflow-hidden": true, "pointer-events-none": !expanded() }}
aria-hidden={!expanded()}
>
{props.renderPanel()}
</div>
</div> </div>
) )
} }
@@ -182,7 +182,7 @@ const WorkspaceActions = (props: {
aria-label={props.language.t("common.moreOptions")} aria-label={props.language.t("common.moreOptions")}
/> />
</Tooltip> </Tooltip>
<DropdownMenu.Portal> <DropdownMenu.Portal mount={!props.mobile ? props.nav() : undefined}>
<DropdownMenu.Content <DropdownMenu.Content
onCloseAutoFocus={(event) => { onCloseAutoFocus={(event) => {
if (!props.pendingRename()) return if (!props.pendingRename()) return
@@ -249,7 +249,7 @@ const WorkspaceSessionList = (props: {
loadMore: () => Promise<void> loadMore: () => Promise<void>
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
}): JSX.Element => ( }): JSX.Element => (
<nav class="flex flex-col gap-1 px-3"> <nav class="flex flex-col gap-1 px-2">
<Show when={props.showNew()}> <Show when={props.showNew()}>
<NewSessionItem <NewSessionItem
slug={props.slug()} slug={props.slug()}
@@ -490,7 +490,7 @@ export const LocalWorkspace = (props: {
ref={(el) => props.ctx.setScrollContainerRef(el, props.mobile)} ref={(el) => props.ctx.setScrollContainerRef(el, props.mobile)}
class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]" class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]"
> >
<nav class="flex flex-col gap-1 px-3"> <nav class="flex flex-col gap-1 px-2">
<Show when={loading()}> <Show when={loading()}>
<SessionSkeleton /> <SessionSkeleton />
</Show> </Show>
+116 -211
View File
@@ -1,17 +1,4 @@
import type { Project, UserMessage } from "@opencode-ai/sdk/v2" import { onCleanup, Show, Match, Switch, createMemo, createEffect, on, onMount, untrack } from "solid-js"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import {
onCleanup,
Show,
Match,
Switch,
createMemo,
createEffect,
createComputed,
on,
onMount,
untrack,
} from "solid-js"
import { createMediaQuery } from "@solid-primitives/media" import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer" import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
@@ -20,30 +7,30 @@ import { createStore } from "solid-js/store"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Select } from "@opencode-ai/ui/select" import { Select } from "@opencode-ai/ui/select"
import { createAutoScroll } from "@opencode-ai/ui/hooks" import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { Button } from "@opencode-ai/ui/button" import { Mark } from "@opencode-ai/ui/logo"
import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode, checksum } from "@opencode-ai/util/encode"
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer" import { useLayout } from "@/context/layout"
import { checksum, base64Encode } from "@opencode-ai/util/encode"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useNavigate, useParams } from "@solidjs/router"
import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSDK } from "@/context/sdk"
import { usePrompt } from "@/context/prompt"
import { useComments } from "@/context/comments"
import { SessionHeader, NewSessionView } from "@/components/session"
import { same } from "@/utils/same"
import { createOpenReviewFile } from "@/pages/session/helpers" import { createOpenReviewFile } from "@/pages/session/helpers"
import { MessageTimeline } from "@/pages/session/message-timeline"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { createScrollSpy } from "@/pages/session/scroll-spy" import { createScrollSpy } from "@/pages/session/scroll-spy"
import { SessionReviewTab, type DiffStyle, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { TerminalPanel } from "@/pages/session/terminal-panel"
import { MessageTimeline } from "@/pages/session/message-timeline"
import { useSessionCommands } from "@/pages/session/use-session-commands"
import { SessionComposerRegion, createSessionComposerState } from "@/pages/session/composer"
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs" import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
import { SessionSidePanel } from "@/pages/session/session-side-panel" import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { TerminalPanel } from "@/pages/session/terminal-panel"
import { useSessionCommands } from "@/pages/session/use-session-commands"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
import { same } from "@/utils/same"
import { formatServerError } from "@/utils/server-errors"
const emptyUserMessages: UserMessage[] = [] const emptyUserMessages: UserMessage[] = []
@@ -255,7 +242,6 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
} }
export default function Page() { export default function Page() {
const globalSync = useGlobalSync()
const layout = useLayout() const layout = useLayout()
const local = useLocal() const local = useLocal()
const file = useFile() const file = useFile()
@@ -267,22 +253,8 @@ export default function Page() {
const sdk = useSDK() const sdk = useSDK()
const prompt = usePrompt() const prompt = usePrompt()
const comments = useComments() const comments = useComments()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
createEffect(() => {
if (!untrack(() => prompt.ready())) return
prompt.ready()
untrack(() => {
if (params.id || !prompt.ready()) return
const text = searchParams.prompt
if (!text) return
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
setSearchParams({ ...searchParams, prompt: undefined })
})
})
const [ui, setUi] = createStore({ const [ui, setUi] = createStore({
git: false,
pendingMessage: undefined as string | undefined, pendingMessage: undefined as string | undefined,
scrollGesture: 0, scrollGesture: 0,
scroll: { scroll: {
@@ -375,6 +347,24 @@ export default function Page() {
if (path) file.load(path) if (path) file.load(path)
}) })
createEffect(() => {
const current = tabs().all()
if (current.length === 0) return
const next = normalizeTabs(current)
if (same(current, next)) return
tabs().setAll(next)
const active = tabs().active()
if (!active) return
if (!active.startsWith("file://")) return
const normalized = normalizeTab(active)
if (active === normalized) return
tabs().setActive(normalized)
})
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
@@ -421,10 +411,7 @@ export default function Page() {
() => { () => {
const msg = lastUserMessage() const msg = lastUserMessage()
if (!msg) return if (!msg) return
if (msg.agent) { if (msg.agent) local.agent.set(msg.agent)
local.agent.set(msg.agent)
if (local.agent.current()?.model) return
}
if (msg.model) local.model.set(msg.model) if (msg.model) local.model.set(msg.model)
}, },
), ),
@@ -435,20 +422,8 @@ export default function Page() {
mobileTab: "session" as "session" | "changes", mobileTab: "session" as "session" | "changes",
changes: "session" as "session" | "turn", changes: "session" as "session" | "turn",
newSessionWorktree: "main", newSessionWorktree: "main",
deferRender: false,
}) })
createComputed((prev) => {
const key = sessionKey()
if (key !== prev) {
setStore("deferRender", true)
requestAnimationFrame(() => {
setTimeout(() => setStore("deferRender", false), 0)
})
}
return key
}, sessionKey())
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? []) const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs())) const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
@@ -495,51 +470,10 @@ export default function Page() {
}) })
const reviewEmptyKey = createMemo(() => { const reviewEmptyKey = createMemo(() => {
const project = sync.project const project = sync.project
if (project && !project.vcs) return "session.review.noVcs" if (!project || project.vcs) return "session.review.empty"
if (sync.data.config.snapshot === false) return "session.review.noSnapshot" return "session.review.noVcs"
return "session.review.empty"
}) })
function upsert(next: Project) {
const list = globalSync.data.project
sync.set("project", next.id)
const idx = list.findIndex((item) => item.id === next.id)
if (idx >= 0) {
globalSync.set(
"project",
list.map((item, i) => (i === idx ? { ...item, ...next } : item)),
)
return
}
const at = list.findIndex((item) => item.id > next.id)
if (at >= 0) {
globalSync.set("project", [...list.slice(0, at), next, ...list.slice(at)])
return
}
globalSync.set("project", [...list, next])
}
function initGit() {
if (ui.git) return
setUi("git", true)
void sdk.client.project
.initGit()
.then((x) => {
if (!x.data) return
upsert(x.data)
})
.catch((err) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: formatServerError(err, language.t),
})
})
.finally(() => {
setUi("git", false)
})
}
let inputRef!: HTMLDivElement let inputRef!: HTMLDivElement
let promptDock: HTMLDivElement | undefined let promptDock: HTMLDivElement | undefined
let dockHeight = 0 let dockHeight = 0
@@ -739,11 +673,7 @@ export default function Page() {
on( on(
sessionKey, sessionKey,
() => { () => {
setTree({ setTree({ reviewScroll: undefined, pendingDiff: undefined, activeDiff: undefined })
reviewScroll: undefined,
pendingDiff: undefined,
activeDiff: undefined,
})
}, },
{ defer: true }, { defer: true },
), ),
@@ -766,35 +696,29 @@ export default function Page() {
showAllFiles, showAllFiles,
tabForPath: file.tab, tabForPath: file.tab,
openTab: tabs().open, openTab: tabs().open,
setActive: tabs().setActive,
loadFile: file.load, loadFile: file.load,
}) })
const changesOptions = ["session", "turn"] as const const changesOptions = ["session", "turn"] as const
const changesOptionsList = [...changesOptions] const changesOptionsList = [...changesOptions]
const changesTitle = () => { const changesTitle = () => (
if (!hasReview()) { <Select
return null options={changesOptionsList}
} current={store.changes}
label={(option) =>
return ( option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
<Select }
options={changesOptionsList} onSelect={(option) => option && setStore("changes", option)}
current={store.changes} variant="ghost"
label={(option) => size="small"
option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn") valueClass="text-14-medium"
} />
onSelect={(option) => option && setStore("changes", option)} )
variant="ghost"
size="small"
valueClass="text-14-medium"
/>
)
}
const emptyTurn = () => ( const emptyTurn = () => (
<div class="h-full pb-30 flex flex-col items-center justify-center text-center gap-6"> <div class="h-full pb-30 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div> <div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
</div> </div>
) )
@@ -806,12 +730,35 @@ export default function Page() {
loadingClass: string loadingClass: string
emptyClass: string emptyClass: string
}) => ( }) => (
<Show when={!store.deferRender}> <Switch>
<Switch> <Match when={store.changes === "turn" && !!params.id}>
<Match when={store.changes === "turn" && !!params.id}> <SessionReviewTab
title={changesTitle()}
empty={emptyTurn()}
diffs={reviewDiffs}
view={view}
diffStyle={input.diffStyle}
onDiffStyleChange={input.onDiffStyleChange}
onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
onLineCommentUpdate={updateCommentInContext}
onLineCommentDelete={removeCommentFromContext}
lineCommentActions={reviewCommentActions()}
comments={comments.all()}
focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus}
onViewFile={openReviewFile}
classes={input.classes}
/>
</Match>
<Match when={hasReview()}>
<Show
when={diffsReady()}
fallback={<div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>}
>
<SessionReviewTab <SessionReviewTab
title={changesTitle()} title={changesTitle()}
empty={emptyTurn()}
diffs={reviewDiffs} diffs={reviewDiffs}
view={view} view={view}
diffStyle={input.diffStyle} diffStyle={input.diffStyle}
@@ -828,78 +775,39 @@ export default function Page() {
onViewFile={openReviewFile} onViewFile={openReviewFile}
classes={input.classes} classes={input.classes}
/> />
</Match> </Show>
<Match when={hasReview()}> </Match>
<Show <Match when={true}>
when={diffsReady()} <SessionReviewTab
fallback={<div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>} title={changesTitle()}
> empty={
<SessionReviewTab store.changes === "turn" ? (
title={changesTitle()} emptyTurn()
diffs={reviewDiffs} ) : (
view={view} <div class={input.emptyClass}>
diffStyle={input.diffStyle} <Mark class="w-14 opacity-10" />
onDiffStyleChange={input.onDiffStyleChange} <div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
onScrollRef={(el) => setTree("reviewScroll", el)} </div>
focusedFile={tree.activeDiff} )
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} }
onLineCommentUpdate={updateCommentInContext} diffs={reviewDiffs}
onLineCommentDelete={removeCommentFromContext} view={view}
lineCommentActions={reviewCommentActions()} diffStyle={input.diffStyle}
comments={comments.all()} onDiffStyleChange={input.onDiffStyleChange}
focusedComment={comments.focus()} onScrollRef={(el) => setTree("reviewScroll", el)}
onFocusedCommentChange={comments.setFocus} focusedFile={tree.activeDiff}
onViewFile={openReviewFile} onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
classes={input.classes} onLineCommentUpdate={updateCommentInContext}
/> onLineCommentDelete={removeCommentFromContext}
</Show> lineCommentActions={reviewCommentActions()}
</Match> comments={comments.all()}
<Match when={true}> focusedComment={comments.focus()}
<SessionReviewTab onFocusedCommentChange={comments.setFocus}
title={changesTitle()} onViewFile={openReviewFile}
empty={ classes={input.classes}
store.changes === "turn" ? ( />
emptyTurn() </Match>
) : reviewEmptyKey() === "session.review.noVcs" ? ( </Switch>
<div class={input.emptyClass}>
<div class="flex flex-col gap-3">
<div class="text-14-medium text-text-strong">Create a Git repository</div>
<div
class="text-14-regular text-text-base max-w-md"
style={{ "line-height": "var(--line-height-normal)" }}
>
Track, review, and undo changes in this project
</div>
</div>
<Button size="large" disabled={ui.git} onClick={initGit}>
{ui.git ? "Creating Git repository..." : "Create Git repository"}
</Button>
</div>
) : (
<div class={input.emptyClass}>
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
</div>
)
}
diffs={reviewDiffs}
view={view}
diffStyle={input.diffStyle}
onDiffStyleChange={input.onDiffStyleChange}
onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
onLineCommentUpdate={updateCommentInContext}
onLineCommentDelete={removeCommentFromContext}
lineCommentActions={reviewCommentActions()}
comments={comments.all()}
focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus}
onViewFile={openReviewFile}
classes={input.classes}
/>
</Match>
</Switch>
</Show>
) )
const reviewPanel = () => ( const reviewPanel = () => (
@@ -1201,9 +1109,7 @@ export default function Page() {
const el = scroller const el = scroller
const delta = next - dockHeight const delta = next - dockHeight
const stick = el const stick = el ? el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta) : false
? !autoScroll.userScrolled() || el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta)
: false
dockHeight = next dockHeight = next
@@ -1338,7 +1244,6 @@ export default function Page() {
<SessionComposerRegion <SessionComposerRegion
state={composer} state={composer}
ready={!store.deferRender && messagesReady()}
centered={centered()} centered={centered()}
inputRef={(el) => { inputRef={(el) => {
inputRef = el inputRef = el
@@ -1,7 +1,5 @@
import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { Show, createEffect, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { PromptInput } from "@/components/prompt-input" import { PromptInput } from "@/components/prompt-input"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt" import { usePrompt } from "@/context/prompt"
@@ -13,7 +11,6 @@ import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
export function SessionComposerRegion(props: { export function SessionComposerRegion(props: {
state: SessionComposerState state: SessionComposerState
ready: boolean
centered: boolean centered: boolean
inputRef: (el: HTMLDivElement) => void inputRef: (el: HTMLDivElement) => void
newSessionWorktree: string newSessionWorktree: string
@@ -21,23 +18,6 @@ export function SessionComposerRegion(props: {
onSubmit: () => void onSubmit: () => void
onResponseSubmit: () => void onResponseSubmit: () => void
setPromptDockRef: (el: HTMLDivElement) => void setPromptDockRef: (el: HTMLDivElement) => void
visualDuration?: number
bounce?: number
dockOpenVisualDuration?: number
dockOpenBounce?: number
dockCloseVisualDuration?: number
dockCloseBounce?: number
drawerExpandVisualDuration?: number
drawerExpandBounce?: number
drawerCollapseVisualDuration?: number
drawerCollapseBounce?: number
subtitleDuration?: number
subtitleTravel?: number
subtitleEdge?: number
countDuration?: number
countMask?: number
countMaskHeight?: number
countWidthDuration?: number
}) { }) {
const params = useParams() const params = useParams()
const prompt = usePrompt() const prompt = usePrompt()
@@ -63,74 +43,6 @@ export function SessionComposerRegion(props: {
setSessionHandoff(sessionKey(), { prompt: previewPrompt() }) setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
}) })
const [gate, setGate] = createStore({
ready: false,
})
let timer: number | undefined
let frame: number | undefined
const clear = () => {
if (timer !== undefined) {
window.clearTimeout(timer)
timer = undefined
}
if (frame !== undefined) {
cancelAnimationFrame(frame)
frame = undefined
}
}
createEffect(() => {
sessionKey()
const ready = props.ready
const delay = 140
clear()
setGate("ready", false)
if (!ready) return
frame = requestAnimationFrame(() => {
frame = undefined
timer = window.setTimeout(() => {
setGate("ready", true)
timer = undefined
}, delay)
})
})
onCleanup(clear)
const open = createMemo(() => gate.ready && props.state.dock() && !props.state.closing())
const config = createMemo(() =>
open()
? {
visualDuration: props.dockOpenVisualDuration ?? props.visualDuration ?? 0.3,
bounce: props.dockOpenBounce ?? props.bounce ?? 0,
}
: {
visualDuration: props.dockCloseVisualDuration ?? props.visualDuration ?? 0.3,
bounce: props.dockCloseBounce ?? props.bounce ?? 0,
},
)
const progress = useSpring(() => (open() ? 1 : 0), config)
const value = createMemo(() => Math.max(0, Math.min(1, progress())))
const [height, setHeight] = createSignal(320)
const dock = createMemo(() => (gate.ready && props.state.dock()) || value() > 0.001)
const full = createMemo(() => Math.max(78, height()))
const [contentRef, setContentRef] = createSignal<HTMLDivElement>()
createEffect(() => {
const el = contentRef()
if (!el) return
const update = () => {
setHeight(el.getBoundingClientRect().height)
}
update()
const observer = new ResizeObserver(update)
observer.observe(el)
onCleanup(() => observer.disconnect())
})
return ( return (
<div <div
ref={props.setPromptDockRef} ref={props.setPromptDockRef}
@@ -175,46 +87,30 @@ export function SessionComposerRegion(props: {
</div> </div>
} }
> >
<Show when={dock()}> <Show when={props.state.dock()}>
<div <div
classList={{ classList={{
"overflow-hidden": true, "transition-[max-height,opacity,transform] duration-[400ms] ease-out overflow-hidden": true,
"pointer-events-none": value() < 0.98, "max-h-[320px]": !props.state.closing(),
}} "max-h-0 pointer-events-none": props.state.closing(),
style={{ "opacity-0 translate-y-9": props.state.closing() || props.state.opening(),
"max-height": `${full() * value()}px`, "opacity-100 translate-y-0": !props.state.closing() && !props.state.opening(),
}} }}
> >
<div ref={setContentRef}> <SessionTodoDock
<SessionTodoDock todos={props.state.todos()}
todos={props.state.todos()} title={language.t("session.todo.title")}
title={language.t("session.todo.title")} collapseLabel={language.t("session.todo.collapse")}
collapseLabel={language.t("session.todo.collapse")} expandLabel={language.t("session.todo.expand")}
expandLabel={language.t("session.todo.expand")} />
dockProgress={value()}
visualDuration={props.visualDuration}
bounce={props.bounce}
expandVisualDuration={props.drawerExpandVisualDuration}
expandBounce={props.drawerExpandBounce}
collapseVisualDuration={props.drawerCollapseVisualDuration}
collapseBounce={props.drawerCollapseBounce}
subtitleDuration={props.subtitleDuration}
subtitleTravel={props.subtitleTravel}
subtitleEdge={props.subtitleEdge}
countDuration={props.countDuration}
countMask={props.countMask}
countMaskHeight={props.countMaskHeight}
countWidthDuration={props.countWidthDuration}
/>
</div>
</div> </div>
</Show> </Show>
<div <div
classList={{ classList={{
"relative z-10": true, "relative z-10": true,
}} "transition-[margin] duration-[400ms] ease-out": true,
style={{ "-mt-9": props.state.dock() && !props.state.closing(),
"margin-top": `${-36 * value()}px`, "mt-0": !props.state.dock() || props.state.closing(),
}} }}
> >
<PromptInput <PromptInput
@@ -29,7 +29,7 @@ export function createSessionComposerBlocked() {
}) })
} }
export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) { export function createSessionComposerState() {
const params = useParams() const params = useParams()
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
@@ -96,19 +96,12 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
let timer: number | undefined let timer: number | undefined
let raf: number | undefined let raf: number | undefined
const closeMs = () => {
const value = options?.closeMs
if (typeof value === "function") return Math.max(0, value())
if (typeof value === "number") return Math.max(0, value)
return 400
}
const scheduleClose = () => { const scheduleClose = () => {
if (timer) window.clearTimeout(timer) if (timer) window.clearTimeout(timer)
timer = window.setTimeout(() => { timer = window.setTimeout(() => {
setStore({ dock: false, closing: false }) setStore({ dock: false, closing: false })
timer = undefined timer = undefined
}, closeMs()) }, 400)
} }
createEffect( createEffect(
@@ -8,8 +8,6 @@ import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
const cache = new Map<string, { tab: number; answers: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>()
export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => { export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => {
const sdk = useSDK() const sdk = useSDK()
const language = useLanguage() const language = useLanguage()
@@ -17,18 +15,16 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const questions = createMemo(() => props.request.questions) const questions = createMemo(() => props.request.questions)
const total = createMemo(() => questions().length) const total = createMemo(() => questions().length)
const cached = cache.get(props.request.id)
const [store, setStore] = createStore({ const [store, setStore] = createStore({
tab: cached?.tab ?? 0, tab: 0,
answers: cached?.answers ?? ([] as QuestionAnswer[]), answers: [] as QuestionAnswer[],
custom: cached?.custom ?? ([] as string[]), custom: [] as string[],
customOn: cached?.customOn ?? ([] as boolean[]), customOn: [] as boolean[],
editing: false, editing: false,
sending: false, sending: false,
}) })
let root: HTMLDivElement | undefined let root: HTMLDivElement | undefined
let replied = false
const question = createMemo(() => questions()[store.tab]) const question = createMemo(() => questions()[store.tab])
const options = createMemo(() => question()?.options ?? []) const options = createMemo(() => question()?.options ?? [])
@@ -111,16 +107,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
}) })
}) })
onCleanup(() => {
if (replied) return
cache.set(props.request.id, {
tab: store.tab,
answers: store.answers.map((a) => (a ? [...a] : [])),
custom: store.custom.map((s) => s ?? ""),
customOn: store.customOn.map((b) => b ?? false),
})
})
const fail = (err: unknown) => { const fail = (err: unknown) => {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message }) showToast({ title: language.t("common.requestFailed"), description: message })
@@ -133,8 +119,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
setStore("sending", true) setStore("sending", true)
try { try {
await sdk.client.question.reply({ requestID: props.request.id, answers }) await sdk.client.question.reply({ requestID: props.request.id, answers })
replied = true
cache.delete(props.request.id)
} catch (err) { } catch (err) {
fail(err) fail(err)
} finally { } finally {
@@ -149,8 +133,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
setStore("sending", true) setStore("sending", true)
try { try {
await sdk.client.question.reject({ requestID: props.request.id }) await sdk.client.question.reject({ requestID: props.request.id })
replied = true
cache.delete(props.request.id)
} catch (err) { } catch (err) {
fail(err) fail(err)
} finally { } finally {
@@ -1,12 +1,8 @@
import type { Todo } from "@opencode-ai/sdk/v2" import type { Todo } from "@opencode-ai/sdk/v2"
import { AnimatedNumber } from "@opencode-ai/ui/animated-number"
import { Checkbox } from "@opencode-ai/ui/checkbox" import { Checkbox } from "@opencode-ai/ui/checkbox"
import { DockTray } from "@opencode-ai/ui/dock-surface" import { DockTray } from "@opencode-ai/ui/dock-surface"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { useSpring } from "@opencode-ai/ui/motion-spring" import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
import { Index, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
function dot(status: Todo["status"]) { function dot(status: Todo["status"]) {
@@ -34,35 +30,19 @@ function dot(status: Todo["status"]) {
) )
} }
export function SessionTodoDock(props: { export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseLabel: string; expandLabel: string }) {
todos: Todo[]
title: string
collapseLabel: string
expandLabel: string
dockProgress?: number
visualDuration?: number
bounce?: number
expandVisualDuration?: number
expandBounce?: number
collapseVisualDuration?: number
collapseBounce?: number
subtitleDuration?: number
subtitleTravel?: number
subtitleEdge?: number
countDuration?: number
countMask?: number
countMaskHeight?: number
countWidthDuration?: number
}) {
const [store, setStore] = createStore({ const [store, setStore] = createStore({
collapsed: false, collapsed: false,
}) })
const toggle = () => setStore("collapsed", (value) => !value) const toggle = () => setStore("collapsed", (value) => !value)
const total = createMemo(() => props.todos.length) const summary = createMemo(() => {
const done = createMemo(() => props.todos.filter((todo) => todo.status === "completed").length) const total = props.todos.length
const label = createMemo(() => `${done()} of ${total()} ${props.title.toLowerCase()} completed`) if (total === 0) return ""
const completed = props.todos.filter((todo) => todo.status === "completed").length
return `${completed} of ${total} ${props.title.toLowerCase()} completed`
})
const active = createMemo( const active = createMemo(
() => () =>
@@ -73,134 +53,56 @@ export function SessionTodoDock(props: {
) )
const preview = createMemo(() => active()?.content ?? "") const preview = createMemo(() => active()?.content ?? "")
const config = createMemo(() =>
store.collapsed
? {
visualDuration: props.collapseVisualDuration ?? props.visualDuration ?? 0.3,
bounce: props.collapseBounce ?? props.bounce ?? 0,
}
: {
visualDuration: props.expandVisualDuration ?? props.visualDuration ?? 0.3,
bounce: props.expandBounce ?? props.bounce ?? 0,
},
)
const collapse = useSpring(() => (store.collapsed ? 1 : 0), config)
const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress ?? 1)))
const shut = createMemo(() => 1 - dock())
const value = createMemo(() => Math.max(0, Math.min(1, collapse())))
const hide = createMemo(() => Math.max(value(), shut()))
const off = createMemo(() => hide() > 0.98)
const turn = createMemo(() => Math.max(0, Math.min(1, value())))
const [height, setHeight] = createSignal(320)
const full = createMemo(() => Math.max(78, height()))
let contentRef: HTMLDivElement | undefined
createEffect(() => {
const el = contentRef
if (!el) return
const update = () => {
setHeight(el.getBoundingClientRect().height)
}
update()
const observer = new ResizeObserver(update)
observer.observe(el)
onCleanup(() => observer.disconnect())
})
return ( return (
<DockTray <DockTray
data-component="session-todo-dock" data-component="session-todo-dock"
style={{ classList={{
"overflow-x": "visible", "h-[78px]": store.collapsed,
"overflow-y": "hidden",
"max-height": `${Math.max(78, full() - value() * (full() - 78))}px`,
}} }}
> >
<div ref={contentRef}> <div
<div data-action="session-todo-toggle"
data-action="session-todo-toggle" class="pl-3 pr-2 py-2 flex items-center gap-2"
class="pl-3 pr-2 py-2 flex items-center gap-2 overflow-visible" role="button"
role="button" tabIndex={0}
tabIndex={0} onClick={toggle}
onClick={toggle} onKeyDown={(event) => {
onKeyDown={(event) => { if (event.key !== "Enter" && event.key !== " ") return
if (event.key !== "Enter" && event.key !== " ") return event.preventDefault()
event.preventDefault() toggle()
toggle() }}
}} >
> <span class="text-14-regular text-text-strong cursor-default">{summary()}</span>
<span <Show when={store.collapsed}>
class="text-14-regular text-text-strong cursor-default inline-flex items-baseline shrink-0 whitespace-nowrap overflow-visible" <div class="ml-1 flex-1 min-w-0">
aria-label={label()} <Show when={preview()}>
style={{ <div class="text-14-regular text-text-base truncate cursor-default">{preview()}</div>
"--tool-motion-odometer-ms": `${props.countDuration ?? 600}ms`, </Show>
"--tool-motion-mask": `${props.countMask ?? 18}%`,
"--tool-motion-mask-height": `${props.countMaskHeight ?? 0}px`,
"--tool-motion-spring-ms": `${props.countWidthDuration ?? 560}ms`,
opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`,
filter: `blur(${Math.max(0, Math.min(1, shut())) * 2}px)`,
}}
>
<AnimatedNumber value={done()} />
<span class="mx-1">of</span>
<AnimatedNumber value={total()} />
<span>&nbsp;{props.title.toLowerCase()} completed</span>
</span>
<div
data-slot="session-todo-preview"
class="ml-1 min-w-0 overflow-hidden"
style={{
flex: "1 1 auto",
"max-width": "100%",
}}
>
<TextReveal
class="text-14-regular text-text-base cursor-default"
text={store.collapsed ? preview() : undefined}
duration={props.subtitleDuration ?? 600}
travel={props.subtitleTravel ?? 25}
edge={props.subtitleEdge ?? 17}
spring="cubic-bezier(0.34, 1, 0.64, 1)"
springSoft="cubic-bezier(0.34, 1, 0.64, 1)"
growOnly
truncate
/>
</div>
<div class="ml-auto">
<IconButton
data-action="session-todo-toggle-button"
data-collapsed={store.collapsed ? "true" : "false"}
icon="chevron-down"
size="normal"
variant="ghost"
style={{ transform: `rotate(${turn() * 180}deg)` }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
toggle()
}}
aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
/>
</div> </div>
</Show>
<div classList={{ "ml-auto": !store.collapsed, "ml-1": store.collapsed }}>
<IconButton
data-action="session-todo-toggle-button"
icon="chevron-down"
size="normal"
variant="ghost"
classList={{ "rotate-180": store.collapsed }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
toggle()
}}
aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
/>
</div> </div>
</div>
<div <div data-slot="session-todo-list" hidden={store.collapsed}>
data-slot="session-todo-list" <TodoList todos={props.todos} open={!store.collapsed} />
aria-hidden={store.collapsed || off()}
classList={{
"pointer-events-none": hide() > 0.1,
}}
style={{
visibility: off() ? "hidden" : "visible",
opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
filter: `blur(${Math.max(0, Math.min(1, hide())) * 2}px)`,
}}
>
<TodoList todos={props.todos} open={!store.collapsed} />
</div>
</div> </div>
</DockTray> </DockTray>
) )
@@ -269,40 +171,33 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
}, 250) }, 250)
}} }}
> >
<Index each={props.todos}> <For each={props.todos}>
{(todo) => ( {(todo) => (
<Checkbox <Checkbox
readOnly readOnly
checked={todo().status === "completed"} checked={todo.status === "completed"}
indeterminate={todo().status === "in_progress"} indeterminate={todo.status === "in_progress"}
data-in-progress={todo().status === "in_progress" ? "" : undefined} data-in-progress={todo.status === "in_progress" ? "" : undefined}
data-state={todo().status} icon={dot(todo.status)}
icon={dot(todo().status)} style={{ "--checkbox-align": "flex-start", "--checkbox-offset": "1px" }}
style={{
"--checkbox-align": "flex-start",
"--checkbox-offset": "1px",
transition: "opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
opacity: todo().status === "pending" ? "0.94" : "1",
}}
> >
<TextStrikethrough <span
active={todo().status === "completed" || todo().status === "cancelled"}
text={todo().content}
class="text-14-regular min-w-0 break-words" class="text-14-regular min-w-0 break-words"
classList={{
"text-text-weak": todo.status === "completed" || todo.status === "cancelled",
"text-text-strong": todo.status !== "completed" && todo.status !== "cancelled",
}}
style={{ style={{
"line-height": "var(--line-height-normal)", "line-height": "var(--line-height-normal)",
transition: "text-decoration":
"color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))", todo.status === "completed" || todo.status === "cancelled" ? "line-through" : undefined,
color:
todo().status === "completed" || todo().status === "cancelled"
? "var(--text-weak)"
: "var(--text-strong)",
opacity: todo().status === "pending" ? "0.92" : "1",
}} }}
/> >
{todo.content}
</span>
</Checkbox> </Checkbox>
)} )}
</Index> </For>
</div> </div>
<div <div
class="pointer-events-none absolute top-0 left-0 right-0 h-4 transition-opacity duration-150" class="pointer-events-none absolute top-0 left-0 right-0 h-4 transition-opacity duration-150"

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