Compare commits

..

7 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
673 changed files with 7564 additions and 33048 deletions

View File

@@ -59,10 +59,43 @@ jobs:
{
"permission": {
"*": "deny",
"read": "allow",
"edit": "allow",
"glob": "allow",
"task": "allow"
"read": {
"*": "deny",
"packages/web/src/content/docs": "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: |

View File

@@ -99,6 +99,7 @@ jobs:
with:
name: opencode-cli
path: packages/opencode/dist
outputs:
version: ${{ needs.version.outputs.version }}
@@ -239,131 +240,11 @@ jobs:
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
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:
needs:
- version
- build-cli
- build-tauri
- build-electron
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v3
@@ -400,12 +281,6 @@ jobs:
name: opencode-cli
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)
uses: actions/cache@v4
with:
@@ -433,4 +308,3 @@ jobs:
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
GH_REPO: ${{ needs.version.outputs.repo }}
NPM_CONFIG_PROVENANCE: false
LATEST_YML_DIR: /tmp/latest-yml

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

View File

@@ -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.

View File

@@ -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

View File

@@ -20,17 +20,6 @@
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
// Good
const foo = 1
@@ -122,3 +111,7 @@ const table = sqliteTable("session", {
- Avoid mocks as much as possible
- Test actual implementation, do not duplicate logic into tests
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
## Type Checking
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.

View File

@@ -35,8 +35,7 @@
<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>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -35,8 +35,7 @@
<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>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -27,7 +27,6 @@
<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> |
@@ -35,8 +34,7 @@
<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>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -35,8 +35,7 @@
<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>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -27,7 +27,6 @@
<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> |
@@ -35,8 +34,7 @@
<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>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -27,7 +27,6 @@
<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> |
@@ -35,8 +34,7 @@
<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>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -27,7 +27,6 @@
<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> |
@@ -35,8 +34,7 @@
<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>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -27,7 +27,6 @@
<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> |
@@ -35,8 +34,7 @@
<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>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -35,8 +35,7 @@
<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>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -27,7 +27,6 @@
<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> |
@@ -35,8 +34,7 @@
<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>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -27,7 +27,6 @@
<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> |
@@ -35,8 +34,7 @@
<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>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -27,7 +27,6 @@
<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> |
@@ -35,8 +34,7 @@
<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>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -35,8 +35,7 @@
<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>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -27,7 +27,6 @@
<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> |
@@ -35,8 +34,7 @@
<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>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -27,7 +27,6 @@
<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> |
@@ -35,8 +34,7 @@
<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>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -27,7 +27,6 @@
<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> |
@@ -35,8 +34,7 @@
<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>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -27,7 +27,6 @@
<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> |
@@ -35,8 +34,7 @@
<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>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -27,7 +27,6 @@
<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> |
@@ -35,8 +34,7 @@
<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>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -35,8 +35,7 @@
<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>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

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)

View File

@@ -27,7 +27,6 @@
<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> |
@@ -35,8 +34,7 @@
<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>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -27,7 +27,6 @@
<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> |
@@ -35,8 +34,7 @@
<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>
<a href="README.gr.md">Ελληνικά</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

806
bun.lock

File diff suppressed because it is too large Load Diff

6
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1772091128,
"narHash": "sha256-TnrYykX8Mf/Ugtkix6V+PjW7miU2yClA6uqWl/v6KWM=",
"lastModified": 1770812194,
"narHash": "sha256-OH+lkaIKAvPXR3nITO7iYZwew2nW9Y7Xxq0yfM/UcUU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3f0336406035444b4a24b942788334af5f906259",
"rev": "8482c7ded03bae7550f3d69884f1e611e3bd19e8",
"type": "github"
},
"original": {

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 { createOpencodeClient } from "@opencode-ai/sdk"
import { spawn } from "node:child_process"
import { setTimeout as sleep } from "node:timers/promises"
type GitHubAuthor = {
login: string
@@ -282,7 +281,7 @@ async function assertOpencodeConnected() {
connected = true
break
} catch (e) {}
await sleep(300)
await Bun.sleep(300)
} while (retry++ < 30)
if (!connected) {

View File

@@ -118,6 +118,7 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
price: zenLitePrice.id,
},
})
const ZEN_LITE_LIMITS = new sst.Secret("ZEN_LITE_LIMITS")
const zenBlackProduct = new stripe.Product("ZenBlack", {
name: "OpenCode Black",
@@ -141,6 +142,7 @@ const ZEN_BLACK_PRICE = new sst.Linkable("ZEN_BLACK_PRICE", {
plan20: zenBlackPrice20.id,
},
})
const ZEN_BLACK_LIMITS = new sst.Secret("ZEN_BLACK_LIMITS")
const ZEN_MODELS = [
new sst.Secret("ZEN_MODELS1"),
@@ -213,8 +215,9 @@ new sst.cloudflare.x.SolidStart("Console", {
AWS_SES_ACCESS_KEY_ID,
AWS_SES_SECRET_ACCESS_KEY,
ZEN_BLACK_PRICE,
ZEN_BLACK_LIMITS,
ZEN_LITE_PRICE,
new sst.Secret("ZEN_LIMITS"),
ZEN_LITE_LIMITS,
new sst.Secret("ZEN_SESSION_SECRET"),
...ZEN_MODELS,
...($dev

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-pBTIT8Pgdm3272YhBjiAZsmj0SSpHTklh6lGc8YcMoE=",
"aarch64-linux": "sha256-prt039++d5UZgtldAN6+RVOR557ifIeusiy5XpzN8QU=",
"aarch64-darwin": "sha256-Y3f+cXcIGLqz6oyc5fG22t6CLD4wGkvwqO6RNXjFriQ=",
"x86_64-darwin": "sha256-BjbBBhQUgGhrlP56skABcrObvutNUZSWnrnPCg1OTKE="
"x86_64-linux": "sha256-2XLuizbG90QDUQL+1M90XxfVZxjkIQ1cFYS46nnVO7g=",
"aarch64-linux": "sha256-hlckiGAtbpAlwgcE7KgzKKRq9T2FEOSq3Q1MhuHfZ2c=",
"aarch64-darwin": "sha256-V/8Kay+5bDb/BSVgBQhSMwzmRmkNGl3U0HFMVbVcMak=",
"x86_64-darwin": "sha256-duLDF88Q/hXK5jwBy4dVxMSiTTS0R4obp9MlTuOF/Pw="
}
}

View File

@@ -31,7 +31,6 @@ stdenvNoCC.mkDerivation {
../package.json
../patches
../install # required by desktop build (cli.rs include_str!)
../.github/TEAM_MEMBERS # required by @opencode-ai/script
]
);
};

View File

@@ -9,7 +9,6 @@
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop tauri dev",
"dev:web": "bun --cwd packages/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"typecheck": "bun turbo typecheck",
"prepare": "husky",
"random": "echo 'Random script'",
@@ -41,8 +40,8 @@
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"drizzle-kit": "1.0.0-beta.12-a5629fb",
"drizzle-orm": "1.0.0-beta.12-a5629fb",
"ai": "5.0.124",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
@@ -71,13 +70,12 @@
"@actions/artifact": "5.0.1",
"@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1",
"@typescript/native-preview": "catalog:",
"glob": "13.0.5",
"husky": "9.1.7",
"prettier": "3.6.2",
"semver": "^7.6.0",
"sst": "3.18.10",
"turbo": "2.8.13"
"turbo": "2.5.6"
},
"dependencies": {
"@aws-sdk/client-s3": "3.933.0",
@@ -100,8 +98,7 @@
"protobufjs",
"tree-sitter",
"tree-sitter-bash",
"web-tree-sitter",
"electron"
"web-tree-sitter"
],
"overrides": {
"@types/bun": "catalog:",

View File

@@ -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

View File

@@ -8,7 +8,6 @@ import {
sessionItemSelector,
dropdownMenuTriggerSelector,
dropdownMenuContentSelector,
sessionHeaderSelector,
projectMenuTriggerSelector,
projectWorkspacesToggleSelector,
titlebarRightSelector,
@@ -226,9 +225,9 @@ export async function hoverSessionItem(page: Page, sessionID: string) {
export async function openSessionMoreMenu(page: Page, sessionID: string) {
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
const header = page.locator(sessionHeaderSelector).first()
await expect(header).toBeVisible()
await expect(header.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
const scroller = page.locator(".scroll-view__viewport").first()
await expect(scroller).toBeVisible()
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
const menu = page
.locator(dropdownMenuContentSelector)
@@ -244,7 +243,7 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) {
if (opened) return menu
const menuTrigger = header.getByRole("button", { name: /more options/i }).first()
const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
await expect(menuTrigger).toBeVisible()
await menuTrigger.click()
@@ -442,57 +441,6 @@ export async function seedSessionPermission(
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(
sdk: ReturnType<typeof createSdk>,
input: {

View File

@@ -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).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()
})

View File

@@ -92,19 +92,14 @@ test("switching back to a project opens the latest workspace session", async ({
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session(?:[/?#]|$)`))
// Create a session by sending a prompt
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.fill("test")
await page.keyboard.press("Enter")
// 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()}`)
const created = await createSdk(workspaceDir)
.session.create()
.then((x) => x.data?.id)
if (!created) throw new Error(`Failed to create session for workspace: ${workspaceDir}`)
sessionID = created
await page.goto(sessionPath(workspaceDir, created))
await expect(page.locator(promptSelector)).toBeVisible()
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
await openSidebar(page)

View File

@@ -53,8 +53,6 @@ export const dropdownMenuContentSelector = '[data-component="dropdown-menu-conte
export const inlineInputSelector = '[data-component="inline-input"]'
export const sessionHeaderSelector = "[data-session-title]"
export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
export const workspaceItemSelector = (slug: string) =>

View File

@@ -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)
}
})
})

View File

@@ -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 }) => {
await withDockSession(sdk, "e2e composer dock question", async (session) => {
await withDockSeed(sdk, session.id, async () => {

View File

@@ -7,7 +7,7 @@ import {
openSharePopover,
withSession,
} from "../actions"
import { sessionHeaderSelector, sessionItemSelector, inlineInputSelector } from "../selectors"
import { sessionItemSelector, inlineInputSelector } from "../selectors"
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
@@ -44,7 +44,7 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /rename/i)
const input = page.locator(sessionHeaderSelector).locator(inlineInputSelector).first()
const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
await expect(input).toBeVisible()
await expect(input).toBeFocused()
await input.fill(renamedTitle)

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.2.20",
"version": "1.2.15",
"description": "",
"type": "module",
"exports": {
@@ -57,7 +57,7 @@
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#main",
"ghostty-web": "0.4.0",
"luxon": "catalog:",
"marked": "catalog:",
"marked-shiki": "catalog:",

View File

@@ -7,8 +7,8 @@ import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { Font } from "@opencode-ai/ui/font"
import { ThemeProvider } from "@opencode-ai/ui/theme"
import { MetaProvider } from "@solidjs/meta"
import { BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { Component, ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
import { Navigate, Route, Router } from "@solidjs/router"
import { ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
import { CommandProvider } from "@/context/command"
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
@@ -28,7 +28,6 @@ import { TerminalProvider } from "@/context/terminal"
import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
import { Dynamic } from "solid-js/web"
const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
@@ -145,15 +144,13 @@ export function AppInterface(props: {
children?: JSX.Element
defaultServer: ServerConnection.Key
servers?: Array<ServerConnection.Any>
router?: Component<BaseRouterProps>
}) {
return (
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
<ServerKey>
<GlobalSDKProvider>
<GlobalSyncProvider>
<Dynamic
component={props.router ?? Router}
<Router
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
>
<Route path="/" component={HomeRoute} />
@@ -161,7 +158,7 @@ export function AppInterface(props: {
<Route path="/" component={SessionIndexRoute} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Dynamic>
</Router>
</GlobalSyncProvider>
</GlobalSDKProvider>
</ServerKey>

View File

@@ -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 = file.tree.children(props.path)
const current = filter()

View File

@@ -1,5 +1,4 @@
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 { createStore } from "solid-js/store"
import { createFocusSignal } from "@solid-primitives/active-element"
@@ -244,7 +243,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
draggingType: "image" | "@mention" | null
mode: "normal" | "shell"
applyingHistory: boolean
pendingAutoAccept: boolean
}>({
popover: null,
historyIndex: -1,
@@ -253,19 +251,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
draggingType: null,
mode: "normal",
applyingHistory: false,
pendingAutoAccept: false,
})
const buttonsSpring = useSpring(
() => (store.mode === "normal" ? 1 : 0),
{ visualDuration: 0.2, bounce: 0 },
)
const springFade = (t: number): Record<string, string> => ({
opacity: `${t}`,
transform: `scale(${0.95 + t * 0.05})`,
filter: `blur(${(1 - t) * 2}px)`,
"pointer-events": t > 0.5 ? "auto" : "none",
})
const commentCount = createMemo(() => {
@@ -316,12 +301,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}),
)
createEffect(
on(sessionKey, () => {
setStore("pendingAutoAccept", false)
}),
)
const historyComments = () => {
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
return prompt.context.items().flatMap((item) => {
@@ -612,6 +591,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setActive: setSlashActive,
onInput: slashOnInput,
onKeyDown: slashOnKeyDown,
refetch: slashRefetch,
} = useFilteredList<SlashCommand>({
items: slashCommands,
key: (x) => x?.id,
@@ -668,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
createEffect(() => {
const activeId = slashActive()
@@ -968,18 +956,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
readClipboardImage: platform.readClipboardImage,
})
const variants = createMemo(() => ["default", ...local.model.variant.list()])
const accepting = createMemo(() => {
const id = params.id
if (!id) return store.pendingAutoAccept
return permission.isAutoAccepting(id, sdk.directory)
})
const { abort, handleSubmit } = createPromptSubmit({
info,
imageAttachments,
commentCount,
autoAccept: () => accepting(),
mode: () => store.mode,
working,
editor: () => editorRef,
@@ -1144,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 (
<div class="relative size-full _max-h-[320px] flex flex-col gap-0">
<PromptPopover
@@ -1221,9 +1208,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
aria-multiline="true"
aria-label={placeholder()}
contenteditable="true"
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
autocorrect={store.mode === "normal" ? "on" : "off"}
spellcheck={store.mode === "normal"}
autocapitalize="off"
autocorrect="off"
spellcheck={false}
onInput={handleInput}
onPaste={handlePaste}
onCompositionStart={() => setComposing(true)}
@@ -1263,8 +1250,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div
aria-hidden={store.mode !== "normal"}
class="flex items-center gap-1"
style={{ "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none" }}
class="flex items-center gap-1 transition-all duration-200 ease-out"
classList={{
"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
placement="top"
@@ -1276,7 +1266,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type="button"
variant="ghost"
class="size-8 p-0"
style={springFade(buttonsSpring())}
onClick={pick}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
@@ -1314,7 +1303,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="size-8"
style={springFade(buttonsSpring())}
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
@@ -1334,11 +1322,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Button
data-action="prompt-permissions"
variant="ghost"
disabled={!params.id}
onClick={() => {
if (!params.id) {
setStore("pendingAutoAccept", (value) => !value)
return
}
if (!params.id) return
permission.toggleAutoAccept(params.id, sdk.directory)
}}
classList={{
@@ -1367,15 +1353,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Show when={store.mode === "normal" || store.mode === "shell"}>
<DockTray attach="top">
<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="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0"
style={{ padding: "0 4px 0 8px", ...springFade(1 - buttonsSpring()) }}
>
<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">
<div class="flex items-center gap-1.5 min-w-0 flex-1">
<Show when={store.mode === "shell"}>
<div class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0" style={{ padding: "0 4px 0 8px" }}>
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
<div class="size-4 shrink-0" />
</div>
</Show>
<Show when={store.mode === "normal"}>
<TooltipKeybind
placement="top"
gutter={4}
@@ -1389,7 +1374,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSelect={local.agent.set}
class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular"
triggerStyle={{ height: "28px", ...springFade(buttonsSpring()) }}
triggerStyle={{ height: "28px" }}
variant="ghost"
/>
</TooltipKeybind>
@@ -1407,7 +1392,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
variant="ghost"
size="normal"
class="min-w-0 max-w-[320px] text-13-regular group"
style={{ height: "28px", ...springFade(buttonsSpring()) }}
style={{ height: "28px" }}
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
>
<Show when={local.model.current()?.provider?.id}>
@@ -1436,7 +1421,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
triggerProps={{
variant: "ghost",
size: "normal",
style: { height: "28px", ...springFade(buttonsSpring()) },
style: { height: "28px" },
class: "min-w-0 max-w-[320px] text-13-regular group",
}}
>
@@ -1468,11 +1453,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular"
triggerStyle={{ height: "28px", ...springFade(buttonsSpring()) }}
triggerStyle={{ height: "28px" }}
variant="ghost"
/>
</TooltipKeybind>
</div>
</Show>
</div>
<div class="shrink-0">
<RadioGroup

View File

@@ -5,7 +5,6 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
const createdClients: string[] = []
const createdSessions: string[] = []
const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
const sentShell: 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", () => ({
usePrompt: () => ({
current: () => promptValue,
@@ -154,7 +145,6 @@ beforeAll(async () => {
beforeEach(() => {
createdClients.length = 0
createdSessions.length = 0
enabledAutoAccept.length = 0
sentShell.length = 0
syncedDirectories.length = 0
selected = "/repo/worktree-a"
@@ -166,7 +156,6 @@ describe("prompt submit worktree selection", () => {
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "shell",
working: () => false,
editor: () => undefined,
@@ -192,31 +181,4 @@ describe("prompt submit worktree selection", () => {
expect(sentShell).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" }])
})
})

View File

@@ -1,15 +1,13 @@
import type { Message } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/util/encode"
import { errorMessage } from "@/pages/layout/helpers"
import { useNavigate, useParams } from "@solidjs/router"
import { batch, type Accessor } from "solid-js"
import type { Accessor } from "solid-js"
import type { FileSelection } from "@/context/file"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import { usePermission } from "@/context/permission"
import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
@@ -17,7 +15,6 @@ import { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree"
import { buildRequestParts } from "./build-request-parts"
import { setCursorPosition } from "./editor-dom"
import { formatServerError } from "@/utils/server-errors"
type PendingPrompt = {
abort: AbortController
@@ -30,7 +27,6 @@ type PromptSubmitInput = {
info: Accessor<{ id: string } | undefined>
imageAttachments: Accessor<ImageAttachmentPart[]>
commentCount: Accessor<number>
autoAccept: Accessor<boolean>
mode: Accessor<"normal" | "shell">
working: Accessor<boolean>
editor: () => HTMLDivElement | undefined
@@ -60,13 +56,19 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const sync = useSync()
const globalSync = useGlobalSync()
const local = useLocal()
const permission = usePermission()
const prompt = usePrompt()
const layout = useLayout()
const language = useLanguage()
const params = useParams()
const toastError = (err: unknown) => errorMessage(err, language.t("common.requestFailed"))
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message
}
if (err instanceof Error) return err.message
return language.t("common.requestFailed")
}
const abort = async () => {
const sessionID = params.id
@@ -138,7 +140,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const projectDirectory = sdk.directory
const isNewSession = !params.id
const shouldAutoAccept = isNewSession && input.autoAccept()
const worktreeSelection = input.newSessionWorktree?.() || "main"
let sessionDirectory = projectDirectory
@@ -152,7 +153,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
.catch((err) => {
showToast({
title: language.t("prompt.toast.worktreeCreateFailed.title"),
description: toastError(err),
description: errorMessage(err),
})
return undefined
})
@@ -191,12 +192,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
.catch((err) => {
showToast({
title: language.t("prompt.toast.sessionCreateFailed.title"),
description: toastError(err),
description: errorMessage(err),
})
return undefined
})
if (session) {
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
}
@@ -249,7 +249,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
.catch((err) => {
showToast({
title: language.t("prompt.toast.shellSendFailed.title"),
description: toastError(err),
description: errorMessage(err),
})
restoreInput()
})
@@ -281,7 +281,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
.catch((err) => {
showToast({
title: language.t("prompt.toast.commandSendFailed.title"),
description: formatServerError(err, language.t, language.t("common.requestFailed")),
description: errorMessage(err),
})
restoreInput()
})
@@ -327,14 +327,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
messageID,
})
batch(() => {
removeCommentItems(commentItems)
clearInput()
if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "busy" })
}
addOptimisticMessage()
})
removeCommentItems(commentItems)
clearInput()
addOptimisticMessage()
const waitForWorktree = async () => {
const worktree = WorktreeState.get(sessionDirectory)
@@ -411,7 +406,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
showToast({
title: language.t("prompt.toast.promptSendFailed.title"),
description: toastError(err),
description: errorMessage(err),
})
removeOptimisticMessage()
restoreCommentItems(commentItems)

View File

@@ -39,7 +39,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const usd = createMemo(
() =>
new Intl.NumberFormat(language.intl(), {
new Intl.NumberFormat(language.locale(), {
style: "currency",
currency: "USD",
}),
@@ -77,7 +77,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
{(ctx) => (
<>
<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>
</div>
<div class="flex items-center gap-2">

View File

@@ -4,7 +4,8 @@ import { useParams } from "@solidjs/router"
import { useSync } from "@/context/sync"
import { useLayout } from "@/context/layout"
import { checksum } from "@opencode-ai/util/encode"
import { findLast, same } from "@opencode-ai/util/array"
import { findLast } from "@opencode-ai/util/array"
import { same } from "@/utils/same"
import { Icon } from "@opencode-ai/ui/icon"
import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
@@ -127,7 +128,7 @@ export function SessionContextTab() {
const usd = createMemo(
() =>
new Intl.NumberFormat(language.intl(), {
new Intl.NumberFormat(language.locale(), {
style: "currency",
currency: "USD",
}),
@@ -135,7 +136,7 @@ export function SessionContextTab() {
const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
const ctx = createMemo(() => metrics().context)
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
const formatter = createMemo(() => createSessionContextFormatter(language.locale()))
const cost = createMemo(() => {
return usd().format(metrics().totalCost)
@@ -199,7 +200,7 @@ export function SessionContextTab() {
const stats = [
{ 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.model", value: modelLabel },
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
@@ -212,8 +213,8 @@ export function SessionContextTab() {
label: "context.stats.cacheTokens",
value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`,
},
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.locale()) },
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.locale()) },
{ label: "context.stats.totalCost", value: cost },
{ label: "context.stats.sessionCreated", value: () => formatter().time(info()?.time.created) },
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
@@ -306,7 +307,7 @@ export function SessionContextTab() {
<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>{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>
)}
</For>

View File

@@ -138,12 +138,12 @@ function useSessionShare(args: {
globalSDK: ReturnType<typeof useGlobalSDK>
currentSession: () =>
| {
id: string
share?: {
url?: string
}
}
| undefined
sessionID: () => string | undefined
projectDirectory: () => string
platform: ReturnType<typeof usePlatform>
}) {
@@ -167,11 +167,11 @@ function useSessionShare(args: {
})
const shareSession = () => {
const sessionID = args.sessionID()
if (!sessionID || state.share) return
const session = args.currentSession()
if (!session || state.share) return
setState("share", true)
args.globalSDK.client.session
.share({ sessionID, directory: args.projectDirectory() })
.share({ sessionID: session.id, directory: args.projectDirectory() })
.catch((error) => {
console.error("Failed to share session", error)
})
@@ -181,11 +181,11 @@ function useSessionShare(args: {
}
const unshareSession = () => {
const sessionID = args.sessionID()
if (!sessionID || state.unshare) return
const session = args.currentSession()
if (!session || state.unshare) return
setState("unshare", true)
args.globalSDK.client.session
.unshare({ sessionID, directory: args.projectDirectory() })
.unshare({ sessionID: session.id, directory: args.projectDirectory() })
.catch((error) => {
console.error("Failed to unshare session", error)
})
@@ -243,9 +243,9 @@ export function SessionHeader() {
})
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 showShare = createMemo(() => shareEnabled() && !!params.id)
const showShare = createMemo(() => shareEnabled() && !!currentSession())
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey))
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 opening = createMemo(() => openRequest.app !== undefined)
const selectApp = (app: OpenApp) => {
if (!options().some((item) => item.id === app)) return
setPrefs("app", app)
}
createEffect(() => {
const value = prefs.app
if (options().some((o) => o.id === value)) return
setPrefs("app", options()[0]?.id ?? "finder")
})
const openDir = (app: OpenApp) => {
if (opening() || !canOpen() || !platform.openPath) return
@@ -346,7 +347,6 @@ export function SessionHeader() {
const share = useSessionShare({
globalSDK,
currentSession,
sessionID: () => params.id,
projectDirectory,
platform,
})
@@ -458,7 +458,7 @@ export function SessionHeader() {
value={current().id}
onChange={(value) => {
if (!OPEN_APPS.includes(value as OpenApp)) return
selectApp(value as OpenApp)
setPrefs("app", value as OpenApp)
}}
>
<For each={options()}>

View File

@@ -51,26 +51,26 @@ export function NewSessionView(props: NewSessionViewProps) {
return (
<div class={ROOT_CLASS}>
<div class="text-20-medium text-text-weaker">{language.t("command.session.new")}</div>
<div class="flex justify-center items-start gap-3 min-h-5">
<Icon name="folder" size="small" class="mt-0.5 shrink-0" />
<div class="text-12-medium text-text-weak select-text leading-5">
<div class="flex justify-center items-center gap-3">
<Icon name="folder" size="small" />
<div class="text-12-medium text-text-weak select-text">
{getDirectory(projectRoot())}
<span class="text-text-strong">{getFilename(projectRoot())}</span>
</div>
</div>
<div class="flex justify-center items-start gap-3 min-h-5">
<Icon name="branch" size="small" class="mt-0.5 shrink-0" />
<div class="text-12-medium text-text-weak select-text leading-5">{label(current())}</div>
<div class="flex justify-center items-center gap-1">
<Icon name="branch" size="small" />
<div class="text-12-medium text-text-weak select-text ml-2">{label(current())}</div>
</div>
<Show when={sync.project}>
{(project) => (
<div class="flex justify-center items-start gap-3 min-h-5">
<Icon name="pencil-line" size="small" class="mt-0.5 shrink-0" />
<div class="text-12-medium text-text-weak leading-5">
<div class="flex justify-center items-center gap-3">
<Icon name="pencil-line" size="small" />
<div class="text-12-medium text-text-weak">
{language.t("session.new.lastModified")}&nbsp;
<span class="text-text-strong">
{DateTime.fromMillis(project().time.updated ?? project().time.created)
.setLocale(language.intl())
.setLocale(language.locale())
.toRelative()}
</span>
</div>

View File

@@ -288,6 +288,29 @@ export const SettingsGeneral: Component = () => {
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
>
<div data-action="settings-feed-shell-tool-parts-expanded">
<Switch
checked={settings.general.shellToolPartsExpanded()}
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.editToolPartsExpanded.title")}
description={language.t("settings.general.row.editToolPartsExpanded.description")}
>
<div data-action="settings-feed-edit-tool-parts-expanded">
<Switch
checked={settings.general.editToolPartsExpanded()}
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
/>
</div>
</SettingsRow>
</div>
</div>
)

View File

@@ -17,7 +17,6 @@ type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[num
const PROVIDER_NOTES = [
{ 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.startsWith("github-copilot"), key: "dialog.provider.copilot.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">
<ProviderIcon id={item.id} class="size-5 shrink-0 icon-strong-base" />
<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"}>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
<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>
</div>
<Show when={note(item.id)}>

View File

@@ -202,26 +202,29 @@ export function StatusPopover() {
triggerAs={Button}
triggerProps={{
variant: "ghost",
class: "titlebar-icon w-6 h-6 p-0 box-border",
"aria-label": language.t("status.popover.trigger"),
class:
"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 },
}}
trigger={
<div class="flex size-4 items-center justify-center">
<div
classList={{
"size-1.5 rounded-full": true,
"bg-icon-success-base": overallHealthy(),
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
"bg-border-weak-base": server.healthy() === undefined,
}}
/>
<div class="flex items-center gap-0.5">
<div class="size-4 flex items-center justify-center">
<div
classList={{
"size-1.5 rounded-full": true,
"bg-icon-success-base": overallHealthy(),
"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>
}
class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
gutter={4}
placement="bottom-end"
shift={-168}
shift={-136}
>
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
<Tabs

View File

@@ -1,7 +1,7 @@
import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
import { showToast } from "@opencode-ai/ui/toast"
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 { matchKeybind, parseKeybind } from "@/context/command"
import { useLanguage } from "@/context/language"
@@ -18,7 +18,7 @@ const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY
onSubmit?: () => void
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
onCleanup?: (pty: LocalPTY) => void
onConnect?: () => void
onConnectError?: (error: unknown) => void
}
@@ -126,8 +126,8 @@ const persistTerminal = (input: {
term: Term | undefined
addon: SerializeAddon | undefined
cursor: number
id: string
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
pty: LocalPTY
onCleanup?: (pty: LocalPTY) => void
}) => {
if (!input.addon || !input.onCleanup || !input.term) return
const buffer = (() => {
@@ -140,7 +140,7 @@ const persistTerminal = (input: {
})()
input.onCleanup({
id: input.id,
...input.pty,
buffer,
cursor: input.cursor,
rows: input.term.rows,
@@ -158,19 +158,6 @@ export const Terminal = (props: TerminalProps) => {
const server = useServer()
let container!: HTMLDivElement
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 term: Term | undefined
let ghostty: Ghostty
@@ -203,7 +190,7 @@ export const Terminal = (props: TerminalProps) => {
const pushSize = (cols: number, rows: number) => {
return sdk.client.pty
.update({
ptyID: id,
ptyID: local.pty.id,
size: { cols, rows },
})
.catch((err) => {
@@ -232,7 +219,7 @@ export const Terminal = (props: TerminalProps) => {
}
}
const terminalColors = createMemo(getTerminalColors)
const [terminalColors, setTerminalColors] = createSignal<TerminalColors>(getTerminalColors())
const scheduleFit = () => {
if (disposed) return
@@ -272,7 +259,8 @@ export const Terminal = (props: TerminalProps) => {
}
createEffect(() => {
const colors = terminalColors()
const colors = getTerminalColors()
setTerminalColors(colors)
if (!term) return
setOptionIfSupported(term, "theme", colors)
})
@@ -332,6 +320,18 @@ export const Terminal = (props: TerminalProps) => {
const mod = loaded.mod
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({
cursorBlink: true,
cursorStyle: "bar",
@@ -428,14 +428,14 @@ export const Terminal = (props: TerminalProps) => {
await write(restore)
fit.fit()
scheduleSize(t.cols, t.rows)
if (scrollY !== undefined) t.scrollToLine(scrollY)
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
startResize()
} else {
fit.fit()
scheduleSize(t.cols, t.rows)
if (restore) {
await write(restore)
if (scrollY !== undefined) t.scrollToLine(scrollY)
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
}
startResize()
}
@@ -447,9 +447,9 @@ export const Terminal = (props: TerminalProps) => {
const once = { value: 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("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.username = server.current?.http.username ?? ""
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)
const finalize = () => {
persistTerminal({ term, addon: serializeAddon, cursor, id, onCleanup: props.onCleanup })
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
cleanup()
}

View File

@@ -157,7 +157,6 @@ export function Titlebar() {
<header
class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
style={{ "min-height": minHeight() }}
data-tauri-drag-region
onMouseDown={drag}
onDblClick={maximize}
>
@@ -277,7 +276,6 @@ export function Titlebar() {
"flex items-center min-w-0 justify-end": true,
"pr-2": !windows(),
}}
data-tauri-drag-region
onMouseDown={drag}
>
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />

View File

@@ -4,7 +4,6 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { isEditableTarget } from "@/utils/dom"
import { Persist, persisted } from "@/utils/persist"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
@@ -178,6 +177,14 @@ export function formatKeybind(config: string): string {
return IS_MAC ? parts.join("") : parts.join("+")
}
function isEditableTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false
if (target.isContentEditable) return true
if (target.closest("[contenteditable='true']")) return true
if (target.closest("input, textarea, select")) return true
return false
}
export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
name: "Command",
init: () => {

View File

@@ -11,6 +11,7 @@ import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
import {
createContext,
createEffect,
getOwner,
Match,
onCleanup,
@@ -34,6 +35,7 @@ import { trimSessions } from "./global-sync/session-trim"
import type { ProjectMeta } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
import { sanitizeProject } from "./global-sync/utils"
import { usePlatform } from "./platform"
import { formatServerError } from "@/utils/server-errors"
type GlobalStore = {
@@ -52,6 +54,7 @@ type GlobalStore = {
function createGlobalSync() {
const globalSDK = useGlobalSDK()
const platform = usePlatform()
const language = useLanguage()
const owner = getOwner()
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 sessionMeta = new Map<string, { limit: number }>()
const [projectCache, setProjectCache, projectInit] = persisted(
const [projectCache, setProjectCache, , projectCacheReady] = persisted(
Persist.global("globalSync.project", ["globalSync.project.v1"]),
createStore({ value: [] as Project[] }),
)
@@ -77,57 +80,6 @@ function createGlobalSync() {
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) => {
if (!sessionID) return
if (!todos) {
@@ -175,6 +127,30 @@ function createGlobalSync() {
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) {
const pending = sessionLoads.get(directory)
if (pending) return pending
@@ -228,7 +204,10 @@ function createGlobalSync() {
showToast({
variant: "error",
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],
vcsCache: cache,
loadSessions,
translate: language.t,
unknownError: language.t("error.chain.unknown"),
invalidConfigurationError: language.t("error.server.invalidConfiguration"),
})
})()
@@ -279,7 +259,13 @@ function createGlobalSync() {
event,
project: globalStore.project,
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") {
for (const directory of Object.keys(children.children)) {
@@ -327,9 +313,10 @@ function createGlobalSync() {
url: globalSDK.url,
}),
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 }),
setGlobalStore: setBootStore,
setGlobalStore,
})
}
@@ -353,8 +340,7 @@ function createGlobalSync() {
.update({ config })
.then(bootstrap)
.then(() => {
setGlobalStore("reload", undefined)
queue.refresh()
setGlobalStore("reload", "complete")
})
.catch((error) => {
setGlobalStore("reload", undefined)
@@ -364,7 +350,7 @@ function createGlobalSync() {
return {
data: globalStore,
set,
set: setGlobalStore,
get ready() {
return globalStore.ready
},

View File

@@ -36,7 +36,8 @@ export async function bootstrapGlobal(input: {
connectErrorTitle: string
connectErrorDescription: string
requestFailedTitle: string
translate: (key: string, vars?: Record<string, string | number>) => string
unknownError: string
invalidConfigurationError: string
formatMoreCount: (count: number) => string
setGlobalStore: SetStoreFunction<GlobalStore>
}) {
@@ -90,7 +91,10 @@ export async function bootstrapGlobal(input: {
const results = await Promise.allSettled(tasks)
const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
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) : ""
showToast({
variant: "error",
@@ -118,7 +122,8 @@ export async function bootstrapDirectory(input: {
setStore: SetStoreFunction<State>
vcsCache: VcsCache
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")
@@ -140,7 +145,10 @@ export async function bootstrapDirectory(input: {
showToast({
variant: "error",
title: `Failed to reload ${project}`,
description: formatServerError(err, input.translate),
description: formatServerError(err, {
unknown: input.unknownError,
invalidConfiguration: input.invalidConfigurationError,
}),
})
input.setStore("status", "partial")
return

View File

@@ -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 { Persist, persisted } from "@/utils/persist"
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")
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, () =>
persisted(
@@ -153,12 +154,10 @@ export function createChildStoreManager(input: {
const init = () =>
createRoot((dispose) => {
const initialMeta = meta[0].value
const initialIcon = icon[0].value
const child = createStore<State>({
project: "",
projectMeta: initialMeta,
icon: initialIcon,
projectMeta: meta[0].value,
icon: icon[0].value,
provider: { all: [], connected: [], default: {} },
config: {},
path: { state: "", config: "", worktree: "", directory: "", home: "" },
@@ -182,27 +181,16 @@ export function createChildStoreManager(input: {
children[directory] = child
disposers.set(directory, dispose)
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
if (!(init instanceof Promise)) return
void init.then(() => {
if (children[directory] !== child) return
run()
})
}
onPersistedInit(vcs[2], () => {
createEffect(() => {
if (!vcsReady()) return
const cached = vcsStore.value
if (!cached?.branch) return
child[1]("vcs", (value) => value ?? cached)
})
onPersistedInit(meta[2], () => {
if (child[0].projectMeta !== initialMeta) return
createEffect(() => {
child[1]("projectMeta", meta[0].value)
})
onPersistedInit(icon[2], () => {
if (child[0].icon !== initialIcon) return
createEffect(() => {
child[1]("icon", icon[0].value)
})
})

View File

@@ -84,26 +84,6 @@ const LOCALES: readonly Locale[] = [
"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> = {
en: "language.en",
zh: "language.zh",
@@ -217,7 +197,6 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
)
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
const intl = createMemo(() => INTL[locale()])
const dict = createMemo<Dictionary>(() => DICT[locale()])
@@ -234,7 +213,6 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
return {
ready,
locale,
intl,
locales: LOCALES,
label,
t,

View File

@@ -7,10 +7,8 @@ import { useServer } from "./server"
import { usePlatform } from "./platform"
import { Project } from "@opencode-ai/sdk/v2"
import { Persist, persisted, removePersisted } from "@/utils/persist"
import { decode64 } from "@/utils/base64"
import { same } from "@opencode-ai/util/array"
import { same } from "@/utils/same"
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 DEFAULT_PANEL_WIDTH = 344
@@ -98,38 +96,6 @@ function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string):
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({
name: "Layout",
init: () => {
@@ -181,46 +147,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
})()
const sessionTabs = value.sessionTabs
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
}
if (migratedSidebar === sidebar && migratedReview === review && migratedFileTree === fileTree) return value
return {
...value,
sidebar: migratedSidebar,
review: migratedReview,
fileTree: migratedFileTree,
sessionTabs: migratedSessionTabs,
}
}
@@ -813,26 +745,22 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
tabs(sessionKey: string | Accessor<string>) {
const key = createSessionKeyReader(sessionKey, ensureKey)
const path = createMemo(() => sessionPath(key()))
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
const normalize = (tab: string) => normalizeSessionTab(path(), tab)
const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all)
return {
tabs,
active: createMemo(() => tabs().active),
all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
setActive(tab: string | undefined) {
const session = key()
const next = tab ? normalize(tab) : tab
if (!store.sessionTabs[session]) {
setStore("sessionTabs", session, { all: [], active: next })
setStore("sessionTabs", session, { all: [], active: tab })
} else {
setStore("sessionTabs", session, "active", next)
setStore("sessionTabs", session, "active", tab)
}
},
setAll(all: string[]) {
const session = key()
const next = normalizeAll(all).filter((tab) => tab !== "review")
const next = all.filter((tab) => tab !== "review")
if (!store.sessionTabs[session]) {
setStore("sessionTabs", session, { all: next, active: undefined })
} else {
@@ -841,7 +769,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
async open(tab: string) {
const session = key()
const next = nextSessionTabsForOpen(store.sessionTabs[session], normalize(tab))
const next = nextSessionTabsForOpen(store.sessionTabs[session], tab)
setStore("sessionTabs", session, next)
},
close(tab: string) {

View File

@@ -35,8 +35,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const agent = (() => {
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
const models = useModels()
const [store, setStore] = createStore<{
current?: string
}>({
@@ -55,17 +53,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
setStore("current", undefined)
return
}
const match = name ? available.find((x) => x.name === name) : undefined
const value = match ?? available[0]
if (!value) return
setStore("current", value.name)
if (!value.model) return
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)
if (name && available.some((x) => x.name === name)) {
setStore("current", name)
return
}
setStore("current", available[0].name)
},
move(direction: 1 | -1) {
const available = list()
@@ -79,13 +71,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const value = available[next]
if (!value) return
setStore("current", value.name)
if (!value.model) return
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)
if (value.model)
setModel({
providerID: value.model.providerID,
modelID: value.model.modelID,
})
},
}
})()

View File

@@ -132,6 +132,28 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
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[]) => {
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
const directory = e.name
const time = Date.now()
if (event.type === "session.error") {
markIdleSuppressed(directory, event.properties.sessionID)
}
if (event.type === "session.idle") {
handleSessionIdle(directory, event, time)
if (shouldSuppressIdle(directory, event.properties.sessionID)) return
handleSessionIdle(directory, event, Date.now())
return
}
handleSessionError(directory, event, time)
handleSessionError(directory, event, Date.now())
})
onCleanup(() => {
meta.disposed = true

View File

@@ -31,13 +31,13 @@ describe("autoRespondsPermission", () => {
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 autoAccept = {
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", () => {

View File

@@ -37,5 +37,5 @@ export function autoRespondsPermission(
const value = sessionLineage(session, permission.sessionID)
.map((id) => accepted(autoAccept, id, directory))
.find((item): item is boolean => item !== undefined)
return value ?? false
return value ?? true
}

View File

@@ -23,6 +23,8 @@ export interface Settings {
autoSave: boolean
releaseNotes: boolean
showReasoningSummaries: boolean
shellToolPartsExpanded: boolean
editToolPartsExpanded: boolean
}
updates: {
startup: boolean
@@ -44,6 +46,8 @@ const defaultSettings: Settings = {
autoSave: true,
releaseNotes: true,
showReasoningSummaries: false,
shellToolPartsExpanded: true,
editToolPartsExpanded: false,
},
updates: {
startup: true,
@@ -129,6 +133,20 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setShowReasoningSummaries(value: boolean) {
setStore("general", "showReasoningSummaries", value)
},
shellToolPartsExpanded: withFallback(
() => store.general?.shellToolPartsExpanded,
defaultSettings.general.shellToolPartsExpanded,
),
setShellToolPartsExpanded(value: boolean) {
setStore("general", "shellToolPartsExpanded", value)
},
editToolPartsExpanded: withFallback(
() => store.general?.editToolPartsExpanded,
defaultSettings.general.editToolPartsExpanded,
),
setEditToolPartsExpanded(value: boolean) {
setStore("general", "editToolPartsExpanded", value)
},
},
updates: {
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),

View File

@@ -541,6 +541,12 @@ export const dict = {
"settings.general.row.theme.description": "تخصيص سمة OpenCode.",
"settings.general.row.font.title": "الخط",
"settings.general.row.font.description": "تخصيص الخط الأحادي المستخدم في كتل التعليمات البرمجية",
"settings.general.row.shellToolPartsExpanded.title": "توسيع أجزاء أداة shell",
"settings.general.row.shellToolPartsExpanded.description":
"إظهار أجزاء أداة shell موسعة بشكل افتراضي في الشريط الزمني",
"settings.general.row.editToolPartsExpanded.title": "توسيع أجزاء أداة edit",
"settings.general.row.editToolPartsExpanded.description":
"إظهار أجزاء أدوات edit و write و patch موسعة بشكل افتراضي في الشريط الزمني",
"settings.general.row.wayland.title": "استخدام Wayland الأصلي",
"settings.general.row.wayland.description": "تعطيل التراجع إلى X11 على Wayland. يتطلب إعادة التشغيل.",
"settings.general.row.wayland.tooltip":

View File

@@ -547,6 +547,12 @@ export const dict = {
"settings.general.row.theme.description": "Personalize como o OpenCode é tematizado.",
"settings.general.row.font.title": "Fonte",
"settings.general.row.font.description": "Personalize a fonte monoespaçada usada em blocos de código",
"settings.general.row.shellToolPartsExpanded.title": "Expandir partes da ferramenta shell",
"settings.general.row.shellToolPartsExpanded.description":
"Mostrar partes da ferramenta shell expandidas por padrão na linha do tempo",
"settings.general.row.editToolPartsExpanded.title": "Expandir partes da ferramenta de edição",
"settings.general.row.editToolPartsExpanded.description":
"Mostrar partes das ferramentas de edição, escrita e patch expandidas por padrão na linha do tempo",
"settings.general.row.wayland.title": "Usar Wayland nativo",
"settings.general.row.wayland.description": "Desabilitar fallback X11 no Wayland. Requer reinicialização.",
"settings.general.row.wayland.tooltip":

View File

@@ -613,6 +613,12 @@ export const dict = {
"settings.general.row.font.title": "Font",
"settings.general.row.font.description": "Prilagodi monospace font koji se koristi u blokovima koda",
"settings.general.row.shellToolPartsExpanded.title": "Proširi dijelove shell alata",
"settings.general.row.shellToolPartsExpanded.description":
"Prikaži dijelove shell alata podrazumijevano proširene na vremenskoj traci",
"settings.general.row.editToolPartsExpanded.title": "Proširi dijelove alata za uređivanje",
"settings.general.row.editToolPartsExpanded.description":
"Prikaži dijelove alata za uređivanje, pisanje i patch podrazumijevano proširene na vremenskoj traci",
"settings.general.row.wayland.title": "Koristi nativni Wayland",
"settings.general.row.wayland.description": "Onemogući X11 fallback na Waylandu. Zahtijeva restart.",
"settings.general.row.wayland.tooltip":

View File

@@ -608,6 +608,11 @@ export const dict = {
"settings.general.row.font.title": "Skrifttype",
"settings.general.row.font.description": "Tilpas mono-skrifttypen brugt i kodeblokke",
"settings.general.row.shellToolPartsExpanded.title": "Udvid shell-værktøjsdele",
"settings.general.row.shellToolPartsExpanded.description": "Vis shell-værktøjsdele udvidet som standard i tidslinjen",
"settings.general.row.editToolPartsExpanded.title": "Udvid edit-værktøjsdele",
"settings.general.row.editToolPartsExpanded.description":
"Vis edit-, write- og patch-værktøjsdele udvidet som standard i tidslinjen",
"settings.general.row.wayland.title": "Brug native Wayland",
"settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Kræver genstart.",
"settings.general.row.wayland.tooltip":

View File

@@ -556,6 +556,12 @@ export const dict = {
"settings.general.row.theme.description": "Das Thema von OpenCode anpassen.",
"settings.general.row.font.title": "Schriftart",
"settings.general.row.font.description": "Die in Codeblöcken verwendete Monospace-Schriftart anpassen",
"settings.general.row.shellToolPartsExpanded.title": "Shell-Tool-Abschnitte ausklappen",
"settings.general.row.shellToolPartsExpanded.description":
"Shell-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen",
"settings.general.row.editToolPartsExpanded.title": "Edit-Tool-Abschnitte ausklappen",
"settings.general.row.editToolPartsExpanded.description":
"Edit-, Write- und Patch-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen",
"settings.general.row.wayland.title": "Natives Wayland verwenden",
"settings.general.row.wayland.description": "X11-Fallback unter Wayland deaktivieren. Erfordert Neustart.",
"settings.general.row.wayland.tooltip":

View File

@@ -636,6 +636,13 @@ export const dict = {
"settings.general.row.font.description": "Customise the mono font used in code blocks",
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
"settings.general.row.shellToolPartsExpanded.description":
"Show shell tool parts expanded by default in the timeline",
"settings.general.row.editToolPartsExpanded.title": "Expand edit tool parts",
"settings.general.row.editToolPartsExpanded.description":
"Show edit, write, and patch tool parts expanded by default in the timeline",
"settings.general.row.wayland.title": "Use native Wayland",
"settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.",
"settings.general.row.wayland.tooltip":

View File

@@ -616,6 +616,12 @@ export const dict = {
"settings.general.row.font.title": "Fuente",
"settings.general.row.font.description": "Personaliza la fuente monoespaciada usada en bloques de código",
"settings.general.row.shellToolPartsExpanded.title": "Expandir partes de la herramienta shell",
"settings.general.row.shellToolPartsExpanded.description":
"Mostrar las partes de la herramienta shell expandidas por defecto en la línea de tiempo",
"settings.general.row.editToolPartsExpanded.title": "Expandir partes de la herramienta de edición",
"settings.general.row.editToolPartsExpanded.description":
"Mostrar las partes de las herramientas de edición, escritura y parcheado expandidas por defecto en la línea de tiempo",
"settings.general.row.wayland.title": "Usar Wayland nativo",
"settings.general.row.wayland.description": "Deshabilitar fallback a X11 en Wayland. Requiere reinicio.",
"settings.general.row.wayland.tooltip":

View File

@@ -553,6 +553,12 @@ export const dict = {
"settings.general.row.theme.description": "Personnaliser le thème d'OpenCode.",
"settings.general.row.font.title": "Police",
"settings.general.row.font.description": "Personnaliser la police mono utilisée dans les blocs de code",
"settings.general.row.shellToolPartsExpanded.title": "Développer les parties de l'outil shell",
"settings.general.row.shellToolPartsExpanded.description":
"Afficher les parties de l'outil shell développées par défaut dans la chronologie",
"settings.general.row.editToolPartsExpanded.title": "Développer les parties de l'outil edit",
"settings.general.row.editToolPartsExpanded.description":
"Afficher les parties des outils edit, write et patch développées par défaut dans la chronologie",
"settings.general.row.wayland.title": "Utiliser Wayland natif",
"settings.general.row.wayland.description": "Désactiver le repli X11 sur Wayland. Nécessite un redémarrage.",
"settings.general.row.wayland.tooltip":

View File

@@ -545,6 +545,12 @@ export const dict = {
"settings.general.row.theme.description": "OpenCodeのテーマをカスタマイズします。",
"settings.general.row.font.title": "フォント",
"settings.general.row.font.description": "コードブロックで使用する等幅フォントをカスタマイズします",
"settings.general.row.shellToolPartsExpanded.title": "shell ツールパーツを展開",
"settings.general.row.shellToolPartsExpanded.description":
"タイムラインで shell ツールパーツをデフォルトで展開して表示します",
"settings.general.row.editToolPartsExpanded.title": "edit ツールパーツを展開",
"settings.general.row.editToolPartsExpanded.description":
"タイムラインで edit、write、patch ツールパーツをデフォルトで展開して表示します",
"settings.general.row.wayland.title": "ネイティブWaylandを使用",
"settings.general.row.wayland.description": "WaylandでのX11フォールバックを無効にします。再起動が必要です。",
"settings.general.row.wayland.tooltip":

View File

@@ -546,6 +546,12 @@ export const dict = {
"settings.general.row.theme.description": "OpenCode 테마 사용자 지정",
"settings.general.row.font.title": "글꼴",
"settings.general.row.font.description": "코드 블록에 사용되는 고정폭 글꼴 사용자 지정",
"settings.general.row.shellToolPartsExpanded.title": "shell 도구 파트 펼치기",
"settings.general.row.shellToolPartsExpanded.description":
"타임라인에서 기본적으로 shell 도구 파트를 펼친 상태로 표시합니다",
"settings.general.row.editToolPartsExpanded.title": "edit 도구 파트 펼치기",
"settings.general.row.editToolPartsExpanded.description":
"타임라인에서 기본적으로 edit, write, patch 도구 파트를 펼친 상태로 표시합니다",
"settings.general.row.wayland.title": "네이티브 Wayland 사용",
"settings.general.row.wayland.description": "Wayland에서 X11 폴백을 비활성화합니다. 다시 시작해야 합니다.",
"settings.general.row.wayland.tooltip":

View File

@@ -616,6 +616,11 @@ export const dict = {
"settings.general.row.font.title": "Skrift",
"settings.general.row.font.description": "Tilpass mono-skriften som brukes i kodeblokker",
"settings.general.row.shellToolPartsExpanded.title": "Utvid shell-verktøydeler",
"settings.general.row.shellToolPartsExpanded.description": "Vis shell-verktøydeler utvidet som standard i tidslinjen",
"settings.general.row.editToolPartsExpanded.title": "Utvid edit-verktøydeler",
"settings.general.row.editToolPartsExpanded.description":
"Vis edit-, write- og patch-verktøydeler utvidet som standard i tidslinjen",
"settings.general.row.wayland.title": "Bruk innebygd Wayland",
"settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Krever omstart.",
"settings.general.row.wayland.tooltip":

View File

@@ -546,6 +546,12 @@ export const dict = {
"settings.general.row.theme.description": "Dostosuj motyw OpenCode.",
"settings.general.row.font.title": "Czcionka",
"settings.general.row.font.description": "Dostosuj czcionkę mono używaną w blokach kodu",
"settings.general.row.shellToolPartsExpanded.title": "Rozwijaj elementy narzędzia shell",
"settings.general.row.shellToolPartsExpanded.description":
"Domyślnie pokazuj rozwinięte elementy narzędzia shell na osi czasu",
"settings.general.row.editToolPartsExpanded.title": "Rozwijaj elementy narzędzia edit",
"settings.general.row.editToolPartsExpanded.description":
"Domyślnie pokazuj rozwinięte elementy narzędzi edit, write i patch na osi czasu",
"settings.general.row.wayland.title": "Użyj natywnego Wayland",
"settings.general.row.wayland.description": "Wyłącz fallback X11 na Wayland. Wymaga restartu.",
"settings.general.row.wayland.tooltip":

View File

@@ -614,6 +614,12 @@ export const dict = {
"settings.general.row.font.title": "Шрифт",
"settings.general.row.font.description": "Настройте моноширинный шрифт для блоков кода",
"settings.general.row.shellToolPartsExpanded.title": "Разворачивать элементы инструмента shell",
"settings.general.row.shellToolPartsExpanded.description":
"Показывать элементы инструмента shell в ленте развернутыми по умолчанию",
"settings.general.row.editToolPartsExpanded.title": "Разворачивать элементы инструмента edit",
"settings.general.row.editToolPartsExpanded.description":
"Показывать элементы инструментов edit, write и patch в ленте развернутыми по умолчанию",
"settings.general.row.wayland.title": "Использовать нативный Wayland",
"settings.general.row.wayland.description": "Отключить X11 fallback на Wayland. Требуется перезапуск.",
"settings.general.row.wayland.tooltip":

View File

@@ -608,6 +608,11 @@ export const dict = {
"settings.general.row.font.title": "ฟอนต์",
"settings.general.row.font.description": "ปรับแต่งฟอนต์โมโนที่ใช้ในบล็อกโค้ด",
"settings.general.row.shellToolPartsExpanded.title": "ขยายส่วนเครื่องมือ shell",
"settings.general.row.shellToolPartsExpanded.description": "แสดงส่วนเครื่องมือ shell แบบขยายตามค่าเริ่มต้นในไทม์ไลน์",
"settings.general.row.editToolPartsExpanded.title": "ขยายส่วนเครื่องมือ edit",
"settings.general.row.editToolPartsExpanded.description":
"แสดงส่วนเครื่องมือ edit, write และ patch แบบขยายตามค่าเริ่มต้นในไทม์ไลน์",
"settings.general.row.wayland.title": "ใช้ Wayland แบบเนทีฟ",
"settings.general.row.wayland.description": "ปิดใช้งาน X11 fallback บน Wayland ต้องรีสตาร์ท",
"settings.general.row.wayland.tooltip": "บน Linux ที่มีจอภาพรีเฟรชเรตแบบผสม Wayland แบบเนทีฟอาจเสถียรกว่า",

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.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.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.format": "http:// veya https:// ile başlamalı",
"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.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.11": "Veritabanı sorgularını optimize et",
"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.15": "Kodumu en iyi uygulamalar için incele",
"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.22": "Bu uç nokta için önbellekleme uygula",
"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.popover.emptyResults": "Eşleşen sonuç yok",
"prompt.popover.emptyCommands": "Eşleşen komut yok",
"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.skill": "beceri",
"prompt.slash.badge.mcp": "mcp",
@@ -343,7 +343,7 @@ export const dict = {
"dialog.project.edit.icon.recommended": "Önerilen: 128x128px",
"dialog.project.edit.color": "Renk",
"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.placeholder": "örneğin bun install",
@@ -454,7 +454,7 @@ export const dict = {
"error.page.version": "Sürüm: {{version}}",
"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ı?",
"directory.error.invalidUrl": "URL'de geçersiz dizin.",
@@ -623,6 +623,13 @@ export const dict = {
"settings.general.row.font.description": "Kod bloklarında kullanılan monospace yazı tipini özelleştirin",
"settings.general.row.reasoningSummaries.title": "Akıl yürütme özetlerini göster",
"settings.general.row.reasoningSummaries.description": "Zaman çizelgesinde model akıl yürütme özetlerini görüntüle",
"settings.general.row.shellToolPartsExpanded.title": "Kabuk araç bileşenlerini genişlet",
"settings.general.row.shellToolPartsExpanded.description":
"Zaman çizelgesinde kabuk araç bileşenlerini varsayılan olarak genişletilmiş göster",
"settings.general.row.editToolPartsExpanded.title": "Düzenleme araç bileşenlerini genişlet",
"settings.general.row.editToolPartsExpanded.description":
"Zaman çizelgesinde düzenleme, yazma ve yama araç bileşenlerini varsayılan olarak genişletilmiş göster",
"settings.general.row.wayland.title": "Yerel Wayland kullan",
"settings.general.row.wayland.description":
"Wayland'da X11 geri dönüşünü devre dışı bırak. Yeniden başlatma gerektirir.",

View File

@@ -607,6 +607,10 @@ export const dict = {
"settings.general.row.theme.description": "自定义 OpenCode 的主题。",
"settings.general.row.font.title": "字体",
"settings.general.row.font.description": "自定义代码块使用的等宽字体",
"settings.general.row.shellToolPartsExpanded.title": "展开 shell 工具部分",
"settings.general.row.shellToolPartsExpanded.description": "默认在时间线中展开 shell 工具部分",
"settings.general.row.editToolPartsExpanded.title": "展开编辑工具部分",
"settings.general.row.editToolPartsExpanded.description": "默认在时间线中展开 edit、write 和 patch 工具部分",
"settings.general.row.wayland.title": "使用原生 Wayland",
"settings.general.row.wayland.description": "在 Wayland 上禁用 X11 回退。需要重启。",
"settings.general.row.wayland.tooltip": "在混合刷新率显示器的 Linux 系统上,原生 Wayland 可能更稳定。",

View File

@@ -603,6 +603,10 @@ export const dict = {
"settings.general.row.font.title": "字型",
"settings.general.row.font.description": "自訂程式碼區塊使用的等寬字型",
"settings.general.row.shellToolPartsExpanded.title": "展開 shell 工具區塊",
"settings.general.row.shellToolPartsExpanded.description": "在時間軸中預設展開 shell 工具區塊",
"settings.general.row.editToolPartsExpanded.title": "展開 edit 工具區塊",
"settings.general.row.editToolPartsExpanded.description": "在時間軸中預設展開 edit、write 和 patch 工具區塊",
"settings.general.row.wayland.title": "使用原生 Wayland",
"settings.general.row.wayland.description": "在 Wayland 上停用 X11 後備模式。需要重新啟動。",
"settings.general.row.wayland.tooltip": "在混合更新率螢幕的 Linux 系統上,原生 Wayland 可能更穩定。",

View File

@@ -42,9 +42,7 @@ import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry"
import { playSound, soundSrc } from "@/utils/sound"
import { createAim } from "@/utils/aim"
import { setNavigate } from "@/utils/notification-click"
import { Worktree as WorktreeState } from "@/utils/worktree"
import { setSessionHandoff } from "@/pages/session/handoff"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
@@ -61,19 +59,14 @@ import { useLanguage, type Locale } from "@/context/language"
import {
childMapByParent,
displayName,
effectiveWorkspaceOrder,
errorMessage,
getDraggableId,
latestRootSession,
sortedRootSessions,
syncWorkspaceOrder,
workspaceKey,
} from "./layout/helpers"
import {
collectNewSessionDeepLinks,
collectOpenProjectDeepLinks,
deepLinkEvent,
drainPendingDeepLinks,
} from "./layout/deep-links"
import { collectOpenProjectDeepLinks, deepLinkEvent, drainPendingDeepLinks } from "./layout/deep-links"
import { createInlineEditorController } from "./layout/inline-editor"
import {
LocalWorkspace,
@@ -114,7 +107,6 @@ export default function Layout(props: ParentProps) {
const notification = useNotification()
const permission = usePermission()
const navigate = useNavigate()
setNavigate(navigate)
const providers = useProviders()
const dialog = useDialog()
const command = useCommand()
@@ -489,6 +481,21 @@ export default function Layout(props: ParentProps) {
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(
on(
() => ({ ready: pageReady(), layoutReady: layoutReady(), dir: params.dir, list: layout.projects.list() }),
@@ -547,17 +554,29 @@ export default function Layout(props: ParentProps) {
return layout.sidebar.workspaces(project.worktree)()
})
const visibleSessionDirs = createMemo(() => {
createEffect(() => {
if (!pageReady()) return
if (!layoutReady()) return
const project = currentProject()
if (!project) return [] as string[]
if (!workspaceSetting()) return [project.worktree]
if (!project) return
const activeDir = currentDir()
return workspaceIds(project).filter((directory) => {
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
const active = directory === activeDir
return expanded || active
})
const local = project.worktree
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
const existing = store.workspaceOrder[project.worktree]
const merged = syncWorkspaceOrder(local, dirs, existing)
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(() => {
@@ -574,17 +593,25 @@ export default function Layout(props: ParentProps) {
})
const currentSessions = createMemo(() => {
const project = currentProject()
if (!project) return [] as Session[]
const now = Date.now()
const dirs = visibleSessionDirs()
if (dirs.length === 0) return [] as Session[]
const result: Session[] = []
for (const dir of dirs) {
const [dirStore] = globalSync.child(dir, { bootstrap: true })
const dirSessions = sortedRootSessions(dirStore, now)
result.push(...dirSessions)
if (workspaceSetting()) {
const dirs = workspaceIds(project)
const activeDir = currentDir()
const result: Session[] = []
for (const dir of dirs) {
const expanded = store.workspaceExpanded[dir] ?? dir === project.worktree
const active = dir === activeDir
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 = {
@@ -799,6 +826,7 @@ export default function Layout(props: ParentProps) {
}
navigateToSession(session)
queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
}
function navigateSessionByUnseen(offset: number) {
@@ -833,6 +861,7 @@ export default function Layout(props: ParentProps) {
}
navigateToSession(session)
queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
return
}
}
@@ -1065,90 +1094,34 @@ export default function Layout(props: ParentProps) {
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) {
if (!directory) return
const root = projectRoot(directory)
server.projects.touch(root)
const project = layout.projects.list().find((item) => item.worktree === root)
let dirs = project
? 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 dirs = Array.from(new Set([root, ...(store.workspaceOrder[root] ?? []), ...(project?.sandboxes ?? [])]))
const openSession = async (target: { directory: string; id: string }) => {
if (!canOpen(target.directory)) return false
const resolved = await globalSDK.client.session
.get({ sessionID: target.id })
.then((x) => x.data)
.catch(() => undefined)
if (!resolved?.directory) return false
if (!canOpen(resolved.directory)) return false
setStore("lastProjectSession", root, { directory: resolved.directory, id: resolved.id, at: Date.now() })
navigateWithSidebarReset(`/${base64Encode(resolved.directory)}/session/${resolved.id}`)
return true
const next = resolved?.directory ? resolved : target
setStore("lastProjectSession", root, { directory: next.directory, id: next.id, at: Date.now() })
navigateWithSidebarReset(`/${base64Encode(next.directory)}/session/${next.id}`)
}
const projectSession = store.lastProjectSession[root]
if (projectSession?.id) {
await refreshDirs(projectSession.directory)
const opened = await openSession(projectSession)
if (opened) return
clearLastProjectSession(root)
await openSession(projectSession)
return
}
const latest = latestRootSession(
dirs.map((item) => globalSync.child(item, { bootstrap: false })[0]),
Date.now(),
)
if (latest && (await openSession(latest))) {
if (latest) {
await openSession(latest)
return
}
@@ -1164,7 +1137,8 @@ export default function Layout(props: ParentProps) {
),
Date.now(),
)
if (fetched && (await openSession(fetched))) {
if (fetched) {
await openSession(fetched)
return
}
@@ -1183,20 +1157,9 @@ export default function Layout(props: ParentProps) {
const handleDeepLinks = (urls: string[]) => {
if (!server.isLocal()) return
for (const directory of collectOpenProjectDeepLinks(urls)) {
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(() => {
@@ -1232,28 +1195,11 @@ export default function Layout(props: ParentProps) {
}
function closeProject(directory: string) {
const list = layout.projects.list()
const index = list.findIndex((x) => x.worktree === directory)
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`)
const index = layout.projects.list().findIndex((x) => x.worktree === directory)
const next = layout.projects.list()[index + 1]
layout.projects.close(directory)
queueMicrotask(() => {
void navigateToProject(next.worktree)
})
if (next) navigateToProject(next.worktree)
else navigate("/")
}
function toggleProjectWorkspaces(project: LocalProject) {
@@ -1294,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
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)
const result = await globalSDK.client.worktree
@@ -1322,10 +1260,6 @@ export default function Layout(props: ParentProps) {
if (!result) return
if (workspaceKey(store.lastProjectSession[root]?.directory ?? "") === workspaceKey(directory)) {
clearLastProjectSession(root)
}
globalSync.set(
"project",
produce((draft) => {
@@ -1339,18 +1273,8 @@ export default function Layout(props: ParentProps) {
layout.projects.close(directory)
layout.projects.open(root)
if (shouldLeave) return
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`)
if (params.dir && currentDir() === directory) {
navigateToProject(root)
}
}
@@ -1453,12 +1377,8 @@ export default function Layout(props: ParentProps) {
})
const handleDelete = () => {
const leaveDeletedWorkspace = !!params.dir && workspaceKey(currentDir()) === workspaceKey(props.directory)
if (leaveDeletedWorkspace) {
navigateWithSidebarReset(`/${base64Encode(props.root)}/session`)
}
dialog.close()
void deleteWorkspace(props.root, props.directory, leaveDeletedWorkspace)
void deleteWorkspace(props.root, props.directory)
}
const description = () => {
@@ -1566,42 +1486,26 @@ export default function Layout(props: ParentProps) {
)
}
const activeRoute = {
session: "",
sessionProject: "",
}
createEffect(
on(
() => [pageReady(), params.dir, params.id, currentProject()?.worktree] as const,
([ready, dir, id]) => {
if (!ready || !dir) {
activeRoute.session = ""
activeRoute.sessionProject = ""
return
}
() => ({ ready: pageReady(), dir: params.dir, id: params.id }),
(value) => {
if (!value.ready) return
const dir = value.dir
const id = value.id
if (!dir || !id) return
const directory = decode64(dir)
if (!directory) return
const root = touchProjectRoute() ?? activeProjectRoot(directory)
if (!id) {
activeRoute.session = ""
activeRoute.sessionProject = ""
return
const at = Date.now()
setStore("lastProjectSession", projectRoot(directory), { directory, id, at })
notification.session.markViewed(id)
const expanded = untrack(() => store.workspaceExpanded[directory])
if (expanded === false) {
setStore("workspaceExpanded", directory, true)
}
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)
requestAnimationFrame(() => scrollToSession(id, `${directory}:${id}`))
},
{ defer: true },
),
)
@@ -1612,29 +1516,40 @@ export default function Layout(props: ParentProps) {
const loadedSessionDirs = new Set<string>()
createEffect(
on(
visibleSessionDirs,
(dirs) => {
if (dirs.length === 0) {
loadedSessionDirs.clear()
return
}
createEffect(() => {
const project = currentProject()
const workspaces = workspaceSetting()
const next = new Set<string>()
if (!project) {
loadedSessionDirs.clear()
return
}
const next = new Set(dirs)
for (const directory of next) {
if (loadedSessionDirs.has(directory)) continue
globalSync.project.loadSessions(directory)
}
if (workspaces) {
const activeDir = currentDir()
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
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()
for (const directory of next) {
loadedSessionDirs.add(directory)
}
},
{ defer: true },
),
)
if (!workspaces) {
next.add(project.worktree)
}
for (const directory of next) {
if (loadedSessionDirs.has(directory)) continue
globalSync.project.loadSessions(directory)
}
loadedSessionDirs.clear()
for (const directory of next) {
loadedSessionDirs.add(directory)
}
})
function handleDragStart(event: unknown) {
const id = getDraggableId(event)
@@ -1668,11 +1583,14 @@ export default function Layout(props: ParentProps) {
const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined
const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree])
if (pending && extra) return [local, extra, ...ordered.filter((item) => item !== local)]
if (!extra) return ordered
if (pending) return ordered
return [...ordered, extra]
const existing = store.workspaceOrder[project.worktree]
if (!existing) return extra ? [...dirs, extra] : dirs
const merged = syncWorkspaceOrder(local, dirs, existing)
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(() => {
@@ -1705,11 +1623,7 @@ export default function Layout(props: ParentProps) {
const [item] = result.splice(fromIndex, 1)
if (!item) return
result.splice(toIndex, 0, item)
setStore(
"workspaceOrder",
project.worktree,
result.filter((directory) => workspaceKey(directory) !== workspaceKey(project.worktree)),
)
setStore("workspaceOrder", project.worktree, result)
}
function handleWorkspaceDragEnd() {
@@ -1747,9 +1661,10 @@ export default function Layout(props: ParentProps) {
const existing = prev ?? []
const next = existing.filter((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)
@@ -1846,7 +1761,7 @@ export default function Layout(props: ParentProps) {
}}
style={{ width: panelProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }}
>
<Show when={panelProps.project} keyed>
<Show when={panelProps.project}>
{(p) => (
<>
<div class="shrink-0 px-2 py-1">
@@ -1855,7 +1770,7 @@ export default function Layout(props: ParentProps) {
<InlineEditor
id={`project:${projectId()}`}
value={projectName}
onSave={(next) => renameProject(p, next)}
onSave={(next) => renameProject(p(), next)}
class="text-14-medium text-text-strong truncate"
displayClass="text-14-medium text-text-strong truncate"
stopPropagation
@@ -1864,7 +1779,7 @@ export default function Layout(props: ParentProps) {
<Tooltip
placement="bottom"
gutter={2}
value={p.worktree}
value={p().worktree}
class="shrink-0"
contentStyle={{
"max-width": "640px",
@@ -1872,7 +1787,7 @@ export default function Layout(props: ParentProps) {
}}
>
<span class="text-12-regular text-text-base truncate select-text">
{p.worktree.replace(homedir(), "~")}
{p().worktree.replace(homedir(), "~")}
</span>
</Tooltip>
</div>
@@ -1883,7 +1798,7 @@ export default function Layout(props: ParentProps) {
icon="dot-grid"
variant="ghost"
data-action="project-menu"
data-project={base64Encode(p.worktree)}
data-project={base64Encode(p().worktree)}
class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active"
classList={{
"opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile,
@@ -1892,24 +1807,24 @@ export default function Layout(props: ParentProps) {
/>
<DropdownMenu.Portal mount={!panelProps.mobile ? state.nav : undefined}>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item onSelect={() => showEditProjectDialog(p)}>
<DropdownMenu.Item onSelect={() => showEditProjectDialog(p())}>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-workspaces-toggle"
data-project={base64Encode(p.worktree)}
disabled={p.vcs !== "git" && !layout.sidebar.workspaces(p.worktree)()}
onSelect={() => toggleProjectWorkspaces(p)}
data-project={base64Encode(p().worktree)}
disabled={p().vcs !== "git" && !layout.sidebar.workspaces(p().worktree)()}
onSelect={() => toggleProjectWorkspaces(p())}
>
<DropdownMenu.ItemLabel>
{layout.sidebar.workspaces(p.worktree)()
{layout.sidebar.workspaces(p().worktree)()
? language.t("sidebar.workspaces.disable")
: language.t("sidebar.workspaces.enable")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-clear-notifications"
data-project={base64Encode(p.worktree)}
data-project={base64Encode(p().worktree)}
disabled={unseenCount() === 0}
onSelect={clearNotifications}
>
@@ -1920,8 +1835,8 @@ export default function Layout(props: ParentProps) {
<DropdownMenu.Separator />
<DropdownMenu.Item
data-action="project-close-menu"
data-project={base64Encode(p.worktree)}
onSelect={() => closeProject(p.worktree)}
data-project={base64Encode(p().worktree)}
onSelect={() => closeProject(p().worktree)}
>
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
@@ -1946,7 +1861,7 @@ export default function Layout(props: ParentProps) {
size="large"
icon="plus-small"
class="w-full"
onClick={() => navigateWithSidebarReset(`/${base64Encode(p.worktree)}/session`)}
onClick={() => navigateWithSidebarReset(`/${base64Encode(p().worktree)}/session`)}
>
{language.t("command.session.new")}
</Button>
@@ -1955,7 +1870,7 @@ export default function Layout(props: ParentProps) {
<div class="flex-1 min-h-0">
<LocalWorkspace
ctx={workspaceSidebarCtx}
project={p}
project={p()}
sortNow={sortNow}
mobile={panelProps.mobile}
/>
@@ -1970,7 +1885,7 @@ export default function Layout(props: ParentProps) {
keybind={command.keybind("workspace.new")}
placement="top"
>
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p)}>
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p())}>
{language.t("workspace.new")}
</Button>
</TooltipKeybind>
@@ -1996,7 +1911,7 @@ export default function Layout(props: ParentProps) {
<SortableWorkspace
ctx={workspaceSidebarCtx}
directory={directory}
project={p}
project={p()}
sortNow={sortNow}
mobile={panelProps.mobile}
/>
@@ -2100,11 +2015,7 @@ export default function Layout(props: ParentProps) {
onOpenSettings={openSettings}
helpLabel={() => language.t("sidebar.help")}
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
renderPanel={() => (
<Show when={currentProject()} keyed>
{(project) => <SidebarPanel project={project} />}
</Show>
)}
renderPanel={() => <SidebarPanel project={currentProject()} />}
/>
</div>
<Show when={!layout.sidebar.opened() ? hoverProjectData()?.worktree : undefined} keyed>

View File

@@ -1,17 +1,15 @@
export const deepLinkEvent = "opencode:deep-link"
const parseUrl = (input: string) => {
export const parseDeepLink = (input: string) => {
if (!input.startsWith("opencode://")) return
if (typeof URL.canParse === "function" && !URL.canParse(input)) return
try {
return new URL(input)
} catch {
return
}
}
export const parseDeepLink = (input: string) => {
const url = parseUrl(input)
const url = (() => {
try {
return new URL(input)
} catch {
return undefined
}
})()
if (!url) return
if (url.hostname !== "open-project") return
const directory = url.searchParams.get("directory")
@@ -19,23 +17,9 @@ export const parseDeepLink = (input: string) => {
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[]) =>
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 & {
__OPENCODE__?: {
deepLinks?: string[]

View File

@@ -1,14 +1,15 @@
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 { 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">) =>
({
@@ -61,28 +62,6 @@ describe("layout deep links", () => {
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", () => {
const target = {
__OPENCODE__: {

View File

@@ -74,29 +74,9 @@ export const errorMessage = (err: unknown, fallback: string) => {
return fallback
}
export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted?: string[]) => {
const root = workspaceKey(local)
const live = new Map<string, string>()
for (const dir of dirs) {
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 = (local: string, dirs: string[], existing?: string[]) => {
if (!existing) return dirs
const keep = existing.filter((d) => d !== local && dirs.includes(d))
const missing = dirs.filter((d) => d !== local && !existing.includes(d))
return [local, ...missing, ...keep]
}
export const syncWorkspaceOrder = effectiveWorkspaceOrder

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 { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { HoverCard } from "@opencode-ai/ui/hover-card"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { MessageNav } from "@opencode-ai/ui/message-nav"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { base64Encode } from "@opencode-ai/util/encode"
import { getFilename } from "@opencode-ai/util/path"
import { A, useNavigate, useParams } from "@solidjs/router"
import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } 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 { type Message, type Session, type TextPart, type UserMessage } from "@opencode-ai/sdk/v2/client"
import { For, Match, Show, Switch, createMemo, onCleanup, type Accessor, type JSX } from "solid-js"
import { agentColor } from "@/utils/agent"
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
import { hasProjectPermissions } from "./helpers"
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
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">
{props.session.title}
</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>
</A>
)
@@ -231,9 +239,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
const isActive = createMemo(() => props.session.id === params.id)
const hoverPrefetch = {
current: undefined as ReturnType<typeof setTimeout> | undefined,
}
const hoverPrefetch = { current: undefined as ReturnType<typeof setTimeout> | undefined }
const cancelHoverPrefetch = () => {
if (hoverPrefetch.current === undefined) return
clearTimeout(hoverPrefetch.current)
@@ -302,15 +308,17 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
setHoverSession={props.setHoverSession}
messageLabel={messageLabel}
onMessageSelect={(message) => {
if (!isActive())
if (!isActive()) {
layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id)
navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`)
navigate(`${props.slug}/session/${props.session.id}`)
return
}
window.history.replaceState(null, "", `#message-${message.id}`)
window.dispatchEvent(new HashChangeEvent("hashchange"))
}}
trigger={item}
/>
</Show>
<div
class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`}
classList={{

View File

@@ -1,17 +1,4 @@
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import {
onCleanup,
Show,
Match,
Switch,
createMemo,
createEffect,
createComputed,
on,
onMount,
untrack,
} from "solid-js"
import { onCleanup, Show, Match, Switch, createMemo, createEffect, on, onMount, untrack } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useLocal } from "@/context/local"
@@ -21,26 +8,28 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Select } from "@opencode-ai/ui/select"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { Mark } from "@opencode-ai/ui/logo"
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 { 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 { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
import { same } from "@opencode-ai/util/array"
import { isEditableTarget } from "@/utils/dom"
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 { 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 { 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 { SessionSidePanel } from "@/pages/session/session-side-panel"
import { TerminalPanel } from "@/pages/session/terminal-panel"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
const emptyUserMessages: UserMessage[] = []
@@ -119,13 +108,13 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
return
}
const beforeTop = el.scrollTop
const beforeHeight = el.scrollHeight
fn()
// SolidJS updates the DOM synchronously. Force reflow so the browser
// processes the new layout, then restore scrollTop before paint.
// With column-reverse + overflow-anchor:none the same scrollTop value
// keeps the same distance from the bottom — no delta math needed.
void el.scrollHeight
el.scrollTop = beforeTop
requestAnimationFrame(() => {
const delta = el.scrollHeight - beforeHeight
if (!delta) return
el.scrollTop = beforeTop + delta
})
}
const backfillTurns = () => {
@@ -208,8 +197,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
if (!input.userScrolled()) return
const el = input.scroller()
if (!el) return
// With column-reverse, distance from top = scrollHeight - clientHeight + scrollTop
if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return
if (el.scrollTop >= turnScrollThreshold) return
const start = turnStart()
if (start > 0) {
@@ -265,19 +253,6 @@ export default function Page() {
const sdk = useSDK()
const prompt = usePrompt()
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({
pendingMessage: undefined as string | undefined,
@@ -287,6 +262,7 @@ export default function Page() {
bottom: true,
},
})
const composer = createSessionComposerState()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
@@ -371,6 +347,24 @@ export default function Page() {
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 diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
@@ -417,10 +411,7 @@ export default function Page() {
() => {
const msg = lastUserMessage()
if (!msg) return
if (msg.agent) {
local.agent.set(msg.agent)
if (local.agent.current()?.model) return
}
if (msg.agent) local.agent.set(msg.agent)
if (msg.model) local.model.set(msg.model)
},
),
@@ -443,6 +434,11 @@ export default function Page() {
return "main"
})
const activeMessage = createMemo(() => {
if (!store.messageId) return lastUserMessage()
const found = visibleUserMessages()?.find((m) => m.id === store.messageId)
return found ?? lastUserMessage()
})
const setActiveMessage = (message: UserMessage | undefined) => {
setStore("messageId", message?.id)
}
@@ -604,6 +600,11 @@ export default function Page() {
saveLabel: language.t("common.save"),
}))
const isEditableTarget = (target: EventTarget | null | undefined) => {
if (!(target instanceof HTMLElement)) return false
return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable
}
const deepActiveElement = () => {
let current: Element | null = document.activeElement
while (current instanceof HTMLElement && current.shadowRoot?.activeElement) {
@@ -672,11 +673,7 @@ export default function Page() {
on(
sessionKey,
() => {
setTree({
reviewScroll: undefined,
pendingDiff: undefined,
activeDiff: undefined,
})
setTree({ reviewScroll: undefined, pendingDiff: undefined, activeDiff: undefined })
},
{ defer: true },
),
@@ -699,7 +696,6 @@ export default function Page() {
showAllFiles,
tabForPath: file.tab,
openTab: tabs().open,
setActive: tabs().setActive,
loadFile: file.load,
})
@@ -1022,10 +1018,7 @@ export default function Page() {
const updateScrollState = (el: HTMLDivElement) => {
const max = el.scrollHeight - el.clientHeight
const overflow = max > 1
// If auto-scroll is tracking the bottom, always report bottom: true
// to prevent the scroll-down arrow from flashing during height animations
// With column-reverse, scrollTop=0 is at the bottom
const bottom = !overflow || Math.abs(el.scrollTop) <= 2 || !autoScroll.userScrolled()
const bottom = !overflow || el.scrollTop >= max - 2
if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return
setUi("scroll", { overflow, bottom })
@@ -1048,7 +1041,7 @@ export default function Page() {
const resumeScroll = () => {
setStore("messageId", undefined)
autoScroll.smoothScrollToBottom()
autoScroll.forceScrollToBottom()
clearMessageHash()
const el = scroller
@@ -1116,8 +1109,7 @@ export default function Page() {
const el = scroller
const delta = next - dockHeight
// With column-reverse, near bottom = scrollTop near 0
const stick = el ? Math.abs(el.scrollTop) < 10 + Math.max(0, delta) : false
const stick = el ? el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta) : false
dockHeight = next
@@ -1183,49 +1175,50 @@ export default function Page() {
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={params.id}>
<MessageTimeline
mobileChanges={mobileChanges()}
mobileFallback={reviewContent({
diffStyle: "unified",
classes: {
root: "pb-8",
header: "px-4",
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
})}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
setScrollRef={setScrollRef}
onScheduleScrollState={scheduleScrollState}
onAutoScrollHandleScroll={autoScroll.handleScroll}
onMarkScrollGesture={markScrollGesture}
hasScrollGesture={hasScrollGesture}
isDesktop={isDesktop()}
onScrollSpyScroll={scrollSpy.onScroll}
onTurnBackfillScroll={historyWindow.onScrollerScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
onPreserveScrollAnchor={autoScroll.preserve}
centered={centered()}
setContentRef={(el) => {
content = el
autoScroll.contentRef(el)
<Show when={activeMessage()}>
<MessageTimeline
mobileChanges={mobileChanges()}
mobileFallback={reviewContent({
diffStyle: "unified",
classes: {
root: "pb-8",
header: "px-4",
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
})}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
setScrollRef={setScrollRef}
onScheduleScrollState={scheduleScrollState}
onAutoScrollHandleScroll={autoScroll.handleScroll}
onMarkScrollGesture={markScrollGesture}
hasScrollGesture={hasScrollGesture}
isDesktop={isDesktop()}
onScrollSpyScroll={scrollSpy.onScroll}
onTurnBackfillScroll={historyWindow.onScrollerScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
centered={centered()}
setContentRef={(el) => {
content = el
autoScroll.contentRef(el)
const root = scroller
if (root) scheduleScrollState(root)
}}
turnStart={historyWindow.turnStart()}
historyMore={historyMore()}
historyLoading={historyLoading()}
onLoadEarlier={() => {
void historyWindow.loadAndReveal()
}}
renderedUserMessages={historyWindow.renderedUserMessages()}
anchor={anchor}
onRegisterMessage={scrollSpy.register}
onUnregisterMessage={scrollSpy.unregister}
/>
const root = scroller
if (root) scheduleScrollState(root)
}}
turnStart={historyWindow.turnStart()}
historyMore={historyMore()}
historyLoading={historyLoading()}
onLoadEarlier={() => {
void historyWindow.loadAndReveal()
}}
renderedUserMessages={historyWindow.renderedUserMessages()}
anchor={anchor}
onRegisterMessage={scrollSpy.register}
onUnregisterMessage={scrollSpy.unregister}
/>
</Show>
</Match>
<Match when={true}>
<NewSessionView

View File

@@ -1,29 +1,3 @@
/**
* Composer Island migration tracker
*
* Goal
* - Replace the split composer stack (PromptInput + question/permission/todo docks)
* with a single morphing ComposerIsland + app runtime adapter.
*
* Current status
* - [x] Storybook prototype with morphing surfaces exists (`packages/ui/src/components/composer-island.tsx`).
* - [ ] App still renders the existing production stack (`session-composer-region` + `prompt-input`).
*
* Feature parity checklist
* - [ ] Submit pipeline parity (session/worktree/create, optimistic user message, abort, restore on error).
* - [x] Runtime adapter API boundary in island (`runtime.submit`, `runtime.abort`, lookup, permission/question handlers).
* - [x] Shell mode wiring parity (single mode source for tray + editor).
* - [x] Cursor scroll-into-view behavior in island editor.
* - [x] Attachment parity in island UI: image + PDF support and file picker wiring.
* - [x] Drag/drop parity in island UI: attachment drop + `file:` text drop into @mention.
* - [x] Keyboard parity in island UI: mod+u, ctrl+g, ctrl+n/ctrl+p, popover navigation.
* - [x] Auto-accept + stop/send state parity in island UI.
* - [x] Async @mention/slash sourcing parity in island via runtime search hooks.
* - [ ] Context item parity (comment chips/open behavior is in progress; app comment wiring still pending).
* - [ ] Question flow parity (island submit/reject hooks + sending locks done; app SDK/cache wiring pending).
* - [ ] Permission flow parity (island responding lock done; app tool-description wiring pending).
* - [x] Todo parity in island UI (in-progress pulse + auto-scroll active item).
*/
export { SessionComposerRegion } from "./session-composer-region"
export { createSessionComposerBlocked, createSessionComposerState } from "./session-composer-state"
export type { SessionComposerState } from "./session-composer-state"

View File

@@ -1,7 +1,5 @@
import { Show, createMemo, createSignal, createEffect } from "solid-js"
import { Show, createEffect, createMemo } from "solid-js"
import { useParams } from "@solidjs/router"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { useElementHeight } from "@opencode-ai/ui/hooks"
import { PromptInput } from "@/components/prompt-input"
import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt"
@@ -9,9 +7,7 @@ import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
import type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
import { SessionTodoDock, COLLAPSED_HEIGHT } from "@/pages/session/composer/session-todo-dock"
const DOCK_SPRING = { visualDuration: 0.3, bounce: 0 }
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
export function SessionComposerRegion(props: {
state: SessionComposerState
@@ -47,16 +43,6 @@ export function SessionComposerRegion(props: {
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
})
const open = createMemo(() => props.state.dock() && !props.state.closing())
const progress = useSpring(
() => (open() ? 1 : 0),
DOCK_SPRING,
)
const dock = createMemo(() => props.state.dock() || progress() > 0.001)
const [contentRef, setContentRef] = createSignal<HTMLDivElement>()
const height = useElementHeight(contentRef, 320)
const full = createMemo(() => Math.max(COLLAPSED_HEIGHT, height()))
return (
<div
ref={props.setPromptDockRef}
@@ -101,33 +87,30 @@ export function SessionComposerRegion(props: {
</div>
}
>
<Show when={dock()}>
<Show when={props.state.dock()}>
<div
classList={{
"overflow-hidden": true,
"pointer-events-none": progress() < 0.98,
}}
style={{
"max-height": `${full() * progress()}px`,
"transition-[max-height,opacity,transform] duration-[400ms] ease-out overflow-hidden": true,
"max-h-[320px]": !props.state.closing(),
"max-h-0 pointer-events-none": props.state.closing(),
"opacity-0 translate-y-9": props.state.closing() || props.state.opening(),
"opacity-100 translate-y-0": !props.state.closing() && !props.state.opening(),
}}
>
<div ref={setContentRef}>
<SessionTodoDock
todos={props.state.todos()}
title={language.t("session.todo.title")}
collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")}
dockProgress={progress()}
/>
</div>
<SessionTodoDock
todos={props.state.todos()}
title={language.t("session.todo.title")}
collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")}
/>
</div>
</Show>
<div
classList={{
"relative z-10": true,
}}
style={{
"margin-top": `${-36 * progress()}px`,
"transition-[margin] duration-[400ms] ease-out": true,
"-mt-9": props.state.dock() && !props.state.closing(),
"mt-0": !props.state.dock() || props.state.closing(),
}}
>
<PromptInput

View File

@@ -29,11 +29,7 @@ export function createSessionComposerBlocked() {
})
}
export function createSessionComposerState(
options?: {
closeMs?: number | (() => number)
},
) {
export function createSessionComposerState() {
const params = useParams()
const sdk = useSDK()
const sync = useSync()
@@ -100,19 +96,12 @@ export function createSessionComposerState(
let timer: 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 = () => {
if (timer) window.clearTimeout(timer)
timer = window.setTimeout(() => {
setStore({ dock: false, closing: false })
timer = undefined
}, closeMs())
}, 400)
}
createEffect(

View File

@@ -8,8 +8,6 @@ import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useLanguage } from "@/context/language"
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) => {
const sdk = useSDK()
const language = useLanguage()
@@ -17,18 +15,16 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const questions = createMemo(() => props.request.questions)
const total = createMemo(() => questions().length)
const cached = cache.get(props.request.id)
const [store, setStore] = createStore({
tab: cached?.tab ?? 0,
answers: cached?.answers ?? ([] as QuestionAnswer[]),
custom: cached?.custom ?? ([] as string[]),
customOn: cached?.customOn ?? ([] as boolean[]),
tab: 0,
answers: [] as QuestionAnswer[],
custom: [] as string[],
customOn: [] as boolean[],
editing: false,
sending: false,
})
let root: HTMLDivElement | undefined
let replied = false
const question = createMemo(() => questions()[store.tab])
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 message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
@@ -133,8 +119,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
setStore("sending", true)
try {
await sdk.client.question.reply({ requestID: props.request.id, answers })
replied = true
cache.delete(props.request.id)
} catch (err) {
fail(err)
} finally {
@@ -149,8 +133,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
setStore("sending", true)
try {
await sdk.client.question.reject({ requestID: props.request.id })
replied = true
cache.delete(props.request.id)
} catch (err) {
fail(err)
} finally {

View File

@@ -1,20 +1,10 @@
import type { Todo } from "@opencode-ai/sdk/v2"
import { AnimatedNumber } from "@opencode-ai/ui/animated-number"
import { Checkbox } from "@opencode-ai/ui/checkbox"
import { DockTray } from "@opencode-ai/ui/dock-surface"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
import { useElementHeight } from "@opencode-ai/ui/hooks"
import { Index, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
const COLLAPSE_SPRING = { visualDuration: 0.3, bounce: 0 }
export const COLLAPSED_HEIGHT = 78
const SUBTITLE = { duration: 600, travel: 25, edge: 17 }
const COUNT = { duration: 600, mask: 18, maskHeight: 0, widthDuration: 560 }
function dot(status: Todo["status"]) {
if (status !== "in_progress") return undefined
return (
@@ -40,22 +30,19 @@ function dot(status: Todo["status"]) {
)
}
export function SessionTodoDock(props: {
todos: Todo[]
title: string
collapseLabel: string
expandLabel: string
dockProgress?: number
}) {
export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseLabel: string; expandLabel: string }) {
const [store, setStore] = createStore({
collapsed: false,
})
const toggle = () => setStore("collapsed", (value) => !value)
const total = createMemo(() => props.todos.length)
const done = createMemo(() => props.todos.filter((todo) => todo.status === "completed").length)
const label = createMemo(() => `${done()} of ${total()} ${props.title.toLowerCase()} completed`)
const summary = createMemo(() => {
const total = props.todos.length
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(
() =>
@@ -66,108 +53,56 @@ export function SessionTodoDock(props: {
)
const preview = createMemo(() => active()?.content ?? "")
const collapse = useSpring(() => (store.collapsed ? 1 : 0), COLLAPSE_SPRING)
const shut = createMemo(() => 1 - (props.dockProgress ?? 1))
const hide = createMemo(() => Math.max(collapse(), shut()))
let contentRef: HTMLDivElement | undefined
const height = useElementHeight(() => contentRef, 320)
const full = createMemo(() => Math.max(COLLAPSED_HEIGHT, height()))
return (
<DockTray
data-component="session-todo-dock"
style={{
"overflow-x": "visible",
"overflow-y": "hidden",
"max-height": `${Math.max(COLLAPSED_HEIGHT, full() - collapse() * (full() - COLLAPSED_HEIGHT))}px`,
classList={{
"h-[78px]": store.collapsed,
}}
>
<div ref={contentRef}>
<div
data-action="session-todo-toggle"
class="pl-3 pr-2 py-2 flex items-center gap-2 overflow-visible"
role="button"
tabIndex={0}
onClick={toggle}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
toggle()
}}
>
<span
class="text-14-regular text-text-strong cursor-default inline-flex items-baseline shrink-0 whitespace-nowrap overflow-visible"
aria-label={label()}
style={{
"--tool-motion-odometer-ms": `${COUNT.duration}ms`,
"--tool-motion-mask": `${COUNT.mask}%`,
"--tool-motion-mask-height": `${COUNT.maskHeight}px`,
"--tool-motion-spring-ms": `${COUNT.widthDuration}ms`,
opacity: `${1 - shut()}`,
filter: shut() > 0.01 ? `blur(${shut() * 2}px)` : "none",
}}
>
<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={SUBTITLE.duration}
travel={SUBTITLE.travel}
edge={SUBTITLE.edge}
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(${collapse() * 180}deg)` }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
toggle()
}}
aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
/>
<div
data-action="session-todo-toggle"
class="pl-3 pr-2 py-2 flex items-center gap-2"
role="button"
tabIndex={0}
onClick={toggle}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
toggle()
}}
>
<span class="text-14-regular text-text-strong cursor-default">{summary()}</span>
<Show when={store.collapsed}>
<div class="ml-1 flex-1 min-w-0">
<Show when={preview()}>
<div class="text-14-regular text-text-base truncate cursor-default">{preview()}</div>
</Show>
</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
data-slot="session-todo-list"
class="pb-2"
aria-hidden={store.collapsed}
classList={{
"pointer-events-none": hide() > 0.1,
}}
style={{
opacity: `${1 - hide()}`,
filter: hide() > 0.01 ? `blur(${hide() * 2}px)` : "none",
visibility: hide() > 0.98 ? "hidden" : "visible",
}}
>
<TodoList todos={props.todos} open={!store.collapsed} />
</div>
<div data-slot="session-todo-list" hidden={store.collapsed}>
<TodoList todos={props.todos} open={!store.collapsed} />
</div>
</DockTray>
)
@@ -236,39 +171,33 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
}, 250)
}}
>
<Index each={props.todos}>
<For each={props.todos}>
{(todo) => (
<Checkbox
readOnly
checked={todo().status === "completed"}
indeterminate={todo().status === "in_progress"}
data-in-progress={todo().status === "in_progress" ? "" : undefined}
data-state={todo().status}
icon={dot(todo().status)}
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.5" : "1",
}}
checked={todo.status === "completed"}
indeterminate={todo.status === "in_progress"}
data-in-progress={todo.status === "in_progress" ? "" : undefined}
icon={dot(todo.status)}
style={{ "--checkbox-align": "flex-start", "--checkbox-offset": "1px" }}
>
<TextStrikethrough
active={todo().status === "completed" || todo().status === "cancelled"}
text={todo().content}
<span
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={{
"line-height": "var(--line-height-normal)",
transition:
"color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
color:
todo().status === "completed" || todo().status === "cancelled"
? "var(--text-weak)"
: "var(--text-strong)",
"text-decoration":
todo.status === "completed" || todo.status === "cancelled" ? "line-through" : undefined,
}}
/>
>
{todo.content}
</span>
</Checkbox>
)}
</Index>
</For>
</div>
<div
class="pointer-events-none absolute top-0 left-0 right-0 h-4 transition-opacity duration-150"

View File

@@ -67,7 +67,6 @@ export function FileTabContent(props: { tab: string }) {
let scroll: HTMLDivElement | undefined
let scrollFrame: number | undefined
let restoreFrame: number | undefined
let pending: { x: number; y: number } | undefined
let codeScroll: HTMLElement[] = []
let find: FileSearchHandle | null = null
@@ -234,6 +233,7 @@ export function FileTabContent(props: { tab: string }) {
if (typeof window === "undefined") return
const onKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented) return
if (tabs().active() !== props.tab) return
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return
if (event.key.toLowerCase() !== "f") return
@@ -349,15 +349,6 @@ export function FileTabContent(props: { tab: string }) {
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
}
const queueRestore = () => {
if (restoreFrame !== undefined) return
restoreFrame = requestAnimationFrame(() => {
restoreFrame = undefined
restoreScroll()
})
}
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
if (codeScroll.length === 0) syncCodeScroll()
@@ -373,29 +364,46 @@ export function FileTabContent(props: { tab: string }) {
setNote("commenting", null)
}
let prev = {
loaded: false,
ready: false,
active: false,
}
createEffect(
on(
() => state()?.loaded,
(loaded) => {
if (!loaded) return
requestAnimationFrame(restoreScroll)
},
{ defer: true },
),
)
createEffect(() => {
const loaded = !!state()?.loaded
const ready = file.ready()
const active = tabs().active() === props.tab
const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active)
prev = { loaded, ready, active }
if (!restore) return
queueRestore()
})
createEffect(
on(
() => file.ready(),
(ready) => {
if (!ready) return
requestAnimationFrame(restoreScroll)
},
{ defer: true },
),
)
createEffect(
on(
() => tabs().active() === props.tab,
(active) => {
if (!active) return
if (!state()?.loaded) return
requestAnimationFrame(restoreScroll)
},
),
)
onCleanup(() => {
for (const item of codeScroll) {
item.removeEventListener("scroll", handleCodeScroll)
}
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
if (scrollFrame === undefined) return
cancelAnimationFrame(scrollFrame)
})
const renderFile = (source: string) => (
@@ -413,7 +421,7 @@ export function FileTabContent(props: { tab: string }) {
selectedLines={activeSelection()}
commentedLines={commentedLines()}
onRendered={() => {
queueRestore()
requestAnimationFrame(restoreScroll)
}}
annotations={commentsUi.annotations()}
renderAnnotation={commentsUi.renderAnnotation}
@@ -432,7 +440,7 @@ export function FileTabContent(props: { tab: string }) {
mode: "auto",
path: path(),
current: state()?.content,
onLoad: queueRestore,
onLoad: () => requestAnimationFrame(restoreScroll),
onError: (args: { kind: "image" | "audio" | "svg" }) => {
if (args.kind !== "svg") return
showToast({

View File

@@ -11,13 +11,12 @@ describe("createOpenReviewFile", () => {
return `file://${path}`
},
openTab: (tab) => calls.push(`open:${tab}`),
setActive: (tab) => calls.push(`active:${tab}`),
loadFile: (path) => calls.push(`load:${path}`),
})
openReviewFile("src/a.ts")
expect(calls).toEqual(["show", "load:src/a.ts", "tab:src/a.ts", "open:file://src/a.ts", "active:file://src/a.ts"])
expect(calls).toEqual(["show", "load:src/a.ts", "tab:src/a.ts", "open:file://src/a.ts"])
})
})

View File

@@ -24,20 +24,15 @@ export const createOpenReviewFile = (input: {
showAllFiles: () => void
tabForPath: (path: string) => string
openTab: (tab: string) => void
setActive: (tab: string) => void
loadFile: (path: string) => any | Promise<void>
}) => {
return (path: string) => {
batch(() => {
input.showAllFiles()
const maybePromise = input.loadFile(path)
const open = () => {
const tab = input.tabForPath(path)
input.openTab(tab)
input.setActive(tab)
}
if (maybePromise instanceof Promise) maybePromise.then(open)
else open()
const openTab = () => input.openTab(input.tabForPath(path))
if (maybePromise instanceof Promise) maybePromise.then(openTab)
else openTab()
})
}
}

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