Compare commits

...

107 Commits

Author SHA1 Message Date
Dax Raad 2beeccb39a perf(tool): reduce edit and read memory overhead 2026-03-24 16:19:38 -04:00
David Hill 77b3b46788 tui: keep file tree open at its minimum resized width (#18777) 2026-03-23 20:06:43 +08:00
Brendan Allan 36dfe1646b fix(app): only navigate prompt history when input is empty (#18775) 2026-03-23 11:48:34 +00:00
opencode-agent[bot] 6926dc26d1 chore: update nix node_modules hashes 2026-03-23 10:52:56 +00:00
opencode-agent[bot] eb74e4a6d2 chore: update nix node_modules hashes 2026-03-23 10:37:23 +00:00
opencode-agent[bot] 85d8e143bf chore: generate 2026-03-23 10:35:30 +00:00
Brendan Allan 8e1b53b32c fix(app): handle session busy state better (#18758) 2026-03-23 10:34:32 +00:00
Brendan Allan 0a7dfc03ee fix(app): lift up project hover state to layout (#18732) 2026-03-23 08:58:20 +00:00
Brendan Allan 4c27e7fc64 electron: more robust sidecar kill handling (#18742) 2026-03-23 08:44:23 +00:00
Shoubhit Dash 0f5626d2e4 fix(app): prefer cmd+k for command palette (#18731) 2026-03-23 08:00:24 +00:00
Shoubhit Dash 5ea95451dd fix(app): prevent stale session hover preview on refocus (#18727) 2026-03-23 07:25:30 +00:00
Shoubhit Dash 9239d877b9 fix(app): batch multi-file prompt attachments (#18722) 2026-03-23 06:44:17 +00:00
github-actions[bot] fc68c24433 Update VOUCHED list
https://github.com/anomalyco/opencode/issues/18718#issuecomment-4108322776
2026-03-23 06:28:47 +00:00
Luke Parker db9619dad6 Add 'write' role to vouch manage action (#18718) 2026-03-23 06:27:35 +00:00
James Long 84d9b38873 fix(core): fix file watcher test (#18698) 2026-03-23 03:35:17 +00:00
opencode-agent[bot] 8035c3435b chore: update nix node_modules hashes 2026-03-23 01:03:20 +00:00
Sebastian 71e7603d71 Upgrade opentui to 0.1.90 (#18551) 2026-03-23 01:45:34 +01:00
David Hill 40e49c5b49 tui: keep patch tool counts visible with long filenames (#18678) 2026-03-23 00:45:11 +00:00
Luke Parker afe9b97274 fix(app): restore keyboard project switching in open sidebar (#18682) 2026-03-23 00:39:46 +00:00
opencode-agent[bot] 3b3549902d chore: update nix node_modules hashes 2026-03-23 00:29:45 +00:00
David Hill e9a9c75c1f tweak(ui): fix padding bottom on the context tab (#18680) 2026-03-23 00:23:45 +00:00
David Hill 2b171828b0 tui: prevent project avatar popover flicker when switching projects (#18660)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-03-23 10:20:49 +10:00
Luke Parker 8dd817023a chore: bump Bun to 1.3.11 (#18144) 2026-03-23 10:19:21 +10:00
Dax Raad 0d6c601365 changelog slash command 2026-03-22 19:45:23 -04:00
opencode-agent[bot] 5460bf9989 chore: generate 2026-03-22 23:32:09 +00:00
opencode eb3bfffad4 release: v1.3.0 2026-03-22 23:32:01 +00:00
Dax e2d03ce38c feat: interactive update flow for non-patch releases (#18662) 2026-03-22 23:12:40 +00:00
David Hill 32f9dc6383 fix(ui): stop auto close of sidebar on resize (#18647) 2026-03-23 08:53:12 +10:00
Filip c529529f84 fix(app): terminal rename from context menu (#18263)
Co-authored-by: Brendan Allan <brendonovich@outlook.com>
2026-03-22 04:39:51 +00:00
Kit Langton 13bac9c91a effectify Pty service (#18572) 2026-03-22 01:17:13 +00:00
Kit Langton fe53af4819 effectify ToolRegistry service (#18571) 2026-03-22 01:06:01 +00:00
opencode-agent[bot] e82c5a9a28 chore: generate 2026-03-22 00:51:19 +00:00
Kit Langton 3236f228fb effectify Plugin service (#18570) 2026-03-22 00:50:22 +00:00
Kit Langton 0e0e7a4a4b effectify Command service (#18568) 2026-03-22 00:21:41 +00:00
Kit Langton 10a3d6c54e effectify SessionStatus service (#18565) 2026-03-21 23:55:07 +00:00
opencode-agent[bot] 832b8e252e chore: update nix node_modules hashes 2026-03-21 19:17:12 +00:00
Sebastian 040f551c57 Upgrade opentui to 0.1.88 (#18079) 2026-03-21 19:00:42 +00:00
Protocol Zero cc818f8032 fix(provider): only set thinkingConfig for models with reasoning capability (#18283) 2026-03-21 11:57:52 -05:00
opencode-agent[bot] d5337b41f4 chore: update nix node_modules hashes 2026-03-21 15:47:15 +00:00
opencode-agent[bot] 9f7a76d6c0 chore: generate 2026-03-21 15:34:05 +00:00
Brendan Allan 6a16db4b92 app: manage mutation loading states with tanstack query (#18501) 2026-03-21 23:33:04 +08:00
Brendan Allan 9ad6588f3e app: allow navigating projects with keybinds (#18502) 2026-03-21 22:13:09 +08:00
opencode-agent[bot] fb6bf0b35e chore: generate 2026-03-21 13:12:54 +00:00
Dax Raad f80343b875 fix annotation 2026-03-21 09:11:15 -04:00
Frank 9b805e1cc4 wip: zen 2026-03-21 04:07:51 -04:00
opencode-agent[bot] 2e0d5d2308 chore: generate 2026-03-21 04:52:23 +00:00
Kit Langton 38e0dc9ccd Move service state into InstanceState, flatten service facades (#18483) 2026-03-21 04:51:35 +00:00
opencode-agent[bot] 40aeaa120d chore: generate 2026-03-21 03:11:28 +00:00
Kit Langton 6a64177589 fix(zen): emit cost chunk in client-facing format, not upstream format (#16817) 2026-03-20 23:10:34 -04:00
Dax Raad 5dc47905a9 allow customizing DB location 2026-03-20 22:49:55 -04:00
Aiden Cline dc0044882c ignore: add danieljoshuanazareth to disavow list (#18476) 2026-03-20 21:34:07 -04:00
github-actions[bot] 45ae7dc653 Update VOUCHED list
https://github.com/anomalyco/opencode/issues/18464#issuecomment-4101766628
2026-03-21 01:28:40 +00:00
Dax Raad 129fe1e350 ci 2026-03-20 17:00:05 -04:00
Kit Langton 214a6c6cf1 fix: switch consumers to service imports to break bundle cycles (#18438) 2026-03-20 20:55:46 +00:00
Dax Raad 3f249aba6d commit and push 2026-03-20 16:36:19 -04:00
Dax Raad 5c6ec1caac fix question cross out 2026-03-20 15:50:04 -04:00
Kit Langton 24f9df5463 fix: update stale account url/email on re-login (#18426) 2026-03-20 14:50:01 -04:00
opencode-agent[bot] 12b8e1c2be chore: generate 2026-03-20 18:38:08 +00:00
Kit Langton d70099b059 fix: apply Layer.fresh at instance service definition site (#18418) 2026-03-20 14:37:12 -04:00
opencode-agent[bot] ce845a0b1b chore: update nix node_modules hashes 2026-03-20 18:16:17 +00:00
Vladimir Glafirov 05d3e65f76 feat: enable GitLab Agent Platform with workflow model discovery (#18014) 2026-03-20 12:55:22 -05:00
opencode-agent[bot] 51618e9cef chore: generate 2026-03-20 16:11:21 +00:00
Kit Langton e78944e9a4 effectify Installation service, drop Effect suffix from namespaces (#18266) 2026-03-20 12:10:33 -04:00
Aiden Cline bfdc38e421 tweak: adjust codex plugin logic so that codex instruction isn't always added (oauth plan no longer enforces instruction whitelisting) (#18337) 2026-03-20 10:37:47 -05:00
MC 83023e4f0f docs: add Cloudflare Workers AI provider (#18322)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-20 10:10:22 -05:00
Brendan Allan d0a57305ef app: file type filter on desktop + multiple files (#18403) 2026-03-20 15:02:07 +00:00
Shoubhit Dash 27a70ad70f fix(app): align review file comments with diff comments (#18406) 2026-03-20 14:44:18 +00:00
Luke Parker 0bbf26a1ce deslopity deslopity (#18343) 2026-03-20 05:24:27 +00:00
opencode-agent[bot] 83cdb4de64 chore: update nix node_modules hashes 2026-03-20 05:14:32 +00:00
Brendan Allan 4989632245 patch solid to try fix memo undefined under transition bug (#18338) 2026-03-20 14:58:35 +10:00
Luke Parker d460614cd7 fix: lots of desktop stability, better e2e error logging (#18300) 2026-03-20 00:12:06 -04:00
Luke Parker 7866dbcfcc fix: avoid truncate permission import cycle (#18292) 2026-03-19 23:52:04 -04:00
opencode-agent[bot] e71a21e0a8 chore: update nix node_modules hashes 2026-03-20 02:21:29 +00:00
Dax 1071aca91f fix: miscellaneous small fixes (#18328) 2026-03-19 22:20:29 -04:00
Jaaneek b3d0446d13 feat: switch xai provider to responses API (#18175)
Co-authored-by: Jaaneek <jankiewiczmilosz@gmail.com>
2026-03-19 21:09:49 -05:00
opencode-agent[bot] 949191ab74 chore: update nix node_modules hashes 2026-03-20 01:36:22 +00:00
Dax 92cd908fb5 feat: add Node.js entry point and build script (#18324) 2026-03-19 21:35:07 -04:00
Dax 6fcc970def fix: include cache bin directory in which() lookups (#18320) 2026-03-19 21:21:55 -04:00
Dax 52a7a04ad8 refactor: replace Bun shell execution with portable Process utilities (#18318) 2026-03-19 21:17:06 -04:00
Dax 37b8662a9d refactor: abstract SQLite behind runtime-conditional #db import (#18316) 2026-03-19 21:15:35 -04:00
Dax ddcb32ae0b refactor(tui): replace Bun-specific APIs with portable alternatives (#18304) 2026-03-19 19:32:59 -04:00
Frank 2c056c90da doc: update translator to gpt model 2026-03-19 19:07:02 -04:00
Dax 812d1bb32a chore: inline tool descriptions, remove separate .txt files (#18303) 2026-03-19 19:02:42 -04:00
Frank 9a58c43ef4 go: upi translation 2026-03-19 18:54:32 -04:00
Dax 63585db6a7 refactor: replace Bun.sleep with node:timers/promises sleep (#18301) 2026-03-19 18:50:40 -04:00
Frank bd44489ada go: upi payment 2026-03-19 18:44:24 -04:00
opencode-agent[bot] a6ef9e9206 chore: generate 2026-03-19 20:21:10 +00:00
Kit Langton 6e09a1d904 fix(account): handle pending console login polling (#18281) 2026-03-19 16:18:28 -04:00
opencode-agent[bot] 4f21757e0d chore: generate 2026-03-19 20:05:33 +00:00
jorge g 2dbcd79fd2 fix: stabilize agent and skill ordering in prompt descriptions (#18261)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-19 15:04:03 -05:00
James Long 48a7f0fd93 Fix base64Decode import in workspaces.spec.ts (#18274) 2026-03-19 15:38:54 -04:00
James Long d69962b0f7 fix(core): disable chunk timeout by default (#18264) 2026-03-19 14:30:08 -04:00
opencode-agent[bot] a6f23cb08e chore: generate 2026-03-19 17:52:50 +00:00
James Long 0540751897 fix(core): use a queue to process events in event routes (#18259) 2026-03-19 13:51:14 -04:00
opencode-agent[bot] baa204193c chore: generate 2026-03-19 16:16:29 +00:00
Aiden Cline aeece6166b ignore: revert 3 commits that broke dev branch (#18260) 2026-03-19 11:15:07 -05:00
Kit Langton 0d7e62a532 fix forked prompt attachments losing file parts (#17815) 2026-03-19 12:00:32 -04:00
Shoubhit Dash 41aa254db4 fix(app): show review on the empty session route (#18251) 2026-03-19 19:51:32 +05:30
opencode-agent[bot] d178d8249f chore: generate 2026-03-19 14:00:32 +00:00
Shoubhit Dash e6f5214779 feat: add git-backed session review modes (#17961) 2026-03-19 19:29:29 +05:30
Brendan Allan 84f60d97a0 app: fix workspace flicker when switching directories (#18207)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-03-19 19:29:14 +05:30
Brendan Allan cbf4b68fee electron: lazily read updater enabled 2026-03-19 21:47:47 +08:00
OpeOginni bd4527b4f2 fix(desktop): remote server switching (#17214)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-03-19 13:32:11 +00:00
Andrew Maguire f4a9fe29a3 fix(app): ignore repeated Enter submits in prompt input (#18148)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-03-19 11:53:44 +00:00
opencode-agent[bot] 5a0bfa7061 chore: generate 2026-03-19 04:46:39 +00:00
Dax 1ac1a0287c anthropic legal requests (#18186) 2026-03-19 04:45:24 +00:00
Aiden Cline 8e09e8c612 feat: integrate multistep auth flows into desktop app (#18103) 2026-03-19 13:47:51 +10:00
303 changed files with 9211 additions and 5411 deletions
+4 -1
View File
@@ -10,6 +10,9 @@
adamdotdevin adamdotdevin
-agusbasari29 AI PR slop -agusbasari29 AI PR slop
ariane-emory ariane-emory
-atharvau AI review spamming literally every PR
-danieljoshuanazareth
-danieljoshuanazareth
edemaine edemaine
-florianleibert -florianleibert
fwang fwang
@@ -17,8 +20,8 @@ iamdavidhill
jayair jayair
kitlangton kitlangton
kommander kommander
-opencode2026
r44vc0rp r44vc0rp
rekram1-node rekram1-node
-spider-yamet clawdbot/llm psychosis, spam pinging the team -spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr thdxr
-OpenCode2026
+22 -6
View File
@@ -50,20 +50,17 @@ jobs:
e2e: e2e:
name: e2e (${{ matrix.settings.name }}) name: e2e (${{ matrix.settings.name }})
needs: unit
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
settings: settings:
- name: linux - name: linux
host: blacksmith-4vcpu-ubuntu-2404 host: blacksmith-4vcpu-ubuntu-2404
playwright: bunx playwright install --with-deps
- name: windows - name: windows
host: blacksmith-4vcpu-windows-2025 host: blacksmith-4vcpu-windows-2025
playwright: bunx playwright install
runs-on: ${{ matrix.settings.host }} runs-on: ${{ matrix.settings.host }}
env: env:
PLAYWRIGHT_BROWSERS_PATH: 0 PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
defaults: defaults:
run: run:
shell: bash shell: bash
@@ -76,9 +73,28 @@ jobs:
- name: Setup Bun - name: Setup Bun
uses: ./.github/actions/setup-bun uses: ./.github/actions/setup-bun
- name: Install Playwright browsers - name: Read Playwright version
id: playwright-version
run: |
version=$(node -e 'console.log(require("./packages/app/package.json").devDependencies["@playwright/test"])')
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ${{ github.workspace }}/.playwright-browsers
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
- name: Install Playwright system dependencies
if: runner.os == 'Linux'
working-directory: packages/app working-directory: packages/app
run: ${{ matrix.settings.playwright }} run: bunx playwright install-deps chromium
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: packages/app
run: bunx playwright install chromium
- name: Run app e2e tests - name: Run app e2e tests
run: bun --cwd packages/app test:e2e:local run: bun --cwd packages/app test:e2e:local
+1 -1
View File
@@ -33,6 +33,6 @@ jobs:
with: with:
issue-id: ${{ github.event.issue.number }} issue-id: ${{ github.event.issue.number }}
comment-id: ${{ github.event.comment.id }} comment-id: ${{ github.event.comment.id }}
roles: admin,maintain roles: admin,maintain,write
env: env:
GITHUB_TOKEN: ${{ steps.committer.outputs.token }} GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
+5 -3
View File
@@ -1,4 +1,6 @@
plans/ node_modules
bun.lock plans
package.json package.json
package-lock.json bun.lock
.gitignore
package-lock.json
+1 -1
View File
@@ -1,7 +1,7 @@
--- ---
description: Translate content for a specified locale while preserving technical terms description: Translate content for a specified locale while preserving technical terms
mode: subagent mode: subagent
model: opencode/gemini-3.1-pro model: opencode/gpt-5.4
--- ---
You are a professional translator and localization specialist. You are a professional translator and localization specialist.
+5
View File
@@ -0,0 +1,5 @@
go through each PR merged since the last tag
for each PR spawn a subagent to summarize what the PR was about. focus on user facing changes. if it was entirely internal or code related you can ignore it. also skip docs updates. each subagent should append its summary to UPCOMING_CHANGELOG.md
once that is done, read UPCOMING_CHANGELOG.md and group it into sections for better readability. make sure all PR references are preserved
+10 -3
View File
@@ -1,7 +1,5 @@
/// <reference path="../env.d.ts" /> /// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin" import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-pr-search.txt"
async function githubFetch(endpoint: string, options: RequestInit = {}) { async function githubFetch(endpoint: string, options: RequestInit = {}) {
const response = await fetch(`https://api.github.com${endpoint}`, { const response = await fetch(`https://api.github.com${endpoint}`, {
...options, ...options,
@@ -24,7 +22,16 @@ interface PR {
} }
export default tool({ export default tool({
description: DESCRIPTION, description: `Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in PR titles or descriptions.`,
args: { args: {
query: tool.schema.string().describe("Search query for PR titles and descriptions"), query: tool.schema.string().describe("Search query for PR titles and descriptions"),
limit: tool.schema.number().describe("Maximum number of results to return").default(10), limit: tool.schema.number().describe("Maximum number of results to return").default(10),
-10
View File
@@ -1,10 +0,0 @@
Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in PR titles or descriptions.
+6 -3
View File
@@ -1,7 +1,5 @@
/// <reference path="../env.d.ts" /> /// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin" import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-triage.txt"
const TEAM = { const TEAM = {
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"], desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
zen: ["fwang", "MrMushrooooom"], zen: ["fwang", "MrMushrooooom"],
@@ -40,7 +38,12 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
} }
export default tool({ export default tool({
description: DESCRIPTION, description: `Use this tool to assign and/or label a GitHub issue.
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`,
args: { args: {
assignee: tool.schema assignee: tool.schema
.enum(ASSIGNEES as [string, ...string[]]) .enum(ASSIGNEES as [string, ...string[]])
-6
View File
@@ -1,6 +0,0 @@
Use this tool to assign and/or label a GitHub issue.
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.
+68 -50
View File
@@ -26,7 +26,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.2.27", "version": "1.3.0",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -41,9 +41,11 @@
"@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3", "@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "catalog:", "@solid-primitives/storage": "catalog:",
"@solid-primitives/timer": "1.4.4",
"@solid-primitives/websocket": "1.3.1", "@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:", "@solidjs/router": "catalog:",
"@tanstack/solid-query": "5.91.4",
"@thisbeyond/solid-dnd": "0.7.5", "@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:", "diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@@ -77,7 +79,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.2.27", "version": "1.3.0",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -111,7 +113,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.2.27", "version": "1.3.0",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -128,7 +130,7 @@
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "catalog:", "@cloudflare/workers-types": "catalog:",
"@tsconfig/node22": "22.0.2", "@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.0", "@types/bun": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"drizzle-kit": "catalog:", "drizzle-kit": "catalog:",
@@ -138,7 +140,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.2.27", "version": "1.3.0",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "2.0.0", "@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2", "@ai-sdk/openai": "2.0.2",
@@ -162,7 +164,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.2.27", "version": "1.3.0",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -186,7 +188,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.2.27", "version": "1.3.0",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -219,7 +221,7 @@
}, },
"packages/desktop-electron": { "packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron", "name": "@opencode-ai/desktop-electron",
"version": "1.2.27", "version": "1.3.0",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -250,7 +252,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.2.27", "version": "1.3.0",
"dependencies": { "dependencies": {
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
@@ -279,7 +281,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.2.27", "version": "1.3.0",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -295,7 +297,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.2.27", "version": "1.3.0",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -325,8 +327,6 @@
"@aws-sdk/credential-providers": "3.993.0", "@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/standard-validator": "0.1.5", "@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:", "@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2", "@modelcontextprotocol/sdk": "1.25.2",
@@ -338,8 +338,8 @@
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4", "@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.87", "@opentui/core": "0.1.90",
"@opentui/solid": "0.1.87", "@opentui/solid": "0.1.90",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
@@ -355,9 +355,10 @@
"cross-spawn": "^7.0.6", "cross-spawn": "^7.0.6",
"decimal.js": "10.5.0", "decimal.js": "10.5.0",
"diff": "catalog:", "diff": "catalog:",
"drizzle-orm": "1.0.0-beta.16-ea816b6", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"gitlab-ai-provider": "5.2.2",
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
@@ -368,6 +369,7 @@
"mime-types": "3.0.2", "mime-types": "3.0.2",
"minimatch": "10.0.3", "minimatch": "10.0.3",
"open": "10.1.2", "open": "10.1.2",
"opencode-gitlab-auth": "2.0.0",
"opentui-spinner": "0.0.6", "opentui-spinner": "0.0.6",
"partial-json": "0.1.7", "partial-json": "0.1.7",
"remeda": "catalog:", "remeda": "catalog:",
@@ -409,8 +411,8 @@
"@types/which": "3.0.4", "@types/which": "3.0.4",
"@types/yargs": "17.0.33", "@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"drizzle-kit": "1.0.0-beta.16-ea816b6", "drizzle-kit": "catalog:",
"drizzle-orm": "1.0.0-beta.16-ea816b6", "drizzle-orm": "catalog:",
"typescript": "catalog:", "typescript": "catalog:",
"vscode-languageserver-types": "3.17.5", "vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2", "why-is-node-running": "3.2.2",
@@ -419,7 +421,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.2.27", "version": "1.3.0",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"zod": "catalog:", "zod": "catalog:",
@@ -443,7 +445,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.2.27", "version": "1.3.0",
"devDependencies": { "devDependencies": {
"@hey-api/openapi-ts": "0.90.10", "@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:", "@tsconfig/node22": "catalog:",
@@ -454,7 +456,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.2.27", "version": "1.3.0",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -489,7 +491,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.2.27", "version": "1.3.0",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -535,7 +537,7 @@
}, },
"packages/util": { "packages/util": {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.2.27", "version": "1.3.0",
"dependencies": { "dependencies": {
"zod": "catalog:", "zod": "catalog:",
}, },
@@ -546,7 +548,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.2.27", "version": "1.3.0",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@@ -586,6 +588,8 @@
], ],
"patchedDependencies": { "patchedDependencies": {
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch", "@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
}, },
"overrides": { "overrides": {
@@ -608,7 +612,7 @@
"@tailwindcss/vite": "4.1.11", "@tailwindcss/vite": "4.1.11",
"@tsconfig/bun": "1.0.9", "@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2", "@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.9", "@types/bun": "1.3.11",
"@types/luxon": "3.7.1", "@types/luxon": "3.7.1",
"@types/node": "22.13.9", "@types/node": "22.13.9",
"@types/semver": "7.7.1", "@types/semver": "7.7.1",
@@ -616,8 +620,8 @@
"ai": "5.0.124", "ai": "5.0.124",
"diff": "8.0.2", "diff": "8.0.2",
"dompurify": "3.3.1", "dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.16-ea816b6", "drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.16-ea816b6", "drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.35", "effect": "4.0.0-beta.35",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"hono": "4.10.7", "hono": "4.10.7",
@@ -1108,10 +1112,6 @@
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="], "@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.6.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-8LmcIQ86xkMtC7L4P1/QYVEC+yKMTRerfPeniaaQGalnzXKtX6iMHLjLPOL9Rxp55lOXi6ed0WrFuJzZx+fNRg=="],
"@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.3", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-FT+KsCmAJjtqWr1YAq0MywGgL9kaLQ4apmsoowAXrPqHtoYf2i/nY10/A+L06kNj22EATeEDRpbB1NWXMto/SA=="],
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="], "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
@@ -1448,21 +1448,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.87", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.87", "@opentui/core-darwin-x64": "0.1.87", "@opentui/core-linux-arm64": "0.1.87", "@opentui/core-linux-x64": "0.1.87", "@opentui/core-win32-arm64": "0.1.87", "@opentui/core-win32-x64": "0.1.87", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-dhsmMv0IqKftwG7J/pBrLBj2armsYIg5R3LBvciRQI/6X89GufP4l1u0+QTACAx6iR4SYJJNVNQ2tdX8LM9rMw=="], "@opentui/core": ["@opentui/core@0.1.90", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.90", "@opentui/core-darwin-x64": "0.1.90", "@opentui/core-linux-arm64": "0.1.90", "@opentui/core-linux-x64": "0.1.90", "@opentui/core-win32-arm64": "0.1.90", "@opentui/core-win32-x64": "0.1.90", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Os2dviqWVETU3kaK36lbSvdcI93GAWhw0xb9ng/d0DWYuM9scRmAhLHiOayp61saWv/BR8OJXeuQYHvrp5rd6A=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.87", "", { "os": "darwin", "cpu": "arm64" }, "sha512-G8oq85diOfkU6n0T1CxCle7oDmpKxwhcdhZ9khBMU5IrfLx9ZDuCM3F6MsiRQWdvPPCq2oomNbd64bYkPamYgw=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.90", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XFrm2zCg1SlHPQ5A2HX/I4dCrmTjYaCJIIpo3QuPIvZBGH3aBMdWDJh2tXw7AB5Mmh8X1K4hDkP5nlK9x0Ewow=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.87", "", { "os": "darwin", "cpu": "x64" }, "sha512-MYTFQfOHm6qO7YaY4GHK9u/oJlXY6djaaxl5I+k4p2mk3vvuFIl/AP1ypITwBFjyV5gyp7PRWFp4nGfY9oN8bw=="], "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.90", "", { "os": "darwin", "cpu": "x64" }, "sha512-vbDpUsnlZ+0CeVKyBBXE+l2+X1XoVncMxMOhXTiMtud2/Cwu+Vfs/g3LC/6Zv08yaytA+9g7Z8sdf0QCqFyQ4w=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.87", "", { "os": "linux", "cpu": "arm64" }, "sha512-he8o1h5M6oskRJ7wE+xKJgmWnv5ZwN6gB3M/Z+SeHtOMPa5cZmi3TefTjG54llEgFfx0F9RcqHof7TJ/GNxRkw=="], "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.90", "", { "os": "linux", "cpu": "arm64" }, "sha512-OTbvBTP5mVQ4uwKyuz6b59ElG+D0i1Ln+q6cVhNkLgeRLySIn1uXEzUFQGlnVgb8lFDANsn3yQmdv+R+Cpw0og=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.87", "", { "os": "linux", "cpu": "x64" }, "sha512-aiUwjPlH4yDcB8/6YDKSmMkaoGAAltL0Xo0AzXyAtJXWK5tkCSaYjEVwzJ/rYRkr4Magnad+Mjth4AQUWdR2AA=="], "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.90", "", { "os": "linux", "cpu": "x64" }, "sha512-2PJi/LLlO7tGk9Ful/n+6iBdg1RFrA9ibU7wVneE6Z1P0LCYeu7bpwMzea1TXL0eAQWPHsjTs9aPlqPxln0EJw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.87", "", { "os": "win32", "cpu": "arm64" }, "sha512-cmP0pOyREjWGniHqbDmaMY7U+1AyagrD8VseJbU0cGpNgVpG2/gbrJUGdfdLB0SNb+mzLdx6SOjdxtrElwRCQA=="], "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.90", "", { "os": "win32", "cpu": "arm64" }, "sha512-+sTRaOb7gCMZ6iLuuG4y9kzyweJzBDcIJN0Xh49ikFWTwVECDXEVtXahNGlw57avm2yYUoNzmpBjK/LV7zBj9A=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.87", "", { "os": "win32", "cpu": "x64" }, "sha512-N2GErAAP8iODf2RPp86pilPaVKiD6G4pkpZL5nLGbKsl0bndrVTpSqZcn8+/nQwFZDPD/AsiRTYNOfWOblhzOw=="], "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.90", "", { "os": "win32", "cpu": "x64" }, "sha512-aVFyErckWp4oW9NJ/ZDKBUAlTlfVUiRXGP63JXFOoeqI7EYaM8uBt6rgZAJuUdFWCN2Q66WRS8Y2mk+0BJwVBg=="],
"@opentui/solid": ["@opentui/solid@0.1.87", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.87", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-lRT9t30l8+FtgOjjWJcdb2MT6hP8/RKqwGgYwTI7fXrOqdhxxwdP2SM+rH2l3suHeASheiTdlvPAo230iUcsvg=="], "@opentui/solid": ["@opentui/solid@0.1.90", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.90", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-zEHDpJOTGS707ts5j4diqoWuFLSqV6yARKl1H0FJkwWOotu+rxCyksL+C0gX0jJUonAw2cjlZ2NNtZY8g78zkg=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -1890,6 +1890,8 @@
"@solid-primitives/storage": ["@solid-primitives/storage@4.3.3", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "@tauri-apps/plugin-store": "*", "solid-js": "^1.6.12" }, "optionalPeers": ["@tauri-apps/plugin-store"] }, "sha512-ACbNwMZ1s8VAvld6EUXkDkX/US3IhtlPLxg6+B2s9MwNUugwdd51I98LPEaHrdLpqPmyzqgoJe0TxEFlf3Dqrw=="], "@solid-primitives/storage": ["@solid-primitives/storage@4.3.3", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "@tauri-apps/plugin-store": "*", "solid-js": "^1.6.12" }, "optionalPeers": ["@tauri-apps/plugin-store"] }, "sha512-ACbNwMZ1s8VAvld6EUXkDkX/US3IhtlPLxg6+B2s9MwNUugwdd51I98LPEaHrdLpqPmyzqgoJe0TxEFlf3Dqrw=="],
"@solid-primitives/timer": ["@solid-primitives/timer@1.4.4", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Ayjyb3+v1hyU92vuLUN0tVHq2mmTCPGxSDLGJMsDydRqx9ZfJIc9xj6cxK4XvdY3pif3ps2mIv52pjgToybEpQ=="],
"@solid-primitives/trigger": ["@solid-primitives/trigger@1.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Za2JebEiDyfamjmDwRaESYqBBYOlgYGzB8kHYH0QrkXyLf2qNADlKdGN+z3vWSLCTDcKxChS43Kssjuc0OZhng=="], "@solid-primitives/trigger": ["@solid-primitives/trigger@1.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Za2JebEiDyfamjmDwRaESYqBBYOlgYGzB8kHYH0QrkXyLf2qNADlKdGN+z3vWSLCTDcKxChS43Kssjuc0OZhng=="],
"@solid-primitives/utils": ["@solid-primitives/utils@6.4.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A=="], "@solid-primitives/utils": ["@solid-primitives/utils@6.4.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A=="],
@@ -1968,10 +1970,14 @@
"@tanstack/directive-functions-plugin": ["@tanstack/directive-functions-plugin@1.134.5", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/router-utils": "1.133.19", "babel-dead-code-elimination": "^1.0.10", "pathe": "^2.0.3", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "vite": ">=6.0.0 || >=7.0.0" } }, "sha512-J3oawV8uBRBbPoLgMdyHt+LxzTNuWRKNJJuCLWsm/yq6v0IQSvIVCgfD2+liIiSnDPxGZ8ExduPXy8IzS70eXw=="], "@tanstack/directive-functions-plugin": ["@tanstack/directive-functions-plugin@1.134.5", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/router-utils": "1.133.19", "babel-dead-code-elimination": "^1.0.10", "pathe": "^2.0.3", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "vite": ">=6.0.0 || >=7.0.0" } }, "sha512-J3oawV8uBRBbPoLgMdyHt+LxzTNuWRKNJJuCLWsm/yq6v0IQSvIVCgfD2+liIiSnDPxGZ8ExduPXy8IzS70eXw=="],
"@tanstack/query-core": ["@tanstack/query-core@5.91.2", "", {}, "sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw=="],
"@tanstack/router-utils": ["@tanstack/router-utils@1.133.19", "", { "dependencies": { "@babel/core": "^7.27.4", "@babel/generator": "^7.27.5", "@babel/parser": "^7.27.5", "@babel/preset-typescript": "^7.27.1", "ansis": "^4.1.0", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-WEp5D2gPxvlLDRXwD/fV7RXjYtqaqJNXKB/L6OyZEbT+9BG/Ib2d7oG9GSUZNNMGPGYAlhBUOi3xutySsk6rxA=="], "@tanstack/router-utils": ["@tanstack/router-utils@1.133.19", "", { "dependencies": { "@babel/core": "^7.27.4", "@babel/generator": "^7.27.5", "@babel/parser": "^7.27.5", "@babel/preset-typescript": "^7.27.1", "ansis": "^4.1.0", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-WEp5D2gPxvlLDRXwD/fV7RXjYtqaqJNXKB/L6OyZEbT+9BG/Ib2d7oG9GSUZNNMGPGYAlhBUOi3xutySsk6rxA=="],
"@tanstack/server-functions-plugin": ["@tanstack/server-functions-plugin@1.134.5", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/directive-functions-plugin": "1.134.5", "babel-dead-code-elimination": "^1.0.9", "tiny-invariant": "^1.3.3" } }, "sha512-2sWxq70T+dOEUlE3sHlXjEPhaFZfdPYlWTSkHchWXrFGw2YOAa+hzD6L9wHMjGDQezYd03ue8tQlHG+9Jzbzgw=="], "@tanstack/server-functions-plugin": ["@tanstack/server-functions-plugin@1.134.5", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/directive-functions-plugin": "1.134.5", "babel-dead-code-elimination": "^1.0.9", "tiny-invariant": "^1.3.3" } }, "sha512-2sWxq70T+dOEUlE3sHlXjEPhaFZfdPYlWTSkHchWXrFGw2YOAa+hzD6L9wHMjGDQezYd03ue8tQlHG+9Jzbzgw=="],
"@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="],
"@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="], "@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
"@tauri-apps/cli": ["@tauri-apps/cli@2.10.1", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.1", "@tauri-apps/cli-darwin-x64": "2.10.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", "@tauri-apps/cli-linux-arm64-musl": "2.10.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-musl": "2.10.1", "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", "@tauri-apps/cli-win32-x64-msvc": "2.10.1" }, "bin": { "tauri": "tauri.js" } }, "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g=="], "@tauri-apps/cli": ["@tauri-apps/cli@2.10.1", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.1", "@tauri-apps/cli-darwin-x64": "2.10.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", "@tauri-apps/cli-linux-arm64-musl": "2.10.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-musl": "2.10.1", "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", "@tauri-apps/cli-win32-x64-msvc": "2.10.1" }, "bin": { "tauri": "tauri.js" } }, "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g=="],
@@ -2054,7 +2060,7 @@
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="], "@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
"@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="],
@@ -2450,7 +2456,7 @@
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="], "bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="],
@@ -2736,9 +2742,9 @@
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="], "dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
"drizzle-kit": ["drizzle-kit@1.0.0-beta.16-ea816b6", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GiJQqCNPZP8Kk+i7/sFa3rtXbq26tLDNi3LbMx9aoLuwF2ofk8CS7cySUGdI+r4J3q0a568quC8FZeaFTCw4IA=="], "drizzle-kit": ["drizzle-kit@1.0.0-beta.19-d95b7a4", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-M0sqc+42TYBod6kEZ3AsW6+JWe3+76gR1aDFbHH5DmuLKEwewmbzlhBG6qnvV6YA1cIIbkuam3dC7r6PREOCXw=="],
"drizzle-orm": ["drizzle-orm@1.0.0-beta.16-ea816b6", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-k9gT4f0O9Qvah5YK/zL+FZonQ8TPyVxcG/ojN4dzO0fHP8hs8tBno8lqmJo53g0JLWv3Q2nsTUoyBRKM2TljFw=="], "drizzle-orm": ["drizzle-orm@1.0.0-beta.19-d95b7a4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-bZZKKeoRKrMVU6zKTscjrSH0+WNb1WEi3N0Jl4wEyQ7aQpTgHzdYY6IJQ1P0M74HuSJVeX4UpkFB/S6dtqLEJg=="],
"dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="], "dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
@@ -3020,6 +3026,8 @@
"get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d", "sha512-fbEK8mtr7ar4ySsF+JUGjhaZrane7dKphanN+SxHt5XXI6yLMAh/Hpf6sNCOyyVa2UlGCd7YpXG/T2v2RUAX+A=="], "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d", "sha512-fbEK8mtr7ar4ySsF+JUGjhaZrane7dKphanN+SxHt5XXI6yLMAh/Hpf6sNCOyyVa2UlGCd7YpXG/T2v2RUAX+A=="],
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
@@ -3028,6 +3036,8 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@5.2.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-ejwnie62rimfVHbjYZ2tsnqwLjF9YLgXD3OQA458gHz8hUvw7vEnhuyuMv5PmWQtyS3ISAghiX7r5SBhUWeCTA=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -3780,6 +3790,8 @@
"opencode": ["opencode@workspace:packages/opencode"], "opencode": ["opencode@workspace:packages/opencode"],
"opencode-gitlab-auth": ["opencode-gitlab-auth@2.0.0", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-jmZOOvYIurRScQCtdBqIW5HbP1JbmIiq7UtI7NGgn2vjke46g9d4NVPBg5/ZmFFVIBwZcgyFgJ7b8kGEOR9ujA=="],
"opencontrol": ["opencontrol@0.0.6", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.6.1", "@tsconfig/bun": "1.0.7", "hono": "4.7.4", "zod": "3.24.2", "zod-to-json-schema": "3.24.3" }, "bin": { "opencontrol": "bin/index.mjs" } }, "sha512-QeCrpOK5D15QV8kjnGVeD/BHFLwcVr+sn4T6KKmP0WAMs2pww56e4h+eOGHb5iPOufUQXbdbBKi6WV2kk7tefQ=="], "opencontrol": ["opencontrol@0.0.6", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.6.1", "@tsconfig/bun": "1.0.7", "hono": "4.7.4", "zod": "3.24.2", "zod-to-json-schema": "3.24.3" }, "bin": { "opencontrol": "bin/index.mjs" } }, "sha512-QeCrpOK5D15QV8kjnGVeD/BHFLwcVr+sn4T6KKmP0WAMs2pww56e4h+eOGHb5iPOufUQXbdbBKi6WV2kk7tefQ=="],
"openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="], "openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="],
@@ -4108,6 +4120,8 @@
"resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="], "responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
"restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], "restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="],
@@ -4240,7 +4254,7 @@
"socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="], "socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="],
"socket.io-parser": ["socket.io-parser@4.2.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ=="], "socket.io-parser": ["socket.io-parser@4.2.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg=="],
"socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="],
@@ -5054,10 +5068,6 @@
"@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], "@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
"@gitlab/gitlab-ai-provider/openai": ["openai@6.27.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ=="],
"@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], "@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
"@hey-api/openapi-ts/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "@hey-api/openapi-ts/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
@@ -5196,8 +5206,6 @@
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], "@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.9", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.1" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.8" }, "optionalPeers": ["solid-js"] }, "sha512-pCnxWrciluXCeli/dj5PIEHgbNzim3evtTn12snjqqg8QZWJNMjH1AWIp4iG/tbVjqQ72aBEymMSagvmgxubXw=="],
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
"@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="], "@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
@@ -5386,6 +5394,8 @@
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"db0/drizzle-orm": ["drizzle-orm@1.0.0-beta.16-ea816b6", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-k9gT4f0O9Qvah5YK/zL+FZonQ8TPyVxcG/ojN4dzO0fHP8hs8tBno8lqmJo53g0JLWv3Q2nsTUoyBRKM2TljFw=="],
"defaults/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], "defaults/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
"dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
@@ -5452,6 +5462,10 @@
"gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], "gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"gitlab-ai-provider/openai": ["openai@6.32.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-j3k+BjydAf8yQlcOI7WUQMQTbbF5GEIMAE2iZYCOzwwB3S2pCheaWYp+XZRNAch4jWVc52PMDGRRjutao3lLCg=="],
"gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], "glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
@@ -5528,6 +5542,8 @@
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="], "opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="], "opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
"opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="], "opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="],
@@ -6278,6 +6294,8 @@
"node-gyp/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], "node-gyp/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], "opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], "opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
+1
View File
@@ -122,6 +122,7 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
properties: { properties: {
product: zenLiteProduct.id, product: zenLiteProduct.id,
price: zenLitePrice.id, price: zenLitePrice.id,
priceInr: 92900,
firstMonth50Coupon: zenLiteCouponFirstMonth50.id, firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
}, },
}) })
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-yfA50QKqylmaioxi+6d++W8Xv4Wix1hl3hEF6Zz7Ue0=", "x86_64-linux": "sha256-E5neEbBiwQDhIQ5QVhijpHCCP9hcxm319S9WrDKngSw=",
"aarch64-linux": "sha256-b5sO7V+/zzJClHHKjkSz+9AUBYC8cb7S3m5ab1kpAyk=", "aarch64-linux": "sha256-lnwaGSEirl9izskDooB/xQ0ZdirW0t3/S+OoOnfYaoQ=",
"aarch64-darwin": "sha256-V66nmRX6kAjrc41ARVeuTElWK7KD8qG/DVk9K7Fu+J8=", "aarch64-darwin": "sha256-RDxxW9NMlGMIdIxTsbOYVqxunflkILv2dA7JqjnJgm4=",
"x86_64-darwin": "sha256-cFyh60WESiqZ5XWZi1+g3F/beSDL1+UPG8KhRivhK8w=" "x86_64-darwin": "sha256-1tvvktu2NRg6N6ASuKzqzcEmMrzH3/LFey0Vxr4E8zg="
} }
} }
+7 -5
View File
@@ -4,7 +4,7 @@
"description": "AI-powered development tool", "description": "AI-powered development tool",
"private": true, "private": true,
"type": "module", "type": "module",
"packageManager": "bun@1.3.10", "packageManager": "bun@1.3.11",
"scripts": { "scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop tauri dev", "dev:desktop": "bun --cwd packages/desktop tauri dev",
@@ -26,7 +26,7 @@
], ],
"catalog": { "catalog": {
"@effect/platform-node": "4.0.0-beta.35", "@effect/platform-node": "4.0.0-beta.35",
"@types/bun": "1.3.9", "@types/bun": "1.3.11",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"ulid": "3.0.1", "ulid": "3.0.1",
@@ -43,8 +43,8 @@
"@tailwindcss/vite": "4.1.11", "@tailwindcss/vite": "4.1.11",
"diff": "8.0.2", "diff": "8.0.2",
"dompurify": "3.3.1", "dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.16-ea816b6", "drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.16-ea816b6", "drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.35", "effect": "4.0.0-beta.35",
"ai": "5.0.124", "ai": "5.0.124",
"hono": "4.10.7", "hono": "4.10.7",
@@ -112,6 +112,8 @@
}, },
"patchedDependencies": { "patchedDependencies": {
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch" "@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
} }
} }
+172 -9
View File
@@ -1,3 +1,4 @@
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
import { expect, type Locator, type Page } from "@playwright/test" import { expect, type Locator, type Page } from "@playwright/test"
import fs from "node:fs/promises" import fs from "node:fs/promises"
import os from "node:os" import os from "node:os"
@@ -8,6 +9,7 @@ import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
import { import {
dropdownMenuTriggerSelector, dropdownMenuTriggerSelector,
dropdownMenuContentSelector, dropdownMenuContentSelector,
projectSwitchSelector,
projectMenuTriggerSelector, projectMenuTriggerSelector,
projectCloseMenuSelector, projectCloseMenuSelector,
projectWorkspacesToggleSelector, projectWorkspacesToggleSelector,
@@ -22,6 +24,16 @@ import {
workspaceMenuTriggerSelector, workspaceMenuTriggerSelector,
} from "./selectors" } from "./selectors"
const phase = new WeakMap<Page, "test" | "cleanup">()
export function setHealthPhase(page: Page, value: "test" | "cleanup") {
phase.set(page, value)
}
export function healthPhase(page: Page) {
return phase.get(page) ?? "test"
}
export async function defocus(page: Page) { export async function defocus(page: Page) {
await page await page
.evaluate(() => { .evaluate(() => {
@@ -163,9 +175,9 @@ export async function runTerminal(page: Page, input: { cmd: string; token: strin
await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true) await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
} }
export async function openPalette(page: Page) { export async function openPalette(page: Page, key = "K") {
await defocus(page) await defocus(page)
await page.keyboard.press(`${modKey}+P`) await page.keyboard.press(`${modKey}+${key}`)
const dialog = page.getByRole("dialog") const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible() await expect(dialog).toBeVisible()
@@ -195,11 +207,51 @@ export async function closeDialog(page: Page, dialog: Locator) {
} }
export async function isSidebarClosed(page: Page) { export async function isSidebarClosed(page: Page) {
const button = page.getByRole("button", { name: /toggle sidebar/i }).first() const button = await waitSidebarButton(page, "isSidebarClosed")
await expect(button).toBeVisible()
return (await button.getAttribute("aria-expanded")) !== "true" return (await button.getAttribute("aria-expanded")) !== "true"
} }
async function errorBoundaryText(page: Page) {
const title = page.getByRole("heading", { name: /something went wrong/i }).first()
if (!(await title.isVisible().catch(() => false))) return
const description = await page
.getByText(/an error occurred while loading the application\./i)
.first()
.textContent()
.catch(() => "")
const detail = await page
.getByRole("textbox", { name: /error details/i })
.first()
.inputValue()
.catch(async () =>
(
(await page
.getByRole("textbox", { name: /error details/i })
.first()
.textContent()
.catch(() => "")) ?? ""
).trim(),
)
return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
}
export async function assertHealthy(page: Page, context: string) {
const text = await errorBoundaryText(page)
if (!text) return
console.log(`[e2e:error-boundary][${context}]\n${text}`)
throw new Error(`Error boundary during ${context}\n${text}`)
}
async function waitSidebarButton(page: Page, context: string) {
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
const boundary = page.getByRole("heading", { name: /something went wrong/i }).first()
await button.or(boundary).first().waitFor({ state: "visible", timeout: 10_000 })
await assertHealthy(page, context)
return button
}
export async function toggleSidebar(page: Page) { export async function toggleSidebar(page: Page) {
await defocus(page) await defocus(page)
await page.keyboard.press(`${modKey}+B`) await page.keyboard.press(`${modKey}+B`)
@@ -208,7 +260,7 @@ export async function toggleSidebar(page: Page) {
export async function openSidebar(page: Page) { export async function openSidebar(page: Page) {
if (!(await isSidebarClosed(page))) return if (!(await isSidebarClosed(page))) return
const button = page.getByRole("button", { name: /toggle sidebar/i }).first() const button = await waitSidebarButton(page, "openSidebar")
await button.click() await button.click()
const opened = await expect(button) const opened = await expect(button)
@@ -225,7 +277,7 @@ export async function openSidebar(page: Page) {
export async function closeSidebar(page: Page) { export async function closeSidebar(page: Page) {
if (await isSidebarClosed(page)) return if (await isSidebarClosed(page)) return
const button = page.getByRole("button", { name: /toggle sidebar/i }).first() const button = await waitSidebarButton(page, "closeSidebar")
await button.click() await button.click()
const closed = await expect(button) const closed = await expect(button)
@@ -240,6 +292,7 @@ export async function closeSidebar(page: Page) {
} }
export async function openSettings(page: Page) { export async function openSettings(page: Page) {
await assertHealthy(page, "openSettings")
await defocus(page) await defocus(page)
const dialog = page.getByRole("dialog") const dialog = page.getByRole("dialog")
@@ -252,6 +305,8 @@ export async function openSettings(page: Page) {
if (opened) return dialog if (opened) return dialog
await assertHealthy(page, "openSettings")
await page.getByRole("button", { name: "Settings" }).first().click() await page.getByRole("button", { name: "Settings" }).first().click()
await expect(dialog).toBeVisible() await expect(dialog).toBeVisible()
return dialog return dialog
@@ -313,10 +368,12 @@ export async function seedProjects(page: Page, input: { directory: string; extra
export async function createTestProject() { export async function createTestProject() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-")) const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
const id = `e2e-${path.basename(root)}`
await fs.writeFile(path.join(root, "README.md"), "# e2e\n") await fs.writeFile(path.join(root, "README.md"), `# e2e\n\n${id}\n`)
execSync("git init", { cwd: root, stdio: "ignore" }) execSync("git init", { cwd: root, stdio: "ignore" })
await fs.writeFile(path.join(root, ".git", "opencode"), id)
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" }) execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
execSync("git add -A", { cwd: root, stdio: "ignore" }) execSync("git add -A", { cwd: root, stdio: "ignore" })
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', { execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
@@ -338,12 +395,24 @@ export function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? "" return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
} }
async function probeSession(page: Page) {
return page
.evaluate(() => {
const win = window as E2EWindow
const current = win.__opencode_e2e?.model?.current
if (!current) return null
return { dir: current.dir, sessionID: current.sessionID }
})
.catch(() => null as { dir?: string; sessionID?: string } | null)
}
export async function waitSlug(page: Page, skip: string[] = []) { export async function waitSlug(page: Page, skip: string[] = []) {
let prev = "" let prev = ""
let next = "" let next = ""
await expect await expect
.poll( .poll(
() => { async () => {
await assertHealthy(page, "waitSlug")
const slug = slugFromUrl(page.url()) const slug = slugFromUrl(page.url())
if (!slug) return "" if (!slug) return ""
if (skip.includes(slug)) return "" if (skip.includes(slug)) return ""
@@ -361,6 +430,94 @@ export async function waitSlug(page: Page, skip: string[] = []) {
return next return next
} }
export async function resolveSlug(slug: string) {
const directory = base64Decode(slug)
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
const resolved = await resolveDirectory(directory)
return { directory: resolved, slug: base64Encode(resolved), raw: slug }
}
export async function waitDir(page: Page, directory: string) {
const target = await resolveDirectory(directory)
await expect
.poll(
async () => {
await assertHealthy(page, "waitDir")
const slug = slugFromUrl(page.url())
if (!slug) return ""
return resolveSlug(slug)
.then((item) => item.directory)
.catch(() => "")
},
{ timeout: 45_000 },
)
.toBe(target)
return { directory: target, slug: base64Encode(target) }
}
export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) {
const target = await resolveDirectory(input.directory)
await expect
.poll(
async () => {
await assertHealthy(page, "waitSession")
const slug = slugFromUrl(page.url())
if (!slug) return false
const resolved = await resolveSlug(slug).catch(() => undefined)
if (!resolved || resolved.directory !== target) return false
if (input.sessionID && sessionIDFromUrl(page.url()) !== input.sessionID) return false
const state = await probeSession(page)
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
if (state?.dir) {
const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
if (dir !== target) return false
}
return page
.locator(promptSelector)
.first()
.isVisible()
.catch(() => false)
},
{ timeout: 45_000 },
)
.toBe(true)
return { directory: target, slug: base64Encode(target) }
}
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) {
const sdk = createSdk(directory)
const target = await resolveDirectory(directory)
await expect
.poll(
async () => {
const data = await sdk.session
.get({ sessionID })
.then((x) => x.data)
.catch(() => undefined)
if (!data?.directory) return ""
return resolveDirectory(data.directory).catch(() => data.directory)
},
{ timeout },
)
.toBe(target)
await expect
.poll(
async () => {
const items = await sdk.session
.messages({ sessionID, limit: 20 })
.then((x) => x.data ?? [])
.catch(() => [])
return items.some((item) => item.info.role === "user")
},
{ timeout },
)
.toBe(true)
}
export function sessionIDFromUrl(url: string) { export function sessionIDFromUrl(url: string) {
const match = /\/session\/([^/?#]+)/.exec(url) const match = /\/session\/([^/?#]+)/.exec(url)
return match?.[1] return match?.[1]
@@ -772,8 +929,14 @@ export async function openStatusPopover(page: Page) {
} }
export async function openProjectMenu(page: Page, projectSlug: string) { export async function openProjectMenu(page: Page, projectSlug: string) {
await openSidebar(page)
const item = page.locator(projectSwitchSelector(projectSlug)).first()
await expect(item).toBeVisible()
await item.hover()
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first() const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
await expect(trigger).toHaveCount(1) await expect(trigger).toHaveCount(1)
await expect(trigger).toBeVisible()
const menu = page const menu = page
.locator(dropdownMenuContentSelector) .locator(dropdownMenuContentSelector)
@@ -782,7 +945,7 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first() const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
const clicked = await trigger const clicked = await trigger
.click({ timeout: 1500 }) .click({ force: true, timeout: 1500 })
.then(() => true) .then(() => true)
.catch(() => false) .catch(() => false)
+10 -1
View File
@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { openPalette } from "../actions" import { closeDialog, openPalette } from "../actions"
test("search palette opens and closes", async ({ page, gotoSession }) => { test("search palette opens and closes", async ({ page, gotoSession }) => {
await gotoSession() await gotoSession()
@@ -9,3 +9,12 @@ test("search palette opens and closes", async ({ page, gotoSession }) => {
await page.keyboard.press("Escape") await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0) await expect(dialog).toHaveCount(0)
}) })
test("search palette also opens with cmd+p", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openPalette(page, "P")
await closeDialog(page, dialog)
await expect(dialog).toHaveCount(0)
})
+39 -5
View File
@@ -1,7 +1,16 @@
import { test as base, expect, type Page } from "@playwright/test" import { test as base, expect, type Page } from "@playwright/test"
import type { E2EWindow } from "../src/testing/terminal" import type { E2EWindow } from "../src/testing/terminal"
import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions" import {
import { promptSelector } from "./selectors" healthPhase,
cleanupSession,
cleanupTestProject,
createTestProject,
setHealthPhase,
seedProjects,
sessionIDFromUrl,
waitSlug,
waitSession,
} from "./actions"
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils" import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
export const settingsKey = "settings.v3" export const settingsKey = "settings.v3"
@@ -27,6 +36,29 @@ type WorkerFixtures = {
} }
export const test = base.extend<TestFixtures, WorkerFixtures>({ export const test = base.extend<TestFixtures, WorkerFixtures>({
page: async ({ page }, use) => {
let boundary: string | undefined
setHealthPhase(page, "test")
const consoleHandler = (msg: { text(): string }) => {
const text = msg.text()
if (!text.includes("[e2e:error-boundary]")) return
if (healthPhase(page) === "cleanup") {
console.warn(`[e2e:error-boundary][cleanup-warning]\n${text}`)
return
}
boundary ||= text
console.log(text)
}
const pageErrorHandler = (err: Error) => {
console.log(`[e2e:pageerror] ${err.stack || err.message}`)
}
page.on("console", consoleHandler)
page.on("pageerror", pageErrorHandler)
await use(page)
page.off("console", consoleHandler)
page.off("pageerror", pageErrorHandler)
if (boundary) throw new Error(boundary)
},
directory: [ directory: [
async ({}, use) => { async ({}, use) => {
const directory = await getWorktree() const directory = await getWorktree()
@@ -48,21 +80,20 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
const gotoSession = async (sessionID?: string) => { const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(directory, sessionID)) await page.goto(sessionPath(directory, sessionID))
await expect(page.locator(promptSelector)).toBeVisible() await waitSession(page, { directory, sessionID })
} }
await use(gotoSession) await use(gotoSession)
}, },
withProject: async ({ page }, use) => { withProject: async ({ page }, use) => {
await use(async (callback, options) => { await use(async (callback, options) => {
const root = await createTestProject() const root = await createTestProject()
const slug = dirSlug(root)
const sessions = new Map<string, string>() const sessions = new Map<string, string>()
const dirs = new Set<string>() const dirs = new Set<string>()
await seedStorage(page, { directory: root, extra: options?.extra }) await seedStorage(page, { directory: root, extra: options?.extra })
const gotoSession = async (sessionID?: string) => { const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(root, sessionID)) await page.goto(sessionPath(root, sessionID))
await expect(page.locator(promptSelector)).toBeVisible() await waitSession(page, { directory: root, sessionID })
const current = sessionIDFromUrl(page.url()) const current = sessionIDFromUrl(page.url())
if (current) trackSession(current) if (current) trackSession(current)
} }
@@ -77,13 +108,16 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
try { try {
await gotoSession() await gotoSession()
const slug = await waitSlug(page)
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory }) return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
} finally { } finally {
setHealthPhase(page, "cleanup")
await Promise.allSettled( await Promise.allSettled(
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })), Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
) )
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory))) await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
await cleanupTestProject(root) await cleanupTestProject(root)
setHealthPhase(page, "test")
} }
}) })
}, },
@@ -1,41 +1,19 @@
import { base64Decode } from "@opencode-ai/util/encode" import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions" import {
defocus,
createTestProject,
cleanupTestProject,
openSidebar,
sessionIDFromUrl,
setWorkspacesEnabled,
waitSession,
waitSessionSaved,
waitSlug,
} from "../actions"
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { dirSlug, resolveDirectory } from "../utils" import { dirSlug, resolveDirectory } from "../utils"
async function workspaces(page: Page, directory: string, enabled: boolean) {
await page.evaluate(
({ directory, enabled }: { directory: string; enabled: boolean }) => {
const key = "opencode.global.dat:layout"
const raw = localStorage.getItem(key)
const data = raw ? JSON.parse(raw) : {}
const sidebar = data.sidebar && typeof data.sidebar === "object" ? data.sidebar : {}
const current =
sidebar.workspaces && typeof sidebar.workspaces === "object" && !Array.isArray(sidebar.workspaces)
? sidebar.workspaces
: {}
const next = { ...current }
if (enabled) next[directory] = true
if (!enabled) delete next[directory]
localStorage.setItem(
key,
JSON.stringify({
...data,
sidebar: {
...sidebar,
workspaces: next,
},
}),
)
},
{ directory, enabled },
)
}
test("can switch between projects from sidebar", async ({ page, withProject }) => { test("can switch between projects from sidebar", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
@@ -76,9 +54,7 @@ test("switching back to a project opens the latest workspace session", async ({
await withProject( await withProject(
async ({ directory, slug, trackSession, trackDirectory }) => { async ({ directory, slug, trackSession, trackDirectory }) => {
await defocus(page) await defocus(page)
await workspaces(page, directory, true) await setWorkspacesEnabled(page, slug, true)
await page.reload()
await expect(page.locator(promptSelector)).toBeVisible()
await openSidebar(page) await openSidebar(page)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
@@ -100,11 +76,7 @@ test("switching back to a project opens the latest workspace session", async ({
await expect(btn).toBeVisible() await expect(btn).toBeVisible()
await btn.click({ force: true }) await btn.click({ force: true })
// A new workspace can be discovered via a transient slug before the route and sidebar await waitSession(page, { directory: space })
// settle to the canonical workspace path on Windows, so interact with either and assert
// against the resolved workspace slug.
await waitSlug(page)
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
// Create a session by sending a prompt // Create a session by sending a prompt
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
@@ -118,6 +90,7 @@ test("switching back to a project opens the latest workspace session", async ({
const created = sessionIDFromUrl(page.url()) const created = sessionIDFromUrl(page.url())
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`) if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
trackSession(created, space) trackSession(created, space)
await waitSessionSaved(space, created)
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`)) await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
@@ -125,14 +98,14 @@ test("switching back to a project opens the latest workspace session", async ({
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible() await expect(otherButton).toBeVisible()
await otherButton.click() await otherButton.click({ force: true })
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) await waitSession(page, { directory: other })
const rootButton = page.locator(projectSwitchSelector(slug)).first() const rootButton = page.locator(projectSwitchSelector(slug)).first()
await expect(rootButton).toBeVisible() await expect(rootButton).toBeVisible()
await rootButton.click() await rootButton.click({ force: true })
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created) await waitSession(page, { directory: space, sessionID: created })
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`)) await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
}, },
{ extra: [other] }, { extra: [other] },
@@ -1,109 +1,94 @@
import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test" import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, slugFromUrl, waitSlug } from "../actions" import {
openSidebar,
resolveSlug,
sessionIDFromUrl,
setWorkspacesEnabled,
waitDir,
waitSession,
waitSessionSaved,
waitSlug,
} from "../actions"
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk } from "../utils" import { createSdk } from "../utils"
async function waitWorkspaceReady(page: Page, slug: string) { function item(space: { slug: string; raw: string }) {
return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
}
function button(space: { slug: string; raw: string }) {
return `${workspaceNewSessionSelector(space.slug)}, ${workspaceNewSessionSelector(space.raw)}`
}
async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) {
await openSidebar(page) await openSidebar(page)
await expect await expect(page.locator(item(space)).first()).toBeVisible({ timeout: 60_000 })
.poll(
async () => {
const item = page.locator(workspaceItemSelector(slug)).first()
try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
} }
async function createWorkspace(page: Page, root: string, seen: string[]) { async function createWorkspace(page: Page, root: string, seen: string[]) {
await openSidebar(page) await openSidebar(page)
await page.getByRole("button", { name: "New workspace" }).first().click() await page.getByRole("button", { name: "New workspace" }).first().click()
const slug = await waitSlug(page, [root, ...seen]) const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
const directory = base64Decode(slug) await waitDir(page, next.directory)
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
return { slug, directory }
}
async function openWorkspaceNewSession(page: Page, slug: string) {
await waitWorkspaceReady(page, slug)
const item = page.locator(workspaceItemSelector(slug)).first()
await item.hover()
const button = page.locator(workspaceNewSessionSelector(slug)).first()
await expect(button).toBeVisible()
await button.click({ force: true })
const next = await waitSlug(page)
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
return next return next
} }
async function createSessionFromWorkspace(page: Page, slug: string, text: string) { async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: string; directory: string }) {
const next = await openWorkspaceNewSession(page, slug) await waitWorkspaceReady(page, space)
const row = page.locator(item(space)).first()
await row.hover()
const next = page.locator(button(space)).first()
await expect(next).toBeVisible()
await next.click({ force: true })
await waitSession(page, { directory: space.directory })
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe("")
}
async function createSessionFromWorkspace(
page: Page,
space: { slug: string; raw: string; directory: string },
text: string,
) {
await openWorkspaceNewSession(page, space)
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible() await expect(prompt).toBeVisible()
await expect(prompt).toBeEditable()
await prompt.click()
await expect(prompt).toBeFocused()
await prompt.fill(text) await prompt.fill(text)
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text) await page.keyboard.press("Enter")
await prompt.press("Enter")
await expect.poll(() => slugFromUrl(page.url())).toBe(next)
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
const sessionID = sessionIDFromUrl(page.url()) const sessionID = sessionIDFromUrl(page.url())
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`) if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`))
return { sessionID, slug: next }
}
async function sessionDirectory(directory: string, sessionID: string) { await waitSessionSaved(space.directory, sessionID)
const info = await createSdk(directory) await createSdk(space.directory)
.session.get({ sessionID }) .session.abort({ sessionID })
.then((x) => x.data)
.catch(() => undefined) .catch(() => undefined)
if (!info) return "" return sessionID
return info.directory
} }
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => { test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => { await withProject(async ({ slug: root, trackDirectory, trackSession }) => {
await openSidebar(page) await openSidebar(page)
await setWorkspacesEnabled(page, root, true) await setWorkspacesEnabled(page, root, true)
const first = await createWorkspace(page, root, []) const first = await createWorkspace(page, root, [])
trackDirectory(first.directory) trackDirectory(first.directory)
await waitWorkspaceReady(page, first.slug) await waitWorkspaceReady(page, first)
const second = await createWorkspace(page, root, [first.slug]) const second = await createWorkspace(page, root, [first.slug])
trackDirectory(second.directory) trackDirectory(second.directory)
await waitWorkspaceReady(page, second.slug) await waitWorkspaceReady(page, second)
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`) trackSession(await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`), first.directory)
trackSession(firstSession.sessionID, first.directory) trackSession(await createSessionFromWorkspace(page, second, `workspace two ${Date.now()}`), second.directory)
trackSession(await createSessionFromWorkspace(page, first, `workspace one again ${Date.now()}`), first.directory)
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
trackSession(secondSession.sessionID, second.directory)
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
trackSession(thirdSession.sessionID, first.directory)
await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
}) })
}) })
+16 -14
View File
@@ -1,7 +1,7 @@
import { base64Decode } from "@opencode-ai/util/encode"
import fs from "node:fs/promises" import fs from "node:fs/promises"
import os from "node:os" import os from "node:os"
import path from "node:path" import path from "node:path"
import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test" import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
@@ -13,8 +13,10 @@ import {
confirmDialog, confirmDialog,
openSidebar, openSidebar,
openWorkspaceMenu, openWorkspaceMenu,
resolveSlug,
setWorkspacesEnabled, setWorkspacesEnabled,
slugFromUrl, slugFromUrl,
waitDir,
waitSlug, waitSlug,
} from "../actions" } from "../actions"
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors" import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
@@ -27,15 +29,15 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
await setWorkspacesEnabled(page, rootSlug, true) await setWorkspacesEnabled(page, rootSlug, true)
await page.getByRole("button", { name: "New workspace" }).first().click() await page.getByRole("button", { name: "New workspace" }).first().click()
const slug = await waitSlug(page, [rootSlug]) const next = await resolveSlug(await waitSlug(page, [rootSlug]))
const dir = base64Decode(slug) await waitDir(page, next.directory)
await openSidebar(page) await openSidebar(page)
await expect await expect
.poll( .poll(
async () => { async () => {
const item = page.locator(workspaceItemSelector(slug)).first() const item = page.locator(workspaceItemSelector(next.slug)).first()
try { try {
await item.hover({ timeout: 500 }) await item.hover({ timeout: 500 })
return true return true
@@ -47,7 +49,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
) )
.toBe(true) .toBe(true)
return { rootSlug, slug, directory: dir } return { rootSlug, slug: next.slug, directory: next.directory }
} }
test("can enable and disable workspaces from project menu", async ({ page, withProject }) => { test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
@@ -79,15 +81,15 @@ test("can create a workspace", async ({ page, withProject }) => {
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await page.getByRole("button", { name: "New workspace" }).first().click() await page.getByRole("button", { name: "New workspace" }).first().click()
const workspaceSlug = await waitSlug(page, [slug]) const next = await resolveSlug(await waitSlug(page, [slug]))
const workspaceDir = base64Decode(workspaceSlug) await waitDir(page, next.directory)
await openSidebar(page) await openSidebar(page)
await expect await expect
.poll( .poll(
async () => { async () => {
const item = page.locator(workspaceItemSelector(workspaceSlug)).first() const item = page.locator(workspaceItemSelector(next.slug)).first()
try { try {
await item.hover({ timeout: 500 }) await item.hover({ timeout: 500 })
return true return true
@@ -99,9 +101,9 @@ test("can create a workspace", async ({ page, withProject }) => {
) )
.toBe(true) .toBe(true)
await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible() await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
await cleanupTestProject(workspaceDir) await cleanupTestProject(next.directory)
}) })
}) })
@@ -119,7 +121,7 @@ test("non-git projects keep workspace mode disabled", async ({ page, withProject
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("") await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
const activeDir = base64Decode(slugFromUrl(page.url())) const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory)
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-") expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
await openSidebar(page) await openSidebar(page)
@@ -331,9 +333,9 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
for (const _ of [0, 1]) { for (const _ of [0, 1]) {
const prev = slugFromUrl(page.url()) const prev = slugFromUrl(page.url())
await page.getByRole("button", { name: "New workspace" }).first().click() await page.getByRole("button", { name: "New workspace" }).first().click()
const slug = await waitSlug(page, [rootSlug, prev]) const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
const dir = base64Decode(slug) await waitDir(page, next.directory)
workspaces.push({ slug, directory: dir }) workspaces.push(next)
await openSidebar(page) await openSidebar(page)
} }
@@ -108,7 +108,10 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
await page.keyboard.type(draft) await page.keyboard.type(draft)
await wait(page, draft) await wait(page, draft)
await edge(page, "start") // Clear the draft before navigating history (ArrowUp only works when prompt is empty)
await prompt.fill("")
await wait(page, "")
await page.keyboard.press("ArrowUp") await page.keyboard.press("ArrowUp")
await wait(page, second) await wait(page, second)
@@ -119,7 +122,7 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
await wait(page, second) await wait(page, second)
await page.keyboard.press("ArrowDown") await page.keyboard.press("ArrowDown")
await wait(page, draft) await wait(page, "")
}) })
}) })
@@ -1,7 +1,14 @@
import { base64Decode } from "@opencode-ai/util/encode"
import type { Locator, Page } from "@playwright/test" import type { Locator, Page } from "@playwright/test"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions" import {
openSidebar,
resolveSlug,
sessionIDFromUrl,
setWorkspacesEnabled,
waitSession,
waitSessionIdle,
waitSlug,
} from "../actions"
import { import {
promptAgentSelector, promptAgentSelector,
promptModelSelector, promptModelSelector,
@@ -30,8 +37,6 @@ const text = async (locator: Locator) => ((await locator.textContent()) ?? "").t
const modelKey = (state: Probe | null) => (state?.model ? `${state.model.providerID}:${state.model.modelID}` : null) const modelKey = (state: Probe | null) => (state?.model ? `${state.model.providerID}:${state.model.modelID}` : null)
const dirKey = (state: Probe | null) => state?.dir ?? ""
async function probe(page: Page): Promise<Probe | null> { async function probe(page: Page): Promise<Probe | null> {
return page.evaluate(() => { return page.evaluate(() => {
const win = window as Window & { const win = window as Window & {
@@ -45,21 +50,6 @@ async function probe(page: Page): Promise<Probe | null> {
}) })
} }
async function currentDir(page: Page) {
let hit = ""
await expect
.poll(
async () => {
const next = dirKey(await probe(page))
if (next) hit = next
return next
},
{ timeout: 30_000 },
)
.not.toBe("")
return hit
}
async function read(page: Page): Promise<Footer> { async function read(page: Page): Promise<Footer> {
return { return {
agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()), agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()),
@@ -188,8 +178,7 @@ async function chooseOtherModel(page: Page): Promise<Footer> {
async function goto(page: Page, directory: string, sessionID?: string) { async function goto(page: Page, directory: string, sessionID?: string) {
await page.goto(sessionPath(directory, sessionID)) await page.goto(sessionPath(directory, sessionID))
await expect(page.locator(promptSelector)).toBeVisible() await waitSession(page, { directory, sessionID })
await expect.poll(async () => dirKey(await probe(page)), { timeout: 30_000 }).toBe(directory)
} }
async function submit(page: Page, value: string) { async function submit(page: Page, value: string) {
@@ -224,10 +213,9 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
await openSidebar(page) await openSidebar(page)
await page.getByRole("button", { name: "New workspace" }).first().click() await page.getByRole("button", { name: "New workspace" }).first().click()
const slug = await waitSlug(page, [root, ...seen]) const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
const directory = base64Decode(slug) await waitSession(page, { directory: next.directory })
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`) return next
return { slug, directory }
} }
async function waitWorkspace(page: Page, slug: string) { async function waitWorkspace(page: Page, slug: string) {
@@ -257,10 +245,8 @@ async function newWorkspaceSession(page: Page, slug: string) {
await expect(button).toBeVisible() await expect(button).toBeVisible()
await button.click({ force: true }) await button.click({ force: true })
const next = await waitSlug(page) const next = await resolveSlug(await waitSlug(page))
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`)) return waitSession(page, { directory: next.directory }).then((item) => item.directory)
await expect(page.locator(promptSelector)).toBeVisible()
return currentDir(page)
} }
test("session model and variant restore per session without leaking into new sessions", async ({ test("session model and variant restore per session without leaking into new sessions", async ({
@@ -279,7 +265,7 @@ test("session model and variant restore per session without leaking into new ses
await waitUser(directory, first) await waitUser(directory, first)
await page.reload() await page.reload()
await expect(page.locator(promptSelector)).toBeVisible() await waitSession(page, { directory, sessionID: first })
await waitFooter(page, firstState) await waitFooter(page, firstState)
await gotoSession() await gotoSession()
@@ -169,6 +169,70 @@ async function overflow(page: Parameters<typeof test>[0]["page"], file: string)
} }
} }
async function openReviewFile(page: Parameters<typeof test>[0]["page"], file: string) {
const row = page.locator(`[data-file="${file}"]`).first()
await expect(row).toBeVisible()
await row.hover()
const open = row.getByRole("button", { name: /^Open file$/i }).first()
await expect(open).toBeVisible()
await open.click()
const tab = page.getByRole("tab", { name: file }).first()
await expect(tab).toBeVisible()
await tab.click()
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
return viewer
}
async function fileComment(page: Parameters<typeof test>[0]["page"], note: string) {
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
const line = viewer.locator('diffs-container [data-line="2"]').first()
await expect(line).toBeVisible()
await line.hover()
const add = viewer.getByRole("button", { name: /^Comment$/ }).first()
await expect(add).toBeVisible()
await add.click()
const area = viewer.locator('[data-slot="line-comment-textarea"]').first()
await expect(area).toBeVisible()
await area.fill(note)
const submit = viewer.locator('[data-slot="line-comment-action"][data-variant="primary"]').first()
await expect(submit).toBeEnabled()
await submit.click()
await expect(viewer.locator('[data-slot="line-comment-content"]').filter({ hasText: note }).first()).toBeVisible()
await expect(viewer.locator('[data-slot="line-comment-tools"]').first()).toBeVisible()
}
async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
const view = page.locator('[role="tabpanel"] .scroll-view__viewport').first()
const pop = viewer.locator('[data-slot="line-comment-popover"][data-inline-body]').first()
const tools = viewer.locator('[data-slot="line-comment-tools"]').first()
const [width, viewBox, popBox, toolsBox] = await Promise.all([
view.evaluate((el) => el.scrollWidth - el.clientWidth),
view.boundingBox(),
pop.boundingBox(),
tools.boundingBox(),
])
if (!viewBox || !popBox || !toolsBox) return null
return {
width,
pop: popBox.x + popBox.width - (viewBox.x + viewBox.width),
tools: toolsBox.x + toolsBox.width - (viewBox.x + viewBox.width),
}
}
test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => { test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
test.setTimeout(180_000) test.setTimeout(180_000)
@@ -218,6 +282,56 @@ test("review applies inline comment clicks without horizontal overflow", async (
}) })
}) })
test("review file comments submit on click without clipping actions", async ({ page, withProject }) => {
test.setTimeout(180_000)
const tag = `review-file-comment-${Date.now()}`
const file = `review-file-comment-${tag}.txt`
const note = `comment ${tag}`
await page.setViewportSize({ width: 1280, height: 900 })
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e review file comment ${tag}`, async (session) => {
await patch(sdk, session.id, seed([{ file, mark: tag }]))
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
)
.toBe(1)
await project.gotoSession(session.id)
await show(page)
const tab = page.getByRole("tab", { name: /Review/i }).first()
await expect(tab).toBeVisible()
await tab.click()
await expand(page)
await waitMark(page, file, tag)
await openReviewFile(page, file)
await fileComment(page, note)
await expect
.poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
})
})
})
test("review keeps scroll position after a live diff update", async ({ page, withProject }) => { test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
test.skip(Boolean(process.env.CI), "Flaky in CI for now.") test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
test.setTimeout(180_000) test.setTimeout(180_000)
@@ -241,7 +241,7 @@ test("changing file open keybind works", async ({ page, gotoSession }) => {
await expect(keybindButton).toBeVisible() await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent() const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain("P") expect(initialKeybind).toContain("K")
await keybindButton.click() await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i) await expect(keybindButton).toHaveText(/press/i)
@@ -1,6 +1,16 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { cleanupSession, closeSidebar, hoverSessionItem } from "../actions" import {
defocus,
cleanupSession,
cleanupTestProject,
closeSidebar,
createTestProject,
hoverSessionItem,
openSidebar,
waitSession,
} from "../actions"
import { projectSwitchSelector } from "../selectors" import { projectSwitchSelector } from "../selectors"
import { dirSlug } from "../utils"
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => { test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
const stamp = Date.now() const stamp = Date.now()
@@ -37,3 +47,72 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
await cleanupSession({ sdk, sessionID: two.id }) await cleanupSession({ sdk, sessionID: two.id })
} }
}) })
test("open sidebar project popover stays closed after clicking avatar", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const slug = dirSlug(other)
try {
await withProject(
async () => {
await openSidebar(page)
const project = page.locator(projectSwitchSelector(slug)).first()
const card = page.locator('[data-component="hover-card-content"]')
await expect(project).toBeVisible()
await project.hover()
await expect(card.getByText(/recent sessions/i)).toBeVisible()
await page.mouse.down()
await expect(card).toHaveCount(0)
await page.mouse.up()
await waitSession(page, { directory: other })
await expect(card).toHaveCount(0)
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
})
test("open sidebar project switch activates on first tabbed enter", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const slug = dirSlug(other)
try {
await withProject(
async () => {
await openSidebar(page)
await defocus(page)
const project = page.locator(projectSwitchSelector(slug)).first()
await expect(project).toBeVisible()
let hit = false
for (let i = 0; i < 20; i++) {
hit = await project.evaluate((el) => {
return el.matches(":focus") || !!el.parentElement?.matches(":focus")
})
if (hit) break
await page.keyboard.press("Tab")
}
expect(hit).toBe(true)
await page.keyboard.press("Enter")
await waitSession(page, { directory: other })
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
})
@@ -1,7 +1,7 @@
import type { Page } from "@playwright/test" import type { Page } from "@playwright/test"
import { runTerminal, waitTerminalReady } from "../actions" import { runTerminal, waitTerminalReady } from "../actions"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { terminalSelector } from "../selectors" import { dropdownMenuContentSelector, terminalSelector } from "../selectors"
import { terminalToggleKey, workspacePersistKey } from "../utils" import { terminalToggleKey, workspacePersistKey } from "../utils"
type State = { type State = {
@@ -130,3 +130,39 @@ test("closing the active terminal tab falls back to the previous tab", async ({
.toEqual({ count: 1, first: true }) .toEqual({ count: 1, first: true })
}) })
}) })
test("terminal tab can be renamed from the context menu", async ({ page, withProject }) => {
await withProject(async ({ directory, gotoSession }) => {
const key = workspacePersistKey(directory, "terminal")
const rename = `E2E term ${Date.now()}`
const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first()
await gotoSession()
await open(page)
await expect(tab).toContainText(/Terminal 1/)
await tab.click({ button: "right" })
const menu = page.locator(dropdownMenuContentSelector).first()
await expect(menu).toBeVisible()
await menu.getByRole("menuitem", { name: /^Rename$/i }).click()
await expect(menu).toHaveCount(0)
const input = page.locator('#terminal-panel input[type="text"]').first()
await expect(input).toBeVisible()
await input.fill(rename)
await input.press("Enter")
await expect(input).toHaveCount(0)
await expect(tab).toContainText(rename)
await expect
.poll(
async () => {
const state = await store(page, key)
return state?.all[0]?.title
},
{ timeout: 5_000 },
)
.toBe(rename)
})
})
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.2.27", "version": "1.3.0",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -51,9 +51,11 @@
"@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3", "@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "catalog:", "@solid-primitives/storage": "catalog:",
"@solid-primitives/timer": "1.4.4",
"@solid-primitives/websocket": "1.3.1", "@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:", "@solidjs/router": "catalog:",
"@tanstack/solid-query": "5.91.4",
"@thisbeyond/solid-dnd": "0.7.5", "@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:", "diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
+44 -31
View File
@@ -9,6 +9,7 @@ import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme" import { ThemeProvider } from "@opencode-ai/ui/theme"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { type Duration, Effect } from "effect" import { type Duration, Effect } from "effect"
import { import {
type Component, type Component,
@@ -46,21 +47,13 @@ import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error" import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health" import { useCheckServerHealth } from "./utils/server-health"
const Home = lazy(() => import("@/pages/home")) const HomeRoute = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session")) const Session = lazy(() => import("@/pages/session"))
const Loading = () => <div class="size-full" /> const Loading = () => <div class="size-full" />
const HomeRoute = () => (
<Suspense fallback={<Loading />}>
<Home />
</Suspense>
)
const SessionRoute = () => ( const SessionRoute = () => (
<SessionProviders> <SessionProviders>
<Suspense fallback={<Loading />}> <Session />
<Session />
</Suspense>
</SessionProviders> </SessionProviders>
) )
@@ -89,6 +82,11 @@ function MarkedProviderWithNativeParser(props: ParentProps) {
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider> return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
} }
function QueryProvider(props: ParentProps) {
const client = new QueryClient()
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
}
function AppShellProviders(props: ParentProps) { function AppShellProviders(props: ParentProps) {
return ( return (
<SettingsProvider> <SettingsProvider>
@@ -124,8 +122,10 @@ function SessionProviders(props: ParentProps) {
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
return ( return (
<AppShellProviders> <AppShellProviders>
{props.appChildren} <Suspense fallback={<Loading />}>
{props.children} {props.appChildren}
{props.children}
</Suspense>
</AppShellProviders> </AppShellProviders>
) )
} }
@@ -142,11 +142,13 @@ export function AppBaseProviders(props: ParentProps) {
<LanguageProvider> <LanguageProvider>
<UiI18nBridge> <UiI18nBridge>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}> <ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<DialogProvider> <QueryProvider>
<MarkedProviderWithNativeParser> <DialogProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider> <MarkedProviderWithNativeParser>
</MarkedProviderWithNativeParser> <FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</DialogProvider> </MarkedProviderWithNativeParser>
</DialogProvider>
</QueryProvider>
</ErrorBoundary> </ErrorBoundary>
</UiI18nBridge> </UiI18nBridge>
</LanguageProvider> </LanguageProvider>
@@ -265,6 +267,15 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key:
) )
} }
function ServerKey(props: ParentProps) {
const server = useServer()
return (
<Show when={server.key} keyed>
{props.children}
</Show>
)
}
export function AppInterface(props: { export function AppInterface(props: {
children?: JSX.Element children?: JSX.Element
defaultServer: ServerConnection.Key defaultServer: ServerConnection.Key
@@ -275,20 +286,22 @@ export function AppInterface(props: {
return ( return (
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}> <ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}> <ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<GlobalSDKProvider> <ServerKey>
<GlobalSyncProvider> <GlobalSDKProvider>
<Dynamic <GlobalSyncProvider>
component={props.router ?? Router} <Dynamic
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>} component={props.router ?? Router}
> root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
<Route path="/" component={HomeRoute} /> >
<Route path="/:dir" component={DirectoryLayout}> <Route path="/" component={HomeRoute} />
<Route path="/" component={SessionIndexRoute} /> <Route path="/:dir" component={DirectoryLayout}>
<Route path="/session/:id?" component={SessionRoute} /> <Route path="/" component={SessionIndexRoute} />
</Route> <Route path="/session/:id?" component={SessionRoute} />
</Dynamic> </Route>
</GlobalSyncProvider> </Dynamic>
</GlobalSDKProvider> </GlobalSyncProvider>
</GlobalSDKProvider>
</ServerKey>
</ConnectionGate> </ConnectionGate>
</ServerProvider> </ServerProvider>
) )
@@ -12,18 +12,15 @@ import { showToast } from "@opencode-ai/ui/toast"
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js" import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link" import { Link } from "@/components/link"
import { useLanguage } from "@/context/language"
import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { usePlatform } from "@/context/platform" import { useLanguage } from "@/context/language"
import { DialogSelectModel } from "./dialog-select-model"
import { DialogSelectProvider } from "./dialog-select-provider" import { DialogSelectProvider } from "./dialog-select-provider"
export function DialogConnectProvider(props: { provider: string }) { export function DialogConnectProvider(props: { provider: string }) {
const dialog = useDialog() const dialog = useDialog()
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
const platform = usePlatform()
const language = useLanguage() const language = useLanguage()
const alive = { value: true } const alive = { value: true }
@@ -49,13 +46,14 @@ export function DialogConnectProvider(props: { provider: string }) {
const [store, setStore] = createStore({ const [store, setStore] = createStore({
methodIndex: undefined as undefined | number, methodIndex: undefined as undefined | number,
authorization: undefined as undefined | ProviderAuthAuthorization, authorization: undefined as undefined | ProviderAuthAuthorization,
state: "pending" as undefined | "pending" | "complete" | "error", state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
error: undefined as string | undefined, error: undefined as string | undefined,
}) })
type Action = type Action =
| { type: "method.select"; index: number } | { type: "method.select"; index: number }
| { type: "method.reset" } | { type: "method.reset" }
| { type: "auth.prompt" }
| { type: "auth.pending" } | { type: "auth.pending" }
| { type: "auth.complete"; authorization: ProviderAuthAuthorization } | { type: "auth.complete"; authorization: ProviderAuthAuthorization }
| { type: "auth.error"; error: string } | { type: "auth.error"; error: string }
@@ -77,6 +75,11 @@ export function DialogConnectProvider(props: { provider: string }) {
draft.error = undefined draft.error = undefined
return return
} }
if (action.type === "auth.prompt") {
draft.state = "prompt"
draft.error = undefined
return
}
if (action.type === "auth.pending") { if (action.type === "auth.pending") {
draft.state = "pending" draft.state = "pending"
draft.error = undefined draft.error = undefined
@@ -120,7 +123,7 @@ export function DialogConnectProvider(props: { provider: string }) {
return fallback return fallback
} }
async function selectMethod(index: number) { async function selectMethod(index: number, inputs?: Record<string, string>) {
if (timer.current !== undefined) { if (timer.current !== undefined) {
clearTimeout(timer.current) clearTimeout(timer.current)
timer.current = undefined timer.current = undefined
@@ -130,6 +133,10 @@ export function DialogConnectProvider(props: { provider: string }) {
dispatch({ type: "method.select", index }) dispatch({ type: "method.select", index })
if (method.type === "oauth") { if (method.type === "oauth") {
if (method.prompts?.length && !inputs) {
dispatch({ type: "auth.prompt" })
return
}
dispatch({ type: "auth.pending" }) dispatch({ type: "auth.pending" })
const start = Date.now() const start = Date.now()
await globalSDK.client.provider.oauth await globalSDK.client.provider.oauth
@@ -137,6 +144,7 @@ export function DialogConnectProvider(props: { provider: string }) {
{ {
providerID: props.provider, providerID: props.provider,
method: index, method: index,
inputs,
}, },
{ throwOnError: true }, { throwOnError: true },
) )
@@ -163,6 +171,122 @@ export function DialogConnectProvider(props: { provider: string }) {
} }
} }
function OAuthPromptsView() {
const [formStore, setFormStore] = createStore({
value: {} as Record<string, string>,
index: 0,
})
const prompts = createMemo(() => method()?.prompts ?? [])
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
if (!prompt.when) return true
const actual = value[prompt.when.key]
if (actual === undefined) return false
return prompt.when.op === "eq" ? actual === prompt.when.value : actual !== prompt.when.value
}
const current = createMemo(() => {
const all = prompts()
const index = all.findIndex((prompt, index) => index >= formStore.index && matches(prompt, formStore.value))
if (index === -1) return
return {
index,
prompt: all[index],
}
})
const valid = createMemo(() => {
const item = current()
if (!item || item.prompt.type !== "text") return false
const value = formStore.value[item.prompt.key] ?? ""
return value.trim().length > 0
})
async function next(index: number, value: Record<string, string>) {
if (store.methodIndex === undefined) return
const next = prompts().findIndex((prompt, i) => i > index && matches(prompt, value))
if (next !== -1) {
setFormStore("index", next)
return
}
await selectMethod(store.methodIndex, value)
}
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
const item = current()
if (!item || item.prompt.type !== "text") return
if (!valid()) return
await next(item.index, formStore.value)
}
const item = () => current()
const text = createMemo(() => {
const prompt = item()?.prompt
if (!prompt || prompt.type !== "text") return
return prompt
})
const select = createMemo(() => {
const prompt = item()?.prompt
if (!prompt || prompt.type !== "select") return
return prompt
})
return (
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<Switch>
<Match when={item()?.prompt.type === "text"}>
<TextField
type="text"
label={text()?.message ?? ""}
placeholder={text()?.placeholder}
value={text() ? (formStore.value[text()!.key] ?? "") : ""}
onChange={(value) => {
const prompt = text()
if (!prompt) return
setFormStore("value", prompt.key, value)
}}
/>
<Button class="w-auto" type="submit" size="large" variant="primary" disabled={!valid()}>
{language.t("common.continue")}
</Button>
</Match>
<Match when={item()?.prompt.type === "select"}>
<div class="w-full flex flex-col gap-1.5">
<div class="text-14-regular text-text-base">{select()?.message}</div>
<div>
<List
items={select()?.options ?? []}
key={(x) => x.value}
current={select()?.options.find((x) => x.value === formStore.value[select()!.key])}
onSelect={(value) => {
if (!value) return
const prompt = select()
if (!prompt) return
const nextValue = {
...formStore.value,
[prompt.key]: value.value,
}
setFormStore("value", prompt.key, value.value)
void next(item()!.index, nextValue)
}}
>
{(option) => (
<div class="w-full flex items-center gap-x-2">
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
</div>
<span>{option.label}</span>
<span class="text-14-regular text-text-weak">{option.hint}</span>
</div>
)}
</List>
</div>
</div>
</Match>
</Switch>
</form>
)
}
let listRef: ListRef | undefined let listRef: ListRef | undefined
function handleKey(e: KeyboardEvent) { function handleKey(e: KeyboardEvent) {
if (e.key === "Enter" && e.target instanceof HTMLInputElement) { if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
@@ -301,7 +425,7 @@ export function DialogConnectProvider(props: { provider: string }) {
error={formStore.error} error={formStore.error}
/> />
<Button class="w-auto" type="submit" size="large" variant="primary"> <Button class="w-auto" type="submit" size="large" variant="primary">
{language.t("common.submit")} {language.t("common.continue")}
</Button> </Button>
</form> </form>
</div> </div>
@@ -314,12 +438,6 @@ export function DialogConnectProvider(props: { provider: string }) {
error: undefined as string | undefined, error: undefined as string | undefined,
}) })
onMount(() => {
if (store.authorization?.method === "code" && store.authorization?.url) {
platform.openLink(store.authorization.url)
}
})
async function handleSubmit(e: SubmitEvent) { async function handleSubmit(e: SubmitEvent) {
e.preventDefault() e.preventDefault()
@@ -368,7 +486,7 @@ export function DialogConnectProvider(props: { provider: string }) {
error={formStore.error} error={formStore.error}
/> />
<Button class="w-auto" type="submit" size="large" variant="primary"> <Button class="w-auto" type="submit" size="large" variant="primary">
{language.t("common.submit")} {language.t("common.continue")}
</Button> </Button>
</form> </form>
</div> </div>
@@ -386,10 +504,6 @@ export function DialogConnectProvider(props: { provider: string }) {
onMount(() => { onMount(() => {
void (async () => { void (async () => {
if (store.authorization?.url) {
platform.openLink(store.authorization.url)
}
const result = await globalSDK.client.provider.oauth const result = await globalSDK.client.provider.oauth
.callback({ .callback({
providerID: props.provider, providerID: props.provider,
@@ -470,6 +584,9 @@ export function DialogConnectProvider(props: { provider: string }) {
</div> </div>
</div> </div>
</Match> </Match>
<Match when={store.state === "prompt"}>
<OAuthPromptsView />
</Match>
<Match when={store.state === "error"}> <Match when={store.state === "error"}>
<div class="text-14-regular text-text-base"> <div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2"> <div class="flex items-center gap-x-2">
@@ -34,7 +34,6 @@ export type FormState = {
apiKey: string apiKey: string
models: ModelRow[] models: ModelRow[]
headers: HeaderRow[] headers: HeaderRow[]
saving: boolean
err: { err: {
providerID?: string providerID?: string
name?: string name?: string
@@ -16,7 +16,6 @@ describe("validateCustomProvider", () => {
{ row: "h0", key: " X-Test ", value: " enabled ", err: {} }, { row: "h0", key: " X-Test ", value: " enabled ", err: {} },
{ row: "h1", key: "", value: "", err: {} }, { row: "h1", key: "", value: "", err: {} },
], ],
saving: false,
err: {}, err: {},
}, },
t, t,
@@ -60,7 +59,6 @@ describe("validateCustomProvider", () => {
{ row: "h0", key: "Authorization", value: "one", err: {} }, { row: "h0", key: "Authorization", value: "one", err: {} },
{ row: "h1", key: "authorization", value: "two", err: {} }, { row: "h1", key: "authorization", value: "two", err: {} },
], ],
saving: false,
err: {}, err: {},
}, },
t, t,
@@ -3,6 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { useMutation } from "@tanstack/solid-query"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { batch, For } from "solid-js" import { batch, For } from "solid-js"
@@ -31,7 +32,6 @@ export function DialogCustomProvider(props: Props) {
apiKey: "", apiKey: "",
models: [modelRow()], models: [modelRow()],
headers: [headerRow()], headers: [headerRow()],
saving: false,
err: {}, err: {},
}) })
@@ -116,48 +116,49 @@ export function DialogCustomProvider(props: Props) {
return output.result return output.result
} }
const save = async (e: SubmitEvent) => { const saveMutation = useMutation(() => ({
e.preventDefault() mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
if (form.saving) return const disabledProviders = globalSync.data.config.disabled_providers ?? []
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
const result = validate() if (result.key) {
if (!result) return await globalSDK.client.auth.set({
setForm("saving", true)
const disabledProviders = globalSync.data.config.disabled_providers ?? []
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
const auth = result.key
? globalSDK.client.auth.set({
providerID: result.providerID, providerID: result.providerID,
auth: { auth: {
type: "api", type: "api",
key: result.key, key: result.key,
}, },
}) })
: Promise.resolve() }
auth await globalSync.updateConfig({
.then(() => provider: { [result.providerID]: result.config },
globalSync.updateConfig({ provider: { [result.providerID]: result.config }, disabled_providers: nextDisabled }), disabled_providers: nextDisabled,
)
.then(() => {
dialog.close()
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
})
}) })
.catch((err: unknown) => { return result
const message = err instanceof Error ? err.message : String(err) },
showToast({ title: language.t("common.requestFailed"), description: message }) onSuccess: (result) => {
}) dialog.close()
.finally(() => { showToast({
setForm("saving", false) variant: "success",
icon: "circle-check",
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
}) })
},
onError: (err) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
},
}))
const save = (e: SubmitEvent) => {
e.preventDefault()
if (saveMutation.isPending) return
const result = validate()
if (!result) return
saveMutation.mutate(result)
} }
return ( return (
@@ -312,8 +313,14 @@ export function DialogCustomProvider(props: Props) {
</Button> </Button>
</div> </div>
<Button class="w-auto self-start" type="submit" size="large" variant="primary" disabled={form.saving}> <Button
{form.saving ? language.t("common.saving") : language.t("common.submit")} class="w-auto self-start"
type="submit"
size="large"
variant="primary"
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
</Button> </Button>
</form> </form>
</div> </div>
@@ -2,6 +2,7 @@ import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { createMemo, For, Show } from "solid-js" import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
@@ -28,7 +29,6 @@ export function DialogEditProject(props: { project: LocalProject }) {
color: props.project.icon?.color || "pink", color: props.project.icon?.color || "pink",
iconUrl: props.project.icon?.override || "", iconUrl: props.project.icon?.override || "",
startup: props.project.commands?.start ?? "", startup: props.project.commands?.start ?? "",
saving: false,
dragOver: false, dragOver: false,
iconHover: false, iconHover: false,
}) })
@@ -71,38 +71,37 @@ export function DialogEditProject(props: { project: LocalProject }) {
setStore("iconUrl", "") setStore("iconUrl", "")
} }
async function handleSubmit(e: SubmitEvent) { const saveMutation = useMutation(() => ({
e.preventDefault() mutationFn: async () => {
const name = store.name.trim() === folderName() ? "" : store.name.trim()
const start = store.startup.trim()
await Promise.resolve() if (props.project.id && props.project.id !== "global") {
.then(async () => { await globalSDK.client.project.update({
setStore("saving", true) projectID: props.project.id,
const name = store.name.trim() === folderName() ? "" : store.name.trim() directory: props.project.worktree,
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
await globalSDK.client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color, override: store.iconUrl },
commands: { start },
})
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
dialog.close()
return
}
globalSync.project.meta(props.project.worktree, {
name, name,
icon: { color: store.color, override: store.iconUrl || undefined }, icon: { color: store.color, override: store.iconUrl },
commands: { start: start || undefined }, commands: { start },
}) })
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
dialog.close() dialog.close()
return
}
globalSync.project.meta(props.project.worktree, {
name,
icon: { color: store.color, override: store.iconUrl || undefined },
commands: { start: start || undefined },
}) })
.finally(() => { dialog.close()
setStore("saving", false) },
}) }))
function handleSubmit(e: SubmitEvent) {
e.preventDefault()
if (saveMutation.isPending) return
saveMutation.mutate()
} }
return ( return (
@@ -246,8 +245,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}> <Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")} {language.t("common.cancel")}
</Button> </Button>
<Button type="submit" variant="primary" size="large" disabled={store.saving}> <Button type="submit" variant="primary" size="large" disabled={saveMutation.isPending}>
{store.saving ? language.t("common.saving") : language.t("common.save")} {saveMutation.isPending ? language.t("common.saving") : language.t("common.save")}
</Button> </Button>
</div> </div>
</form> </form>
@@ -1,4 +1,5 @@
import { Component, createMemo, createSignal, Show } from "solid-js" import { useMutation } from "@tanstack/solid-query"
import { Component, createMemo, Show } from "solid-js"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
@@ -17,7 +18,6 @@ export const DialogSelectMcp: Component = () => {
const sync = useSync() const sync = useSync()
const sdk = useSDK() const sdk = useSDK()
const language = useLanguage() const language = useLanguage()
const [loading, setLoading] = createSignal<string | null>(null)
const items = createMemo(() => const items = createMemo(() =>
Object.entries(sync.data.mcp ?? {}) Object.entries(sync.data.mcp ?? {})
@@ -25,10 +25,8 @@ export const DialogSelectMcp: Component = () => {
.sort((a, b) => a.name.localeCompare(b.name)), .sort((a, b) => a.name.localeCompare(b.name)),
) )
const toggle = async (name: string) => { const toggle = useMutation(() => ({
if (loading()) return mutationFn: async (name: string) => {
setLoading(name)
try {
const status = sync.data.mcp[name] const status = sync.data.mcp[name]
if (status?.status === "connected") { if (status?.status === "connected") {
await sdk.client.mcp.disconnect({ name }) await sdk.client.mcp.disconnect({ name })
@@ -38,10 +36,8 @@ export const DialogSelectMcp: Component = () => {
const result = await sdk.client.mcp.status() const result = await sdk.client.mcp.status()
if (result.data) sync.set("mcp", result.data) if (result.data) sync.set("mcp", result.data)
} finally { },
setLoading(null) }))
}
}
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length) const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
const totalCount = createMemo(() => items().length) const totalCount = createMemo(() => items().length)
@@ -59,7 +55,8 @@ export const DialogSelectMcp: Component = () => {
filterKeys={["name", "status"]} filterKeys={["name", "status"]}
sortBy={(a, b) => a.name.localeCompare(b.name)} sortBy={(a, b) => a.name.localeCompare(b.name)}
onSelect={(x) => { onSelect={(x) => {
if (x) toggle(x.name) if (!x || toggle.isPending) return
toggle.mutate(x.name)
}} }}
> >
{(i) => { {(i) => {
@@ -83,7 +80,7 @@ export const DialogSelectMcp: Component = () => {
<Show when={statusLabel()}> <Show when={statusLabel()}>
<span class="text-11-regular text-text-weaker">{statusLabel()}</span> <span class="text-11-regular text-text-weaker">{statusLabel()}</span>
</Show> </Show>
<Show when={loading() === i.name}> <Show when={toggle.isPending && toggle.variables === i.name}>
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span> <span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
</Show> </Show>
</div> </div>
@@ -92,7 +89,14 @@ export const DialogSelectMcp: Component = () => {
</Show> </Show>
</div> </div>
<div onClick={(e) => e.stopPropagation()}> <div onClick={(e) => e.stopPropagation()}>
<Switch checked={enabled()} disabled={loading() === i.name} onChange={() => toggle(i.name)} /> <Switch
checked={enabled()}
disabled={toggle.isPending && toggle.variables === i.name}
onChange={() => {
if (toggle.isPending) return
toggle.mutate(i.name)
}}
/>
</div> </div>
</div> </div>
) )
@@ -6,6 +6,7 @@ import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js" import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
@@ -186,7 +187,6 @@ export function DialogSelectServer() {
name: "", name: "",
username: DEFAULT_USERNAME, username: DEFAULT_USERNAME,
password: "", password: "",
adding: false,
error: "", error: "",
showForm: false, showForm: false,
status: undefined as boolean | undefined, status: undefined as boolean | undefined,
@@ -198,7 +198,6 @@ export function DialogSelectServer() {
username: "", username: "",
password: "", password: "",
error: "", error: "",
busy: false,
status: undefined as boolean | undefined, status: undefined as boolean | undefined,
}, },
}) })
@@ -209,7 +208,6 @@ export function DialogSelectServer() {
name: "", name: "",
username: DEFAULT_USERNAME, username: DEFAULT_USERNAME,
password: "", password: "",
adding: false,
error: "", error: "",
showForm: false, showForm: false,
status: undefined, status: undefined,
@@ -224,10 +222,78 @@ export function DialogSelectServer() {
password: "", password: "",
error: "", error: "",
status: undefined, status: undefined,
busy: false,
}) })
} }
const addMutation = useMutation(() => ({
mutationFn: async (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetAdd()
return
}
const conn: ServerConnection.Http = {
type: "http",
http: { url: normalized },
}
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
if (store.addServer.password) conn.http.password = store.addServer.password
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
const result = await checkServerHealth(conn.http)
if (!result.healthy) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
resetAdd()
await select(conn, true)
},
}))
const editMutation = useMutation(() => ({
mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
if (input.original.type !== "http") return
const normalized = normalizeServerUrl(input.value)
if (!normalized) {
resetEdit()
return
}
const name = store.editServer.name.trim() || undefined
const username = store.editServer.username || undefined
const password = store.editServer.password || undefined
const existingName = input.original.displayName
if (
normalized === input.original.http.url &&
name === existingName &&
username === input.original.http.username &&
password === input.original.http.password
) {
resetEdit()
return
}
const conn: ServerConnection.Http = {
type: "http",
displayName: name,
http: { url: normalized, username, password },
}
const result = await checkServerHealth(conn.http)
if (!result.healthy) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
if (normalized === input.original.http.url) {
server.add(conn)
} else {
replaceServer(input.original, conn)
}
resetEdit()
},
}))
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => { const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
const active = server.key const active = server.key
const newConn = server.add(next) const newConn = server.add(next)
@@ -291,12 +357,12 @@ export function DialogSelectServer() {
navigate("/") navigate("/")
return return
} }
server.setActive(ServerConnection.key(conn))
navigate("/") navigate("/")
queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
} }
const handleAddChange = (value: string) => { const handleAddChange = (value: string) => {
if (store.addServer.adding) return if (addMutation.isPending) return
setStore("addServer", { url: value, error: "" }) setStore("addServer", { url: value, error: "" })
void previewStatus(value, store.addServer.username, store.addServer.password, (next) => void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
setStore("addServer", { status: next }), setStore("addServer", { status: next }),
@@ -304,12 +370,12 @@ export function DialogSelectServer() {
} }
const handleAddNameChange = (value: string) => { const handleAddNameChange = (value: string) => {
if (store.addServer.adding) return if (addMutation.isPending) return
setStore("addServer", { name: value, error: "" }) setStore("addServer", { name: value, error: "" })
} }
const handleAddUsernameChange = (value: string) => { const handleAddUsernameChange = (value: string) => {
if (store.addServer.adding) return if (addMutation.isPending) return
setStore("addServer", { username: value, error: "" }) setStore("addServer", { username: value, error: "" })
void previewStatus(store.addServer.url, value, store.addServer.password, (next) => void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
setStore("addServer", { status: next }), setStore("addServer", { status: next }),
@@ -317,7 +383,7 @@ export function DialogSelectServer() {
} }
const handleAddPasswordChange = (value: string) => { const handleAddPasswordChange = (value: string) => {
if (store.addServer.adding) return if (addMutation.isPending) return
setStore("addServer", { password: value, error: "" }) setStore("addServer", { password: value, error: "" })
void previewStatus(store.addServer.url, store.addServer.username, value, (next) => void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
setStore("addServer", { status: next }), setStore("addServer", { status: next }),
@@ -325,7 +391,7 @@ export function DialogSelectServer() {
} }
const handleEditChange = (value: string) => { const handleEditChange = (value: string) => {
if (store.editServer.busy) return if (editMutation.isPending) return
setStore("editServer", { value, error: "" }) setStore("editServer", { value, error: "" })
void previewStatus(value, store.editServer.username, store.editServer.password, (next) => void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
setStore("editServer", { status: next }), setStore("editServer", { status: next }),
@@ -333,12 +399,12 @@ export function DialogSelectServer() {
} }
const handleEditNameChange = (value: string) => { const handleEditNameChange = (value: string) => {
if (store.editServer.busy) return if (editMutation.isPending) return
setStore("editServer", { name: value, error: "" }) setStore("editServer", { name: value, error: "" })
} }
const handleEditUsernameChange = (value: string) => { const handleEditUsernameChange = (value: string) => {
if (store.editServer.busy) return if (editMutation.isPending) return
setStore("editServer", { username: value, error: "" }) setStore("editServer", { username: value, error: "" })
void previewStatus(store.editServer.value, value, store.editServer.password, (next) => void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
setStore("editServer", { status: next }), setStore("editServer", { status: next }),
@@ -346,85 +412,13 @@ export function DialogSelectServer() {
} }
const handleEditPasswordChange = (value: string) => { const handleEditPasswordChange = (value: string) => {
if (store.editServer.busy) return if (editMutation.isPending) return
setStore("editServer", { password: value, error: "" }) setStore("editServer", { password: value, error: "" })
void previewStatus(store.editServer.value, store.editServer.username, value, (next) => void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
setStore("editServer", { status: next }), setStore("editServer", { status: next }),
) )
} }
async function handleAdd(value: string) {
if (store.addServer.adding) return
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetAdd()
return
}
setStore("addServer", { adding: true, error: "" })
const conn: ServerConnection.Http = {
type: "http",
http: { url: normalized },
}
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
if (store.addServer.password) conn.http.password = store.addServer.password
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
const result = await checkServerHealth(conn.http)
setStore("addServer", { adding: false })
if (!result.healthy) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
resetAdd()
await select(conn, true)
}
async function handleEdit(original: ServerConnection.Any, value: string) {
if (store.editServer.busy || original.type !== "http") return
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetEdit()
return
}
const name = store.editServer.name.trim() || undefined
const username = store.editServer.username || undefined
const password = store.editServer.password || undefined
const existingName = original.displayName
if (
normalized === original.http.url &&
name === existingName &&
username === original.http.username &&
password === original.http.password
) {
resetEdit()
return
}
setStore("editServer", { busy: true, error: "" })
const conn: ServerConnection.Http = {
type: "http",
displayName: name,
http: { url: normalized, username, password },
}
const result = await checkServerHealth(conn.http)
setStore("editServer", { busy: false })
if (!result.healthy) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
if (normalized === original.http.url) {
server.add(conn)
} else {
replaceServer(original, conn)
}
resetEdit()
}
const mode = createMemo<"list" | "add" | "edit">(() => { const mode = createMemo<"list" | "add" | "edit">(() => {
if (store.editServer.id) return "edit" if (store.editServer.id) return "edit"
if (store.addServer.showForm) return "add" if (store.addServer.showForm) return "add"
@@ -464,23 +458,26 @@ export function DialogSelectServer() {
password: conn.http.password ?? "", password: conn.http.password ?? "",
error: "", error: "",
status: store.status[ServerConnection.key(conn)]?.healthy, status: store.status[ServerConnection.key(conn)]?.healthy,
busy: false,
}) })
} }
const submitForm = () => { const submitForm = () => {
if (mode() === "add") { if (mode() === "add") {
void handleAdd(store.addServer.url) if (addMutation.isPending) return
setStore("addServer", { error: "" })
addMutation.mutate(store.addServer.url)
return return
} }
const original = editing() const original = editing()
if (!original) return if (!original) return
void handleEdit(original, store.editServer.value) if (editMutation.isPending) return
setStore("editServer", { error: "" })
editMutation.mutate({ original, value: store.editServer.value })
} }
const isFormMode = createMemo(() => mode() !== "list") const isFormMode = createMemo(() => mode() !== "list")
const isAddMode = createMemo(() => mode() === "add") const isAddMode = createMemo(() => mode() === "add")
const formBusy = createMemo(() => (isAddMode() ? store.addServer.adding : store.editServer.busy)) const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
const formTitle = createMemo(() => { const formTitle = createMemo(() => {
if (!isFormMode()) return language.t("dialog.server.title") if (!isFormMode()) return language.t("dialog.server.title")
+18 -3
View File
@@ -1043,7 +1043,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return true return true
} }
const { addAttachment, removeAttachment, handlePaste } = createPromptAttachments({ const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
editor: () => editorRef, editor: () => editorRef,
isDialogActive: () => !!dialog.active, isDialogActive: () => !!dialog.active,
setDraggingType: (type) => setStore("draggingType", type), setDraggingType: (type) => setStore("draggingType", type),
@@ -1241,6 +1241,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
// Note: Shift+Enter is handled earlier, before IME check // Note: Shift+Enter is handled earlier, before IME check
if (event.key === "Enter" && !event.shiftKey) { if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
if (event.repeat) return
if (
working() &&
prompt
.current()
.map((part) => ("content" in part ? part.content : ""))
.join("")
.trim().length === 0 &&
imageAttachments().length === 0 &&
commentCount() === 0
) {
return
}
handleSubmit(event) handleSubmit(event)
} }
} }
@@ -1369,11 +1383,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
multiple
accept={ACCEPTED_FILE_TYPES.join(",")} accept={ACCEPTED_FILE_TYPES.join(",")}
class="hidden" class="hidden"
onChange={(e) => { onChange={(e) => {
const file = e.currentTarget.files?.[0] const list = e.currentTarget.files
if (file) void addAttachment(file) if (list) void addAttachments(Array.from(list))
e.currentTarget.value = "" e.currentTarget.value = ""
}} }}
/> />
@@ -71,6 +71,18 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const addAttachment = (file: File) => add(file) const addAttachment = (file: File) => add(file)
const addAttachments = async (files: File[], toast = true) => {
let found = false
for (const file of files) {
const ok = await add(file, false)
if (ok) found = true
}
if (!found && files.length > 0 && toast) warn()
return found
}
const removeAttachment = (id: string) => { const removeAttachment = (id: string) => {
const current = prompt.current() const current = prompt.current()
const next = current.filter((part) => part.type !== "image" || part.id !== id) const next = current.filter((part) => part.type !== "image" || part.id !== id)
@@ -84,18 +96,14 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
const items = Array.from(clipboardData.items) const files = Array.from(clipboardData.items).flatMap((item) => {
const fileItems = items.filter((item) => item.kind === "file") if (item.kind !== "file") return []
const file = item.getAsFile()
return file ? [file] : []
})
if (fileItems.length > 0) { if (files.length > 0) {
let found = false await addAttachments(files)
for (const item of fileItems) {
const file = item.getAsFile()
if (!file) continue
const ok = await add(file, false)
if (ok) found = true
}
if (!found) warn()
return return
} }
@@ -169,12 +177,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const dropped = event.dataTransfer?.files const dropped = event.dataTransfer?.files
if (!dropped) return if (!dropped) return
let found = false await addAttachments(Array.from(dropped))
for (const file of Array.from(dropped)) {
const ok = await add(file, false)
if (ok) found = true
}
if (!found && dropped.length > 0) warn()
} }
onMount(() => { onMount(() => {
@@ -191,6 +194,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
return { return {
addAttachment, addAttachment,
addAttachments,
removeAttachment, removeAttachment,
handlePaste, handlePaste,
} }
@@ -49,6 +49,32 @@ describe("buildRequestParts", () => {
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true) expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
}) })
test("keeps multiple uploaded attachments in order", () => {
const result = buildRequestParts({
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
context: [],
images: [
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
{
type: "image",
id: "img_2",
filename: "b.pdf",
mime: "application/pdf",
dataUrl: "data:application/pdf;base64,BBB",
},
],
text: "check these",
messageID: "msg_multi",
sessionID: "ses_multi",
sessionDirectory: "/repo",
})
const files = result.requestParts.filter((part) => part.type === "file" && part.url.startsWith("data:"))
expect(files).toHaveLength(2)
expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
})
test("deduplicates context files when prompt already includes same path", () => { test("deduplicates context files when prompt already includes same path", () => {
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }] const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
@@ -1,4 +1,6 @@
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-picker"
export { ACCEPTED_FILE_TYPES }
const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES) const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
const IMAGE_EXTS = new Map([ const IMAGE_EXTS = new Map([
@@ -18,61 +20,6 @@ const TEXT_MIMES = new Set([
"application/yaml", "application/yaml",
]) ])
export const ACCEPTED_FILE_TYPES = [
...ACCEPTED_IMAGE_TYPES,
"application/pdf",
"text/*",
"application/json",
"application/ld+json",
"application/toml",
"application/x-toml",
"application/x-yaml",
"application/xml",
"application/yaml",
".c",
".cc",
".cjs",
".conf",
".cpp",
".css",
".csv",
".cts",
".env",
".go",
".gql",
".graphql",
".h",
".hh",
".hpp",
".htm",
".html",
".ini",
".java",
".js",
".json",
".jsx",
".log",
".md",
".mdx",
".mjs",
".mts",
".py",
".rb",
".rs",
".sass",
".scss",
".sh",
".sql",
".toml",
".ts",
".tsx",
".txt",
".xml",
".yaml",
".yml",
".zsh",
]
const SAMPLE = 4096 const SAMPLE = 4096
function kind(type: string) { function kind(type: string) {
@@ -126,7 +126,7 @@ describe("prompt-input history", () => {
test("canNavigateHistoryAtCursor only allows prompt boundaries", () => { test("canNavigateHistoryAtCursor only allows prompt boundaries", () => {
const value = "a\nb\nc" const value = "a\nb\nc"
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true) expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(false)
expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false) expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false)
expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false) expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false)
@@ -135,11 +135,14 @@ describe("prompt-input history", () => {
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false) expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true) expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(true) expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(false)
expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true) expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false) expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false)
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false) expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false)
expect(canNavigateHistoryAtCursor("up", "", 0)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "", 0)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true) expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true) expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true) expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true)
@@ -27,7 +27,7 @@ export function canNavigateHistoryAtCursor(direction: "up" | "down", text: strin
const atStart = position === 0 const atStart = position === 0
const atEnd = position === text.length const atEnd = position === text.length
if (inHistory) return atStart || atEnd if (inHistory) return atStart || atEnd
if (direction === "up") return position === 0 if (direction === "up") return position === 0 && text.length === 0
return position === text.length return position === text.length
} }
@@ -267,14 +267,14 @@ export function SessionContextTab() {
return ( return (
<ScrollView <ScrollView
class="@container h-full pb-10" class="@container h-full"
viewportRef={(el) => { viewportRef={(el) => {
scroll = el scroll = el
restoreScroll() restoreScroll()
}} }}
onScroll={handleScroll} onScroll={handleScroll}
> >
<div class="px-6 pt-4 flex flex-col gap-10"> <div class="px-6 pt-4 pb-10 flex flex-col gap-10">
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4"> <div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
<For each={stats}> <For each={stats}>
{(stat) => <Stat label={language.t(stat.label as Parameters<typeof language.t>[0])} value={stat.value()} />} {(stat) => <Stat label={language.t(stat.label as Parameters<typeof language.t>[0])} value={stat.value()} />}
@@ -24,6 +24,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
}) })
let input: HTMLInputElement | undefined let input: HTMLInputElement | undefined
let blurFrame: number | undefined let blurFrame: number | undefined
let editRequested = false
const isDefaultTitle = () => { const isDefaultTitle = () => {
const number = props.terminal.titleNumber const number = props.terminal.titleNumber
@@ -168,8 +169,14 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
left: `${store.menuPosition.x}px`, left: `${store.menuPosition.x}px`,
top: `${store.menuPosition.y}px`, top: `${store.menuPosition.y}px`,
}} }}
onCloseAutoFocus={(e) => {
if (!editRequested) return
e.preventDefault()
editRequested = false
requestAnimationFrame(() => edit())
}}
> >
<DropdownMenu.Item onSelect={edit}> <DropdownMenu.Item onSelect={() => (editRequested = true)}>
<Icon name="edit" class="w-4 h-4 mr-2" /> <Icon name="edit" class="w-4 h-4 mr-2" />
{language.t("common.rename")} {language.t("common.rename")}
</DropdownMenu.Item> </DropdownMenu.Item>
+28 -32
View File
@@ -4,6 +4,7 @@ import { Icon } from "@opencode-ai/ui/icon"
import { Popover } from "@opencode-ai/ui/popover" import { Popover } from "@opencode-ai/ui/popover"
import { Switch } from "@opencode-ai/ui/switch" import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { useMutation } from "@tanstack/solid-query"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js" import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
@@ -130,41 +131,30 @@ const useDefaultServerKey = (
} }
} }
const useMcpToggle = (input: { const useMcpToggleMutation = () => {
sync: ReturnType<typeof useSync> const sync = useSync()
sdk: ReturnType<typeof useSDK> const sdk = useSDK()
language: ReturnType<typeof useLanguage> const language = useLanguage()
}) => {
const [loading, setLoading] = createSignal<string | null>(null)
const toggle = async (name: string) => { return useMutation(() => ({
if (loading()) return mutationFn: async (name: string) => {
setLoading(name) const status = sync.data.mcp[name]
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
try { const result = await sdk.client.mcp.status()
const status = input.sync.data.mcp[name] if (result.data) sync.set("mcp", result.data)
await (status?.status === "connected" },
? input.sdk.client.mcp.disconnect({ name }) onError: (err) => {
: input.sdk.client.mcp.connect({ name }))
const result = await input.sdk.client.mcp.status()
if (result.data) input.sync.set("mcp", result.data)
} catch (err) {
showToast({ showToast({
variant: "error", variant: "error",
title: input.language.t("common.requestFailed"), title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err), description: err instanceof Error ? err.message : String(err),
}) })
} finally { },
setLoading(null) }))
}
}
return { loading, toggle }
} }
export function StatusPopover() { export function StatusPopover() {
const sync = useSync() const sync = useSync()
const sdk = useSDK()
const server = useServer() const server = useServer()
const platform = usePlatform() const platform = usePlatform()
const dialog = useDialog() const dialog = useDialog()
@@ -181,7 +171,7 @@ export function StatusPopover() {
}) })
const health = useServerHealth(servers) const health = useServerHealth(servers)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health)) const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
const mcp = useMcpToggle({ sync, sdk, language }) const toggleMcp = useMcpToggleMutation()
const defaultServer = useDefaultServerKey(platform.getDefaultServer) const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
@@ -277,8 +267,8 @@ export function StatusPopover() {
aria-disabled={isBlocked()} aria-disabled={isBlocked()}
onClick={() => { onClick={() => {
if (isBlocked()) return if (isBlocked()) return
server.setActive(key)
navigate("/") navigate("/")
queueMicrotask(() => server.setActive(key))
}} }}
> >
<ServerHealthIndicator health={health[key]} /> <ServerHealthIndicator health={health[key]} />
@@ -337,8 +327,11 @@ export function StatusPopover() {
<button <button
type="button" type="button"
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left" class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => mcp.toggle(name)} onClick={() => {
disabled={mcp.loading() === name} if (toggleMcp.isPending) return
toggleMcp.mutate(name)
}}
disabled={toggleMcp.isPending && toggleMcp.variables === name}
> >
<div <div
classList={{ classList={{
@@ -354,8 +347,11 @@ export function StatusPopover() {
<div onClick={(event) => event.stopPropagation()}> <div onClick={(event) => event.stopPropagation()}>
<Switch <Switch
checked={enabled()} checked={enabled()}
disabled={mcp.loading() === name} disabled={toggleMcp.isPending && toggleMcp.variables === name}
onChange={() => mcp.toggle(name)} onChange={() => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name)
}}
/> />
</div> </div>
</button> </button>
+15 -9
View File
@@ -165,6 +165,12 @@ export const Terminal = (props: TerminalProps) => {
const theme = useTheme() const theme = useTheme()
const language = useLanguage() const language = useLanguage()
const server = useServer() const server = useServer()
const directory = sdk.directory
const client = sdk.client
const url = sdk.url
const auth = server.current?.http
const username = auth?.username ?? "opencode"
const password = auth?.password ?? ""
let container!: HTMLDivElement let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"]) const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
const id = local.pty.id const id = local.pty.id
@@ -215,7 +221,7 @@ export const Terminal = (props: TerminalProps) => {
} }
const pushSize = (cols: number, rows: number) => { const pushSize = (cols: number, rows: number) => {
return sdk.client.pty return client.pty
.update({ .update({
ptyID: id, ptyID: id,
size: { cols, rows }, size: { cols, rows },
@@ -474,7 +480,7 @@ export const Terminal = (props: TerminalProps) => {
} }
const gone = () => const gone = () =>
sdk.client.pty client.pty
.get({ ptyID: id }) .get({ ptyID: id })
.then(() => false) .then(() => false)
.catch((err) => { .catch((err) => {
@@ -506,14 +512,14 @@ export const Terminal = (props: TerminalProps) => {
if (disposed) return if (disposed) return
drop?.() drop?.()
const url = new URL(sdk.url + `/pty/${id}/connect`) const next = new URL(url + `/pty/${id}/connect`)
url.searchParams.set("directory", sdk.directory) next.searchParams.set("directory", directory)
url.searchParams.set("cursor", String(seek)) next.searchParams.set("cursor", String(seek))
url.protocol = url.protocol === "https:" ? "wss:" : "ws:" next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
url.username = server.current?.http.username ?? "opencode" next.username = username
url.password = server.current?.http.password ?? "" next.password = password
const socket = new WebSocket(url) const socket = new WebSocket(next)
socket.binaryType = "arraybuffer" socket.binaryType = "arraybuffer"
ws = socket ws = socket
+89
View File
@@ -0,0 +1,89 @@
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
export const ACCEPTED_FILE_TYPES = [
...ACCEPTED_IMAGE_TYPES,
"application/pdf",
"text/*",
"application/json",
"application/ld+json",
"application/toml",
"application/x-toml",
"application/x-yaml",
"application/xml",
"application/yaml",
".c",
".cc",
".cjs",
".conf",
".cpp",
".css",
".csv",
".cts",
".env",
".go",
".gql",
".graphql",
".h",
".hh",
".hpp",
".htm",
".html",
".ini",
".java",
".js",
".json",
".jsx",
".log",
".md",
".mdx",
".mjs",
".mts",
".py",
".rb",
".rs",
".sass",
".scss",
".sh",
".sql",
".toml",
".ts",
".tsx",
".txt",
".xml",
".yaml",
".yml",
".zsh",
]
const MIME_EXT = new Map([
["image/png", "png"],
["image/jpeg", "jpg"],
["image/gif", "gif"],
["image/webp", "webp"],
["application/pdf", "pdf"],
["application/json", "json"],
["application/ld+json", "jsonld"],
["application/toml", "toml"],
["application/x-toml", "toml"],
["application/x-yaml", "yaml"],
["application/xml", "xml"],
["application/yaml", "yaml"],
])
const TEXT_EXT = ["txt", "text", "md", "markdown", "log", "csv"]
export const ACCEPTED_FILE_EXTENSIONS = Array.from(
new Set(
ACCEPTED_FILE_TYPES.flatMap((item) => {
if (item.startsWith(".")) return [item.slice(1)]
if (item === "text/*") return TEXT_EXT
const out = MIME_EXT.get(item)
return out ? [out] : []
}),
),
).sort()
export function filePickerFilters(ext?: string[]) {
if (!ext || ext.length === 0) return undefined
return [{ name: "Files", extensions: ext }]
}
@@ -40,4 +40,11 @@ describe("command keybind helpers", () => {
expect(display.includes("Alt") || display.includes("⌥")).toBe(true) expect(display.includes("Alt") || display.includes("⌥")).toBe(true)
expect(formatKeybind("none")).toBe("") expect(formatKeybind("none")).toBe("")
}) })
test("formatKeybind prefers the first combo", () => {
const display = formatKeybind("mod+k,mod+p")
expect(display.includes("K") || display.includes("k")).toBe(true)
expect(display.includes("P") || display.includes("p")).toBe(false)
})
}) })
+1
View File
@@ -378,6 +378,7 @@ function createGlobalSync() {
return globalStore.error return globalStore.error
}, },
child: children.child, child: children.child,
peek: children.peek,
bootstrap, bootstrap,
updateConfig, updateConfig,
project: projectApi, project: projectApi,
@@ -226,6 +226,15 @@ export function createChildStoreManager(input: {
return childStore return childStore
} }
function peek(directory: string, options: ChildOptions = {}) {
const childStore = ensureChild(directory)
const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap && childStore[0].status === "loading") {
input.onBootstrap(directory)
}
return childStore
}
function projectMeta(directory: string, patch: ProjectMeta) { function projectMeta(directory: string, patch: ProjectMeta) {
const [store, setStore] = ensureChild(directory) const [store, setStore] = ensureChild(directory)
const cached = metaCache.get(directory) const cached = metaCache.get(directory)
@@ -256,6 +265,7 @@ export function createChildStoreManager(input: {
children, children,
ensureChild, ensureChild,
child, child,
peek,
projectMeta, projectMeta,
projectIcon, projectIcon,
mark, mark,
+1 -1
View File
@@ -5,7 +5,7 @@ import { ServerConnection } from "./server"
type PickerPaths = string | string[] | null type PickerPaths = string | string[] | null
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean } type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
type OpenFilePickerOptions = { title?: string; multiple?: boolean } type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
type SaveFilePickerOptions = { title?: string; defaultPath?: string } type SaveFilePickerOptions = { title?: string; defaultPath?: string }
type UpdateInfo = { updateAvailable: boolean; version?: string } type UpdateInfo = { updateAvailable: boolean; version?: string }
+73 -49
View File
@@ -185,6 +185,60 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
}) })
onCleanup(unsub) onCleanup(unsub)
const update = (client: ReturnType<typeof useSDK>["client"], pty: Partial<LocalPTY> & { id: string }) => {
const index = store.all.findIndex((x) => x.id === pty.id)
const previous = index >= 0 ? store.all[index] : undefined
if (index >= 0) {
setStore("all", index, (item) => ({ ...item, ...pty }))
}
client.pty
.update({
ptyID: pty.id,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
.catch((error: unknown) => {
if (previous) {
const currentIndex = store.all.findIndex((item) => item.id === pty.id)
if (currentIndex >= 0) setStore("all", currentIndex, previous)
}
console.error("Failed to update terminal", error)
})
}
const clone = async (client: ReturnType<typeof useSDK>["client"], id: string) => {
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
if (!pty) return
const next = await client.pty
.create({
title: pty.title,
})
.catch((error: unknown) => {
console.error("Failed to clone terminal", error)
return undefined
})
if (!next?.data) return
const active = store.active === pty.id
batch(() => {
setStore("all", index, {
id: next.data.id,
title: next.data.title ?? pty.title,
titleNumber: pty.titleNumber,
buffer: undefined,
cursor: undefined,
scrollY: undefined,
rows: undefined,
cols: undefined,
})
if (active) {
setStore("active", next.data.id)
}
})
}
return { return {
ready, ready,
all: createMemo(() => store.all), all: createMemo(() => store.all),
@@ -216,24 +270,7 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
}) })
}, },
update(pty: Partial<LocalPTY> & { id: string }) { update(pty: Partial<LocalPTY> & { id: string }) {
const index = store.all.findIndex((x) => x.id === pty.id) update(sdk.client, pty)
const previous = index >= 0 ? store.all[index] : undefined
if (index >= 0) {
setStore("all", index, (item) => ({ ...item, ...pty }))
}
sdk.client.pty
.update({
ptyID: pty.id,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
.catch((error: unknown) => {
if (previous) {
const currentIndex = store.all.findIndex((item) => item.id === pty.id)
if (currentIndex >= 0) setStore("all", currentIndex, previous)
}
console.error("Failed to update terminal", error)
})
}, },
trim(id: string) { trim(id: string) {
const index = store.all.findIndex((x) => x.id === id) const index = store.all.findIndex((x) => x.id === id)
@@ -248,37 +285,23 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
}) })
}, },
async clone(id: string) { async clone(id: string) {
const index = store.all.findIndex((x) => x.id === id) await clone(sdk.client, id)
const pty = store.all[index] },
if (!pty) return bind() {
const clone = await sdk.client.pty const client = sdk.client
.create({ return {
title: pty.title, trim(id: string) {
}) const index = store.all.findIndex((x) => x.id === id)
.catch((error: unknown) => { if (index === -1) return
console.error("Failed to clone terminal", error) setStore("all", index, (pty) => trimTerminal(pty))
return undefined },
}) update(pty: Partial<LocalPTY> & { id: string }) {
if (!clone?.data) return update(client, pty)
},
const active = store.active === pty.id async clone(id: string) {
await clone(client, id)
batch(() => { },
setStore("all", index, { }
id: clone.data.id,
title: clone.data.title ?? pty.title,
titleNumber: pty.titleNumber,
// New PTY process, so start clean.
buffer: undefined,
cursor: undefined,
scrollY: undefined,
rows: undefined,
cols: undefined,
})
if (active) {
setStore("active", clone.data.id)
}
})
}, },
open(id: string) { open(id: string) {
setStore("active", id) setStore("active", id)
@@ -403,6 +426,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
trim: (id: string) => workspace().trim(id), trim: (id: string) => workspace().trim(id),
trimAll: () => workspace().trimAll(), trimAll: () => workspace().trimAll(),
clone: (id: string) => workspace().clone(id), clone: (id: string) => workspace().clone(id),
bind: () => workspace(),
open: (id: string) => workspace().open(id), open: (id: string) => workspace().open(id),
close: (id: string) => workspace().close(id), close: (id: string) => workspace().close(id),
move: (id: string, to: number) => workspace().move(id, to), move: (id: string, to: number) => workspace().move(id, to),
+1
View File
@@ -204,6 +204,7 @@ export const dict = {
"common.cancel": "إلغاء", "common.cancel": "إلغاء",
"common.connect": "اتصال", "common.connect": "اتصال",
"common.disconnect": "قطع الاتصال", "common.disconnect": "قطع الاتصال",
"common.continue": "إرسال",
"common.submit": "إرسال", "common.submit": "إرسال",
"common.save": "حفظ", "common.save": "حفظ",
"common.saving": "جارٍ الحفظ...", "common.saving": "جارٍ الحفظ...",
+1
View File
@@ -204,6 +204,7 @@ export const dict = {
"common.cancel": "Cancelar", "common.cancel": "Cancelar",
"common.connect": "Conectar", "common.connect": "Conectar",
"common.disconnect": "Desconectar", "common.disconnect": "Desconectar",
"common.continue": "Enviar",
"common.submit": "Enviar", "common.submit": "Enviar",
"common.save": "Salvar", "common.save": "Salvar",
"common.saving": "Salvando...", "common.saving": "Salvando...",
+1
View File
@@ -221,6 +221,7 @@ export const dict = {
"common.cancel": "Otkaži", "common.cancel": "Otkaži",
"common.connect": "Poveži", "common.connect": "Poveži",
"common.disconnect": "Prekini vezu", "common.disconnect": "Prekini vezu",
"common.continue": "Pošalji",
"common.submit": "Pošalji", "common.submit": "Pošalji",
"common.save": "Sačuvaj", "common.save": "Sačuvaj",
"common.saving": "Čuvanje...", "common.saving": "Čuvanje...",
+1
View File
@@ -219,6 +219,7 @@ export const dict = {
"common.cancel": "Annuller", "common.cancel": "Annuller",
"common.connect": "Forbind", "common.connect": "Forbind",
"common.disconnect": "Frakobl", "common.disconnect": "Frakobl",
"common.continue": "Indsend",
"common.submit": "Indsend", "common.submit": "Indsend",
"common.save": "Gem", "common.save": "Gem",
"common.saving": "Gemmer...", "common.saving": "Gemmer...",
+1
View File
@@ -209,6 +209,7 @@ export const dict = {
"common.cancel": "Abbrechen", "common.cancel": "Abbrechen",
"common.connect": "Verbinden", "common.connect": "Verbinden",
"common.disconnect": "Trennen", "common.disconnect": "Trennen",
"common.continue": "Absenden",
"common.submit": "Absenden", "common.submit": "Absenden",
"common.save": "Speichern", "common.save": "Speichern",
"common.saving": "Speichert...", "common.saving": "Speichert...",
+4 -1
View File
@@ -23,6 +23,8 @@ export const dict = {
"command.sidebar.toggle": "Toggle sidebar", "command.sidebar.toggle": "Toggle sidebar",
"command.project.open": "Open project", "command.project.open": "Open project",
"command.project.previous": "Previous project",
"command.project.next": "Next project",
"command.provider.connect": "Connect provider", "command.provider.connect": "Connect provider",
"command.server.switch": "Switch server", "command.server.switch": "Switch server",
"command.settings.open": "Open settings", "command.settings.open": "Open settings",
@@ -221,6 +223,7 @@ export const dict = {
"common.open": "Open", "common.open": "Open",
"common.connect": "Connect", "common.connect": "Connect",
"common.disconnect": "Disconnect", "common.disconnect": "Disconnect",
"common.continue": "Continue",
"common.submit": "Submit", "common.submit": "Submit",
"common.save": "Save", "common.save": "Save",
"common.saving": "Saving...", "common.saving": "Saving...",
@@ -273,7 +276,7 @@ export const dict = {
"prompt.context.includeActiveFile": "Include active file", "prompt.context.includeActiveFile": "Include active file",
"prompt.context.removeActiveFile": "Remove active file from context", "prompt.context.removeActiveFile": "Remove active file from context",
"prompt.context.removeFile": "Remove file from context", "prompt.context.removeFile": "Remove file from context",
"prompt.action.attachFile": "Add file", "prompt.action.attachFile": "Add files",
"prompt.attachment.remove": "Remove attachment", "prompt.attachment.remove": "Remove attachment",
"prompt.action.send": "Send", "prompt.action.send": "Send",
"prompt.action.stop": "Stop", "prompt.action.stop": "Stop",
+1
View File
@@ -220,6 +220,7 @@ export const dict = {
"common.cancel": "Cancelar", "common.cancel": "Cancelar",
"common.connect": "Conectar", "common.connect": "Conectar",
"common.disconnect": "Desconectar", "common.disconnect": "Desconectar",
"common.continue": "Enviar",
"common.submit": "Enviar", "common.submit": "Enviar",
"common.save": "Guardar", "common.save": "Guardar",
"common.saving": "Guardando...", "common.saving": "Guardando...",
+1
View File
@@ -204,6 +204,7 @@ export const dict = {
"common.cancel": "Annuler", "common.cancel": "Annuler",
"common.connect": "Connecter", "common.connect": "Connecter",
"common.disconnect": "Déconnecter", "common.disconnect": "Déconnecter",
"common.continue": "Soumettre",
"common.submit": "Soumettre", "common.submit": "Soumettre",
"common.save": "Enregistrer", "common.save": "Enregistrer",
"common.saving": "Enregistrement...", "common.saving": "Enregistrement...",
+1
View File
@@ -203,6 +203,7 @@ export const dict = {
"common.cancel": "キャンセル", "common.cancel": "キャンセル",
"common.connect": "接続", "common.connect": "接続",
"common.disconnect": "切断", "common.disconnect": "切断",
"common.continue": "送信",
"common.submit": "送信", "common.submit": "送信",
"common.save": "保存", "common.save": "保存",
"common.saving": "保存中...", "common.saving": "保存中...",
+1
View File
@@ -207,6 +207,7 @@ export const dict = {
"common.cancel": "취소", "common.cancel": "취소",
"common.connect": "연결", "common.connect": "연결",
"common.disconnect": "연결 해제", "common.disconnect": "연결 해제",
"common.continue": "제출",
"common.submit": "제출", "common.submit": "제출",
"common.save": "저장", "common.save": "저장",
"common.saving": "저장 중...", "common.saving": "저장 중...",
+1
View File
@@ -223,6 +223,7 @@ export const dict = {
"common.cancel": "Avbryt", "common.cancel": "Avbryt",
"common.connect": "Koble til", "common.connect": "Koble til",
"common.disconnect": "Koble fra", "common.disconnect": "Koble fra",
"common.continue": "Send inn",
"common.submit": "Send inn", "common.submit": "Send inn",
"common.save": "Lagre", "common.save": "Lagre",
"common.saving": "Lagrer...", "common.saving": "Lagrer...",
+1
View File
@@ -205,6 +205,7 @@ export const dict = {
"common.cancel": "Anuluj", "common.cancel": "Anuluj",
"common.connect": "Połącz", "common.connect": "Połącz",
"common.disconnect": "Rozłącz", "common.disconnect": "Rozłącz",
"common.continue": "Prześlij",
"common.submit": "Prześlij", "common.submit": "Prześlij",
"common.save": "Zapisz", "common.save": "Zapisz",
"common.saving": "Zapisywanie...", "common.saving": "Zapisywanie...",
+1
View File
@@ -220,6 +220,7 @@ export const dict = {
"common.cancel": "Отмена", "common.cancel": "Отмена",
"common.connect": "Подключить", "common.connect": "Подключить",
"common.disconnect": "Отключить", "common.disconnect": "Отключить",
"common.continue": "Отправить",
"common.submit": "Отправить", "common.submit": "Отправить",
"common.save": "Сохранить", "common.save": "Сохранить",
"common.saving": "Сохранение...", "common.saving": "Сохранение...",
+1
View File
@@ -220,6 +220,7 @@ export const dict = {
"common.cancel": "ยกเลิก", "common.cancel": "ยกเลิก",
"common.connect": "เชื่อมต่อ", "common.connect": "เชื่อมต่อ",
"common.disconnect": "ยกเลิกการเชื่อมต่อ", "common.disconnect": "ยกเลิกการเชื่อมต่อ",
"common.continue": "ส่ง",
"common.submit": "ส่ง", "common.submit": "ส่ง",
"common.save": "บันทึก", "common.save": "บันทึก",
"common.saving": "กำลังบันทึก...", "common.saving": "กำลังบันทึก...",
+1
View File
@@ -225,6 +225,7 @@ export const dict = {
"common.cancel": "İptal", "common.cancel": "İptal",
"common.connect": "Bağlan", "common.connect": "Bağlan",
"common.disconnect": "Bağlantı Kes", "common.disconnect": "Bağlantı Kes",
"common.continue": "Gönder",
"common.submit": "Gönder", "common.submit": "Gönder",
"common.save": "Kaydet", "common.save": "Kaydet",
"common.saving": "Kaydediliyor...", "common.saving": "Kaydediliyor...",
+1
View File
@@ -242,6 +242,7 @@ export const dict = {
"common.cancel": "取消", "common.cancel": "取消",
"common.connect": "连接", "common.connect": "连接",
"common.disconnect": "断开连接", "common.disconnect": "断开连接",
"common.continue": "提交",
"common.submit": "提交", "common.submit": "提交",
"common.save": "保存", "common.save": "保存",
"common.saving": "保存中...", "common.saving": "保存中...",
+1
View File
@@ -220,6 +220,7 @@ export const dict = {
"common.cancel": "取消", "common.cancel": "取消",
"common.connect": "連線", "common.connect": "連線",
"common.disconnect": "中斷連線", "common.disconnect": "中斷連線",
"common.continue": "提交",
"common.submit": "提交", "common.submit": "提交",
"common.save": "儲存", "common.save": "儲存",
"common.saving": "儲存中...", "common.saving": "儲存中...",
+1
View File
@@ -1,4 +1,5 @@
export { AppBaseProviders, AppInterface } from "./app" export { AppBaseProviders, AppInterface } from "./app"
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
export { useCommand } from "./context/command" export { useCommand } from "./context/command"
export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform" export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
export { ServerConnection } from "./context/server" export { ServerConnection } from "./context/server"
+46 -51
View File
@@ -1,16 +1,15 @@
import { batch, createEffect, createMemo, Show, type ParentProps } from "solid-js" import { DataProvider } from "@opencode-ai/ui/context"
import { createStore } from "solid-js/store" import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/util/encode"
import { useLocation, useNavigate, useParams } from "@solidjs/router" import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { createMemo, createResource, type ParentProps, Show } from "solid-js"
import { useGlobalSDK } from "@/context/global-sdk"
import { useLanguage } from "@/context/language"
import { LocalProvider } from "@/context/local"
import { SDKProvider } from "@/context/sdk" import { SDKProvider } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync" import { SyncProvider, useSync } from "@/context/sync"
import { LocalProvider } from "@/context/local"
import { useGlobalSDK } from "@/context/global-sdk"
import { DataProvider } from "@opencode-ai/ui/context"
import { base64Encode } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language"
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
const navigate = useNavigate() const navigate = useNavigate()
const sync = useSync() const sync = useSync()
@@ -30,57 +29,53 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
export default function Layout(props: ParentProps) { export default function Layout(props: ParentProps) {
const params = useParams() const params = useParams()
const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const language = useLanguage() const language = useLanguage()
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
const directory = createMemo(() => decode64(params.dir) ?? "") const navigate = useNavigate()
const [state, setState] = createStore({ invalid: "", resolved: "" }) let invalid = ""
createEffect(() => { const [resolved] = createResource(
if (!params.dir) return () => {
const raw = directory() if (params.dir) return [location.pathname, params.dir] as const
if (!raw) { },
if (state.invalid === params.dir) return async ([pathname, b64Dir]) => {
setState("invalid", params.dir) const directory = decode64(b64Dir)
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: language.t("directory.error.invalidUrl"),
})
navigate("/", { replace: true })
return
}
const current = params.dir if (!directory) {
globalSDK if (invalid === params.dir) return
.createClient({ invalid = b64Dir
directory: raw, showToast({
throwOnError: true, variant: "error",
}) title: language.t("common.requestFailed"),
.path.get() description: language.t("directory.error.invalidUrl"),
.then((x) => {
if (params.dir !== current) return
const next = x.data?.directory ?? raw
batch(() => {
setState("invalid", "")
setState("resolved", next)
}) })
if (next === raw) return navigate("/", { replace: true })
const path = location.pathname.slice(current.length + 1) return
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) }
})
.catch(() => { return await globalSDK
if (params.dir !== current) return .createClient({
batch(() => { directory,
setState("invalid", "") throwOnError: true,
setState("resolved", raw)
}) })
}) .path.get()
}) .then((x) => {
const next = x.data?.directory ?? directory
invalid = ""
if (next === directory) return next
const path = pathname.slice(b64Dir.length + 1)
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
})
.catch(() => {
invalid = ""
return directory
})
},
)
return ( return (
<Show when={state.resolved} keyed> <Show when={resolved()} keyed>
{(resolved) => ( {(resolved) => (
<SDKProvider directory={() => resolved}> <SDKProvider directory={() => resolved}>
<SyncProvider> <SyncProvider>
+9 -1
View File
@@ -1,11 +1,12 @@
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { Logo } from "@opencode-ai/ui/logo" import { Logo } from "@opencode-ai/ui/logo"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Component, Show } from "solid-js" import { Component, Show, onMount } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import type { E2EWindow } from "@/testing/terminal"
export type InitError = { export type InitError = {
name: string name: string
@@ -226,6 +227,13 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
actionError: undefined as string | undefined, actionError: undefined as string | undefined,
}) })
onMount(() => {
const win = window as E2EWindow
if (!win.__opencode_e2e) return
const detail = formatError(props.error, language.t)
console.error(`[e2e:error-boundary] ${window.location.pathname}\n${detail}`)
})
async function checkForUpdates() { async function checkForUpdates() {
if (!platform.checkUpdate) return if (!platform.checkUpdate) return
setStore("checking", true) setStore("checking", true)
+120 -38
View File
@@ -129,6 +129,16 @@ export default function Layout(props: ParentProps) {
const theme = useTheme() const theme = useTheme()
const language = useLanguage() const language = useLanguage()
const initialDirectory = decode64(params.dir) const initialDirectory = decode64(params.dir)
const route = createMemo(() => {
const slug = params.dir
if (!slug) return { slug, dir: "" }
const dir = decode64(slug)
if (!dir) return { slug, dir: "" }
return {
slug,
dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir,
}
})
const availableThemeEntries = createMemo(() => Object.entries(theme.themes())) const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"] const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = { const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
@@ -137,7 +147,7 @@ export default function Layout(props: ParentProps) {
dark: "theme.scheme.dark", dark: "theme.scheme.dark",
} }
const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme]) const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
const currentDir = createMemo(() => decode64(params.dir) ?? "") const currentDir = createMemo(() => route().dir)
const [state, setState] = createStore({ const [state, setState] = createStore({
autoselect: !initialDirectory, autoselect: !initialDirectory,
@@ -201,13 +211,22 @@ export default function Layout(props: ParentProps) {
onMount(() => { onMount(() => {
const stop = () => setState("sizing", false) const stop = () => setState("sizing", false)
const blur = () => reset()
const hide = () => {
if (document.visibilityState !== "hidden") return
reset()
}
window.addEventListener("pointerup", stop) window.addEventListener("pointerup", stop)
window.addEventListener("pointercancel", stop) window.addEventListener("pointercancel", stop)
window.addEventListener("blur", stop) window.addEventListener("blur", stop)
window.addEventListener("blur", blur)
document.addEventListener("visibilitychange", hide)
onCleanup(() => { onCleanup(() => {
window.removeEventListener("pointerup", stop) window.removeEventListener("pointerup", stop)
window.removeEventListener("pointercancel", stop) window.removeEventListener("pointercancel", stop)
window.removeEventListener("blur", stop) window.removeEventListener("blur", stop)
window.removeEventListener("blur", blur)
document.removeEventListener("visibilitychange", hide)
}) })
}) })
@@ -227,6 +246,12 @@ export default function Layout(props: ParentProps) {
navLeave.current = undefined navLeave.current = undefined
} }
const reset = () => {
disarm()
setState("hoverSession", undefined)
setHoverProject(undefined)
}
const arm = () => { const arm = () => {
if (layout.sidebar.opened()) return if (layout.sidebar.opened()) return
if (state.hoverProject === undefined) return if (state.hoverProject === undefined) return
@@ -295,8 +320,7 @@ export default function Layout(props: ParentProps) {
const clearSidebarHoverState = () => { const clearSidebarHoverState = () => {
if (layout.sidebar.opened()) return if (layout.sidebar.opened()) return
setState("hoverSession", undefined) reset()
setHoverProject(undefined)
} }
const navigateWithSidebarReset = (href: string) => { const navigateWithSidebarReset = (href: string) => {
@@ -484,8 +508,8 @@ export default function Layout(props: ParentProps) {
} }
const currentSession = params.id const currentSession = params.id
if (directory === currentDir() && props.sessionID === currentSession) return if (workspaceKey(directory) === workspaceKey(currentDir()) && props.sessionID === currentSession) return
if (directory === currentDir() && session?.parentID === currentSession) return if (workspaceKey(directory) === workspaceKey(currentDir()) && session?.parentID === currentSession) return
dismissSessionAlert(sessionKey) dismissSessionAlert(sessionKey)
@@ -543,13 +567,14 @@ export default function Layout(props: ParentProps) {
const currentProject = createMemo(() => { const currentProject = createMemo(() => {
const directory = currentDir() const directory = currentDir()
if (!directory) return if (!directory) return
const key = workspaceKey(directory)
const projects = layout.projects.list() const projects = layout.projects.list()
const sandbox = projects.find((p) => p.sandboxes?.includes(directory)) const sandbox = projects.find((p) => p.sandboxes?.some((item) => workspaceKey(item) === key))
if (sandbox) return sandbox if (sandbox) return sandbox
const direct = projects.find((p) => p.worktree === directory) const direct = projects.find((p) => workspaceKey(p.worktree) === key)
if (direct) return direct if (direct) return direct
const [child] = globalSync.child(directory, { bootstrap: false }) const [child] = globalSync.child(directory, { bootstrap: false })
@@ -619,7 +644,7 @@ export default function Layout(props: ParentProps) {
const activeDir = currentDir() const activeDir = currentDir()
return workspaceIds(project).filter((directory) => { return workspaceIds(project).filter((directory) => {
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
const active = directory === activeDir const active = workspaceKey(directory) === workspaceKey(activeDir)
return expanded || active return expanded || active
}) })
}) })
@@ -630,7 +655,11 @@ export default function Layout(props: ParentProps) {
const projects = layout.projects.list() const projects = layout.projects.list()
for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) { for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) {
if (!expanded) continue if (!expanded) continue
const project = projects.find((item) => item.worktree === directory || item.sandboxes?.includes(directory)) const key = workspaceKey(directory)
const project = projects.find(
(item) =>
workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key),
)
if (!project) continue if (!project) continue
if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue
setStore("workspaceExpanded", directory, false) setStore("workspaceExpanded", directory, false)
@@ -682,7 +711,7 @@ export default function Layout(props: ParentProps) {
seen: lru, seen: lru,
keep: sessionID, keep: sessionID,
limit: PREFETCH_MAX_SESSIONS_PER_DIR, limit: PREFETCH_MAX_SESSIONS_PER_DIR,
preserve: directory === params.dir && params.id ? [params.id] : undefined, preserve: params.id && workspaceKey(directory) === workspaceKey(currentDir()) ? [params.id] : undefined,
}) })
} }
@@ -695,7 +724,7 @@ export default function Layout(props: ParentProps) {
}) })
createEffect(() => { createEffect(() => {
params.dir route()
globalSDK.url globalSDK.url
prefetchToken.value += 1 prefetchToken.value += 1
@@ -921,6 +950,26 @@ export default function Layout(props: ParentProps) {
navigateToSession(session) navigateToSession(session)
} }
function navigateProjectByOffset(offset: number) {
const projects = layout.projects.list()
if (projects.length === 0) return
const current = currentProject()?.worktree
const fallback = currentDir() ? projectRoot(currentDir()) : undefined
const active = current ?? fallback
const index = active ? projects.findIndex((project) => project.worktree === active) : -1
const target =
index === -1
? offset > 0
? projects[0]
: projects[projects.length - 1]
: projects[(index + offset + projects.length) % projects.length]
if (!target) return
openProject(target.worktree)
}
function navigateSessionByUnseen(offset: number) { function navigateSessionByUnseen(offset: number) {
const sessions = currentSessions() const sessions = currentSessions()
if (sessions.length === 0) return if (sessions.length === 0) return
@@ -987,6 +1036,20 @@ export default function Layout(props: ParentProps) {
keybind: "mod+o", keybind: "mod+o",
onSelect: () => chooseProject(), onSelect: () => chooseProject(),
}, },
{
id: "project.previous",
title: language.t("command.project.previous"),
category: language.t("command.category.project"),
keybind: "mod+alt+arrowup",
onSelect: () => navigateProjectByOffset(-1),
},
{
id: "project.next",
title: language.t("command.project.next"),
category: language.t("command.category.project"),
keybind: "mod+alt+arrowdown",
onSelect: () => navigateProjectByOffset(1),
},
{ {
id: "provider.connect", id: "provider.connect",
title: language.t("command.provider.connect"), title: language.t("command.provider.connect"),
@@ -1155,13 +1218,17 @@ export default function Layout(props: ParentProps) {
} }
function projectRoot(directory: string) { function projectRoot(directory: string) {
const key = workspaceKey(directory)
const project = layout.projects const project = layout.projects
.list() .list()
.find((item) => item.worktree === directory || item.sandboxes?.includes(directory)) .find(
(item) =>
workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key),
)
if (project) return project.worktree if (project) return project.worktree
const known = Object.entries(store.workspaceOrder).find( const known = Object.entries(store.workspaceOrder).find(
([root, dirs]) => root === directory || dirs.includes(directory), ([root, dirs]) => workspaceKey(root) === key || dirs.some((item) => workspaceKey(item) === key),
) )
if (known) return known[0] if (known) return known[0]
@@ -1177,13 +1244,6 @@ export default function Layout(props: ParentProps) {
return currentProject()?.worktree ?? projectRoot(directory) 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)) { function rememberSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
setStore("lastProjectSession", root, { directory, id, at: Date.now() }) setStore("lastProjectSession", root, { directory, id, at: Date.now() })
return root return root
@@ -1347,8 +1407,9 @@ export default function Layout(props: ParentProps) {
function closeProject(directory: string) { function closeProject(directory: string) {
const list = layout.projects.list() const list = layout.projects.list()
const index = list.findIndex((x) => x.worktree === directory) const key = workspaceKey(directory)
const active = currentProject()?.worktree === directory const index = list.findIndex((x) => workspaceKey(x.worktree) === key)
const active = workspaceKey(currentProject()?.worktree ?? "") === key
if (index === -1) return if (index === -1) return
const next = list[index + 1] const next = list[index + 1]
@@ -1683,38 +1744,51 @@ export default function Layout(props: ParentProps) {
const activeRoute = { const activeRoute = {
session: "", session: "",
sessionProject: "", sessionProject: "",
directory: "",
} }
createEffect( createEffect(
on( on(
() => [pageReady(), params.dir, params.id, currentProject()?.worktree] as const, () => {
([ready, dir, id]) => { return [pageReady(), route().slug, params.id, currentProject()?.worktree, currentDir()] as const
if (!ready || !dir) { },
([ready, slug, id, root, dir]) => {
if (!ready || !slug || !dir) {
activeRoute.session = "" activeRoute.session = ""
activeRoute.sessionProject = "" activeRoute.sessionProject = ""
activeRoute.directory = ""
return return
} }
const directory = decode64(dir)
if (!directory) return
const root = touchProjectRoute() ?? activeProjectRoot(directory)
if (!id) { if (!id) {
activeRoute.session = "" activeRoute.session = ""
activeRoute.sessionProject = "" activeRoute.sessionProject = ""
activeRoute.directory = ""
return return
} }
const session = `${dir}/${id}` const session = `${slug}/${id}`
if (session !== activeRoute.session) {
if (!root) {
activeRoute.session = session activeRoute.session = session
activeRoute.sessionProject = syncSessionRoute(directory, id, root) activeRoute.directory = dir
activeRoute.sessionProject = ""
return
}
if (server.projects.last() !== root) server.projects.touch(root)
const changed = session !== activeRoute.session || dir !== activeRoute.directory
if (changed) {
activeRoute.session = session
activeRoute.directory = dir
activeRoute.sessionProject = syncSessionRoute(dir, id, root)
return return
} }
if (root === activeRoute.sessionProject) return if (root === activeRoute.sessionProject) return
activeRoute.sessionProject = rememberSessionRoute(directory, id, root) activeRoute.directory = dir
activeRoute.sessionProject = rememberSessionRoute(dir, id, root)
}, },
), ),
) )
@@ -1778,8 +1852,13 @@ export default function Layout(props: ParentProps) {
const local = project.worktree const local = project.worktree
const dirs = [local, ...(project.sandboxes ?? [])] const dirs = [local, ...(project.sandboxes ?? [])]
const active = currentProject() const active = currentProject()
const directory = active?.worktree === project.worktree ? currentDir() : undefined const directory = workspaceKey(active?.worktree ?? "") === workspaceKey(project.worktree) ? currentDir() : undefined
const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined const extra =
directory &&
workspaceKey(directory) !== workspaceKey(local) &&
!dirs.some((item) => workspaceKey(item) === workspaceKey(directory))
? directory
: undefined
const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree]) const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree])
@@ -1902,6 +1981,7 @@ export default function Layout(props: ParentProps) {
const projectSidebarCtx: ProjectSidebarContext = { const projectSidebarCtx: ProjectSidebarContext = {
currentDir, currentDir,
currentProject,
sidebarOpened: () => layout.sidebar.opened(), sidebarOpened: () => layout.sidebar.opened(),
sidebarHovering, sidebarHovering,
hoverProject: () => state.hoverProject, hoverProject: () => state.hoverProject,
@@ -1909,6 +1989,10 @@ export default function Layout(props: ParentProps) {
onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event), onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
onProjectMouseLeave: (worktree) => aim.leave(worktree), onProjectMouseLeave: (worktree) => aim.leave(worktree),
onProjectFocus: (worktree) => aim.activate(worktree), onProjectFocus: (worktree) => aim.activate(worktree),
onHoverOpenChanged: (worktree, hoverOpen) => {
if (!hoverOpen && state.hoverProject && state.hoverProject !== worktree) return
setState("hoverProject", hoverOpen ? worktree : undefined)
},
navigateToProject, navigateToProject,
openSidebar: () => layout.sidebar.open(), openSidebar: () => layout.sidebar.open(),
closeProject, closeProject,
@@ -2302,14 +2386,12 @@ export default function Layout(props: ParentProps) {
size={layout.sidebar.width()} size={layout.sidebar.width()}
min={244} min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64} max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
collapseThreshold={244}
onResize={(w) => { onResize={(w) => {
setState("sizing", true) setState("sizing", true)
if (sizet !== undefined) clearTimeout(sizet) if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setState("sizing", false), 120) sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w) layout.sidebar.resize(w)
}} }}
onCollapse={layout.sidebar.close}
/> />
</div> </div>
</Show> </Show>
@@ -104,14 +104,14 @@ describe("layout deep links", () => {
describe("layout workspace helpers", () => { describe("layout workspace helpers", () => {
test("normalizes trailing slash in workspace key", () => { test("normalizes trailing slash in workspace key", () => {
expect(workspaceKey("/tmp/demo///")).toBe("/tmp/demo") expect(workspaceKey("/tmp/demo///")).toBe("/tmp/demo")
expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:\\tmp\\demo") expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:/tmp/demo")
}) })
test("preserves posix and drive roots in workspace key", () => { test("preserves posix and drive roots in workspace key", () => {
expect(workspaceKey("/")).toBe("/") expect(workspaceKey("/")).toBe("/")
expect(workspaceKey("///")).toBe("/") expect(workspaceKey("///")).toBe("/")
expect(workspaceKey("C:\\")).toBe("C:\\") expect(workspaceKey("C:\\")).toBe("C:/")
expect(workspaceKey("C:\\\\\\")).toBe("C:\\") expect(workspaceKey("C://")).toBe("C:/")
expect(workspaceKey("C:///")).toBe("C:/") expect(workspaceKey("C:///")).toBe("C:/")
}) })
+20 -14
View File
@@ -1,11 +1,17 @@
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
import { type Session } from "@opencode-ai/sdk/v2/client" import { type Session } from "@opencode-ai/sdk/v2/client"
type SessionStore = {
session?: Session[]
path: { directory: string }
}
export const workspaceKey = (directory: string) => { export const workspaceKey = (directory: string) => {
const drive = directory.match(/^([A-Za-z]:)[\\/]+$/) const value = directory.replaceAll("\\", "/")
if (drive) return `${drive[1]}${directory.includes("\\") ? "\\" : "/"}` const drive = value.match(/^([A-Za-z]:)\/+$/)
if (/^[\\/]+$/.test(directory)) return directory.includes("\\") ? "\\" : "/" if (drive) return `${drive[1]}/`
return directory.replace(/[\\/]+$/, "") if (/^\/+$/i.test(value)) return "/"
return value.replace(/\/+$/, "")
} }
function sortSessions(now: number) { function sortSessions(now: number) {
@@ -25,24 +31,24 @@ function sortSessions(now: number) {
const isRootVisibleSession = (session: Session, directory: string) => const isRootVisibleSession = (session: Session, directory: string) =>
workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived
export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) => const roots = (store: SessionStore) =>
store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).sort(sortSessions(now)) (store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory))
export const latestRootSession = (stores: { session: Session[]; path: { directory: string } }[], now: number) => export const sortedRootSessions = (store: SessionStore, now: number) => roots(store).sort(sortSessions(now))
stores
.flatMap((store) => store.session.filter((session) => isRootVisibleSession(session, store.path.directory))) export const latestRootSession = (stores: SessionStore[], now: number) =>
.sort(sortSessions(now))[0] stores.flatMap(roots).sort(sortSessions(now))[0]
export function hasProjectPermissions<T>( export function hasProjectPermissions<T>(
request: Record<string, T[] | undefined>, request: Record<string, T[] | undefined> | undefined,
include: (item: T) => boolean = () => true, include: (item: T) => boolean = () => true,
) { ) {
return Object.values(request).some((list) => list?.some(include)) return Object.values(request ?? {}).some((list) => list?.some(include))
} }
export const childMapByParent = (sessions: Session[]) => { export const childMapByParent = (sessions: Session[] | undefined) => {
const map = new Map<string, string[]>() const map = new Map<string, string[]>()
for (const session of sessions) { for (const session of sessions ?? []) {
if (!session.parentID) continue if (!session.parentID) continue
const existing = map.get(session.parentID) const existing = map.get(session.parentID)
if (existing) { if (existing) {
+38 -27
View File
@@ -157,34 +157,45 @@ const SessionHoverPreview = (props: {
messageLabel: (message: Message) => string | undefined messageLabel: (message: Message) => string | undefined
onMessageSelect: (message: Message) => void onMessageSelect: (message: Message) => void
trigger: JSX.Element trigger: JSX.Element
}): JSX.Element => ( }): JSX.Element => {
<HoverCard let ref: HTMLDivElement | undefined
openDelay={1000}
closeDelay={props.sidebarHovering() ? 600 : 0} return (
placement="right-start" <HoverCard
gutter={16} openDelay={1000}
shift={-2} closeDelay={props.sidebarHovering() ? 600 : 0}
trigger={props.trigger} placement="right-start"
open={props.hoverSession() === props.session.id} gutter={16}
onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)} shift={-2}
> trigger={<div ref={ref}>{props.trigger}</div>}
<Show open={props.hoverSession() === props.session.id}
when={props.hoverReady()} onOpenChange={(open) => {
fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>} if (!open) {
props.setHoverSession(undefined)
return
}
if (!ref?.matches(":hover")) return
props.setHoverSession(props.session.id)
}}
> >
<div class="overflow-y-auto overflow-x-hidden max-h-72 h-full"> <Show
<MessageNav when={props.hoverReady()}
messages={props.hoverMessages() ?? []} fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
current={undefined} >
getLabel={props.messageLabel} <div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
onMessageSelect={props.onMessageSelect} <MessageNav
size="normal" messages={props.hoverMessages() ?? []}
class="w-60" current={undefined}
/> getLabel={props.messageLabel}
</div> onMessageSelect={props.onMessageSelect}
</Show> size="normal"
</HoverCard> class="w-60"
) />
</div>
</Show>
</HoverCard>
)
}
export const SessionItem = (props: SessionItemProps): JSX.Element => { export const SessionItem = (props: SessionItemProps): JSX.Element => {
const params = useParams() const params = useParams()
@@ -15,6 +15,7 @@ import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
export type ProjectSidebarContext = { export type ProjectSidebarContext = {
currentDir: Accessor<string> currentDir: Accessor<string>
currentProject: Accessor<LocalProject | undefined>
sidebarOpened: Accessor<boolean> sidebarOpened: Accessor<boolean>
sidebarHovering: Accessor<boolean> sidebarHovering: Accessor<boolean>
hoverProject: Accessor<string | undefined> hoverProject: Accessor<string | undefined>
@@ -22,6 +23,7 @@ export type ProjectSidebarContext = {
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
onProjectMouseLeave: (worktree: string) => void onProjectMouseLeave: (worktree: string) => void
onProjectFocus: (worktree: string) => void onProjectFocus: (worktree: string) => void
onHoverOpenChanged: (worktree: string, hovered: boolean) => void
navigateToProject: (directory: string) => void navigateToProject: (directory: string) => void
openSidebar: () => void openSidebar: () => void
closeProject: (directory: string) => void closeProject: (directory: string) => void
@@ -108,8 +110,14 @@ const ProjectTile = (props: {
"bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(), "bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
}} }}
onPointerDown={(event) => { onPointerDown={(event) => {
if (event.button === 0 && !event.ctrlKey) {
props.setOpen(false)
props.setSuppressHover(true)
return
}
if (!props.overlay()) return if (!props.overlay()) return
if (event.button !== 2 && !(event.button === 0 && event.ctrlKey)) return if (event.button !== 2 && !(event.button === 0 && event.ctrlKey)) return
props.setOpen(false)
props.setSuppressHover(true) props.setSuppressHover(true)
event.preventDefault() event.preventDefault()
}} }}
@@ -129,12 +137,11 @@ const ProjectTile = (props: {
props.onProjectFocus(props.project.worktree) props.onProjectFocus(props.project.worktree)
}} }}
onClick={() => { onClick={() => {
props.setOpen(false)
if (props.selected()) { if (props.selected()) {
props.setSuppressHover(true)
layout.sidebar.toggle() layout.sidebar.toggle()
return return
} }
props.setSuppressHover(false)
props.navigateToProject(props.project.worktree) props.navigateToProject(props.project.worktree)
}} }}
onBlur={() => props.setOpen(false)} onBlur={() => props.setOpen(false)}
@@ -191,7 +198,6 @@ const ProjectPreviewPanel = (props: {
projectChildren: Accessor<Map<string, string[]>> projectChildren: Accessor<Map<string, string[]>>
workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions> workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions>
workspaceChildren: (directory: string) => Map<string, string[]> workspaceChildren: (directory: string) => Map<string, string[]>
setOpen: (value: boolean) => void
ctx: ProjectSidebarContext ctx: ProjectSidebarContext
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
}): JSX.Element => ( }): JSX.Element => (
@@ -258,7 +264,7 @@ const ProjectPreviewPanel = (props: {
class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent" class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
onClick={() => { onClick={() => {
props.ctx.openSidebar() props.ctx.openSidebar()
props.setOpen(false) props.ctx.onHoverOpenChanged(props.project.worktree, false)
if (props.selected()) return if (props.selected()) return
props.ctx.navigateToProject(props.project.worktree) props.ctx.navigateToProject(props.project.worktree)
}} }}
@@ -278,37 +284,21 @@ export const SortableProject = (props: {
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const language = useLanguage() const language = useLanguage()
const sortable = createSortable(props.project.worktree) const sortable = createSortable(props.project.worktree)
const selected = createMemo( const selected = createMemo(() => props.ctx.currentProject()?.worktree === props.project.worktree)
() =>
props.project.worktree === props.ctx.currentDir() ||
props.project.sandboxes?.includes(props.ctx.currentDir()) === true,
)
const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2)) const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project)) const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
const dirs = createMemo(() => props.ctx.workspaceIds(props.project)) const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
const [state, setState] = createStore({ const [state, setState] = createStore({
open: false,
menu: false, menu: false,
suppressHover: false, suppressHover: false,
}) })
const isHoverProject = () => props.ctx.hoverProject() === props.project.worktree
const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened()) const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened())
const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened()) const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened())
const active = createMemo( const active = createMemo(() => state.menu || (preview() ? isHoverProject() : overlay() && isHoverProject()))
() => state.menu || (preview() ? state.open : overlay() && props.ctx.hoverProject() === props.project.worktree),
)
createEffect(() => { const hoverOpen = () => isHoverProject() && preview() && !selected() && !state.menu
if (preview()) return
if (!state.open) return
setState("open", false)
})
createEffect(() => {
if (!selected()) return
if (!state.open) return
setState("open", false)
})
const label = (directory: string) => { const label = (directory: string) => {
const [data] = globalSync.child(directory, { bootstrap: false }) const [data] = globalSync.child(directory, { bootstrap: false })
@@ -349,7 +339,7 @@ export const SortableProject = (props: {
workspacesEnabled={props.ctx.workspacesEnabled} workspacesEnabled={props.ctx.workspacesEnabled}
closeProject={props.ctx.closeProject} closeProject={props.ctx.closeProject}
setMenu={(value) => setState("menu", value)} setMenu={(value) => setState("menu", value)}
setOpen={(value) => setState("open", value)} setOpen={(value) => props.ctx.onHoverOpenChanged(props.project.worktree, value)}
setSuppressHover={(value) => setState("suppressHover", value)} setSuppressHover={(value) => setState("suppressHover", value)}
language={language} language={language}
/> />
@@ -360,7 +350,7 @@ export const SortableProject = (props: {
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}> <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<Show when={preview() && !selected()} fallback={tile()}> <Show when={preview() && !selected()} fallback={tile()}>
<HoverCard <HoverCard
open={!state.suppressHover && state.open && !state.menu} open={!state.suppressHover && hoverOpen() && !state.menu}
openDelay={0} openDelay={0}
closeDelay={0} closeDelay={0}
placement="right-start" placement="right-start"
@@ -369,7 +359,7 @@ export const SortableProject = (props: {
onOpenChange={(value) => { onOpenChange={(value) => {
if (state.menu) return if (state.menu) return
if (value && state.suppressHover) return if (value && state.suppressHover) return
setState("open", value) props.ctx.onHoverOpenChanged(props.project.worktree, value)
if (value) props.ctx.setHoverSession(undefined) if (value) props.ctx.setHoverSession(undefined)
}} }}
> >
@@ -384,7 +374,6 @@ export const SortableProject = (props: {
projectChildren={projectChildren} projectChildren={projectChildren}
workspaceSessions={workspaceSessions} workspaceSessions={workspaceSessions}
workspaceChildren={workspaceChildren} workspaceChildren={workspaceChildren}
setOpen={(value) => setState("open", value)}
ctx={props.ctx} ctx={props.ctx}
language={language} language={language}
/> />
@@ -17,7 +17,7 @@ import { type LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items" import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
import { childMapByParent, sortedRootSessions } from "./helpers" import { childMapByParent, sortedRootSessions, workspaceKey } from "./helpers"
type InlineEditorComponent = (props: { type InlineEditorComponent = (props: {
id: string id: string
@@ -323,7 +323,7 @@ export const SortableWorkspace = (props: {
const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow())) const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
const children = createMemo(() => childMapByParent(workspaceStore.session)) const children = createMemo(() => childMapByParent(workspaceStore.session))
const local = createMemo(() => props.directory === props.project.worktree) const local = createMemo(() => props.directory === props.project.worktree)
const active = createMemo(() => props.ctx.currentDir() === props.directory) const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory))
const workspaceValue = createMemo(() => { const workspaceValue = createMemo(() => {
const branch = workspaceStore.vcs?.branch const branch = workspaceStore.vcs?.branch
const name = branch ?? getFilename(props.directory) const name = branch ?? getFilename(props.directory)
@@ -332,12 +332,13 @@ export const SortableWorkspace = (props: {
const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local())) const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local()))
const boot = createMemo(() => open() || active()) const boot = createMemo(() => open() || active())
const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false) const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false)
const hasMore = createMemo(() => workspaceStore.sessionTotal > sessions().length) const count = createMemo(() => sessions()?.length ?? 0)
const hasMore = createMemo(() => workspaceStore.sessionTotal > count())
const busy = createMemo(() => props.ctx.isBusy(props.directory)) const busy = createMemo(() => props.ctx.isBusy(props.directory))
const wasBusy = createMemo((prev) => prev || busy(), false) const wasBusy = createMemo((prev) => prev || busy(), false)
const loading = createMemo(() => open() && !booted() && sessions().length === 0 && !wasBusy()) const loading = createMemo(() => open() && !booted() && count() === 0 && !wasBusy())
const touch = createMediaQuery("(hover: none)") const touch = createMediaQuery("(hover: none)")
const showNew = createMemo(() => !loading() && (touch() || sessions().length === 0 || (active() && !params.id))) const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id)))
const loadMore = async () => { const loadMore = async () => {
setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5) setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5)
await globalSync.project.loadSessions(props.directory) await globalSync.project.loadSessions(props.directory)
@@ -472,8 +473,9 @@ export const LocalWorkspace = (props: {
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow())) const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
const children = createMemo(() => childMapByParent(workspace().store.session)) const children = createMemo(() => childMapByParent(workspace().store.session))
const booted = createMemo((prev) => prev || workspace().store.status === "complete", false) const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
const loading = createMemo(() => !booted() && sessions().length === 0) const count = createMemo(() => sessions()?.length ?? 0)
const hasMore = createMemo(() => workspace().store.sessionTotal > sessions().length) const loading = createMemo(() => !booted() && count() === 0)
const hasMore = createMemo(() => workspace().store.sessionTotal > count())
const loadMore = async () => { const loadMore = async () => {
workspace().setStore("limit", (limit) => (limit ?? 0) + 5) workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
await globalSync.project.loadSessions(props.project.worktree) await globalSync.project.loadSessions(props.project.worktree)
+129 -121
View File
@@ -1,5 +1,6 @@
import type { Project, UserMessage } from "@opencode-ai/sdk/v2" import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import { import {
batch, batch,
onCleanup, onCleanup,
@@ -327,10 +328,7 @@ export default function Page() {
}) })
const [ui, setUi] = createStore({ const [ui, setUi] = createStore({
git: false,
pendingMessage: undefined as string | undefined, pendingMessage: undefined as string | undefined,
restoring: undefined as string | undefined,
reverting: false,
reviewSnap: false, reviewSnap: false,
scrollGesture: 0, scrollGesture: 0,
scroll: { scroll: {
@@ -506,7 +504,6 @@ export default function Page() {
const [followup, setFollowup] = createStore({ const [followup, setFollowup] = createStore({
items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>, items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
sending: {} as Record<string, string | undefined>,
failed: {} as Record<string, string | undefined>, failed: {} as Record<string, string | undefined>,
paused: {} as Record<string, boolean | undefined>, paused: {} as Record<string, boolean | undefined>,
edit: {} as Record< edit: {} as Record<
@@ -644,25 +641,24 @@ export default function Page() {
globalSync.set("project", [...list, next]) globalSync.set("project", [...list, next])
} }
const gitMutation = useMutation(() => ({
mutationFn: () => sdk.client.project.initGit(),
onSuccess: (x) => {
if (!x.data) return
upsert(x.data)
},
onError: (err) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: formatServerError(err, language.t),
})
},
}))
function initGit() { function initGit() {
if (ui.git) return if (gitMutation.isPending) return
setUi("git", true) gitMutation.mutate()
void sdk.client.project
.initGit()
.then((x) => {
if (!x.data) return
upsert(x.data)
})
.catch((err) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: formatServerError(err, language.t),
})
})
.finally(() => {
setUi("git", false)
})
} }
let inputRef!: HTMLDivElement let inputRef!: HTMLDivElement
@@ -961,8 +957,8 @@ export default function Page() {
{language.t("session.review.noVcs.createGit.description")} {language.t("session.review.noVcs.createGit.description")}
</div> </div>
</div> </div>
<Button size="large" disabled={ui.git} onClick={initGit}> <Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
{ui.git {gitMutation.isPending
? language.t("session.review.noVcs.createGit.actionLoading") ? language.t("session.review.noVcs.createGit.actionLoading")
: language.t("session.review.noVcs.createGit.action")} : language.t("session.review.noVcs.createGit.action")}
</Button> </Button>
@@ -1379,10 +1375,40 @@ export default function Page() {
return followup.edit[id] return followup.edit[id]
}) })
const followupMutation = useMutation(() => ({
mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
if (!item) return
if (input.manual) setFollowup("paused", input.sessionID, undefined)
setFollowup("failed", input.sessionID, undefined)
const ok = await sendFollowupDraft({
client: sdk.client,
sync,
globalSync,
draft: item,
optimisticBusy: item.sessionDirectory === sdk.directory,
}).catch((err) => {
setFollowup("failed", input.sessionID, input.id)
fail(err)
return false
})
if (!ok) return
setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id))
if (input.manual) resumeScroll()
},
}))
const followupBusy = (sessionID: string) =>
followupMutation.isPending && followupMutation.variables?.sessionID === sessionID
const sendingFollowup = createMemo(() => { const sendingFollowup = createMemo(() => {
const id = params.id const id = params.id
if (!id) return if (!id) return
return followup.sending[id] if (!followupBusy(id)) return
return followupMutation.variables?.id
}) })
const queueEnabled = createMemo(() => { const queueEnabled = createMemo(() => {
@@ -1422,37 +1448,15 @@ export default function Page() {
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => { const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id) const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
if (!item) return Promise.resolve() if (!item) return Promise.resolve()
if (followup.sending[sessionID]) return Promise.resolve() if (followupBusy(sessionID)) return Promise.resolve()
if (opts?.manual) setFollowup("paused", sessionID, undefined) return followupMutation.mutateAsync({ sessionID, id, manual: opts?.manual })
setFollowup("sending", sessionID, id)
setFollowup("failed", sessionID, undefined)
return sendFollowupDraft({
client: sdk.client,
sync,
globalSync,
draft: item,
optimisticBusy: item.sessionDirectory === sdk.directory,
})
.then((ok) => {
if (ok === false) return
setFollowup("items", sessionID, (items) => (items ?? []).filter((entry) => entry.id !== id))
if (opts?.manual) resumeScroll()
})
.catch((err) => {
setFollowup("failed", sessionID, id)
fail(err)
})
.finally(() => {
setFollowup("sending", sessionID, (value) => (value === id ? undefined : value))
})
} }
const editFollowup = (id: string) => { const editFollowup = (id: string) => {
const sessionID = params.id const sessionID = params.id
if (!sessionID) return if (!sessionID) return
if (followup.sending[sessionID]) return if (followupBusy(sessionID)) return
const item = queuedFollowups().find((entry) => entry.id === id) const item = queuedFollowups().find((entry) => entry.id === id)
if (!item) return if (!item) return
@@ -1475,6 +1479,74 @@ export default function Page() {
const halt = (sessionID: string) => const halt = (sessionID: string) =>
busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve() busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve()
const revertMutation = useMutation(() => ({
mutationFn: async (input: { sessionID: string; messageID: string }) => {
const prev = prompt.current().slice()
const last = info()?.revert
const value = draft(input.messageID)
batch(() => {
roll(input.sessionID, { messageID: input.messageID })
prompt.set(value)
})
await halt(input.sessionID)
.then(() => sdk.client.session.revert(input))
.then((result) => {
if (result.data) merge(result.data)
})
.catch((err) => {
batch(() => {
roll(input.sessionID, last)
prompt.set(prev)
})
fail(err)
})
},
}))
const restoreMutation = useMutation(() => ({
mutationFn: async (id: string) => {
const sessionID = params.id
if (!sessionID) return
const next = userMessages().find((item) => item.id > id)
const prev = prompt.current().slice()
const last = info()?.revert
batch(() => {
roll(sessionID, next ? { messageID: next.id } : undefined)
if (next) {
prompt.set(draft(next.id))
return
}
prompt.reset()
})
const task = !next
? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID }))
: halt(sessionID).then(() =>
sdk.client.session.revert({
sessionID,
messageID: next.id,
}),
)
await task
.then((result) => {
if (result.data) merge(result.data)
})
.catch((err) => {
batch(() => {
roll(sessionID, last)
prompt.set(prev)
})
fail(err)
})
},
}))
const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending)
const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined))
const fork = (input: { sessionID: string; messageID: string }) => { const fork = (input: { sessionID: string; messageID: string }) => {
const value = draft(input.messageID) const value = draft(input.messageID)
const dir = base64Encode(sdk.directory) const dir = base64Encode(sdk.directory)
@@ -1496,77 +1568,13 @@ export default function Page() {
} }
const revert = (input: { sessionID: string; messageID: string }) => { const revert = (input: { sessionID: string; messageID: string }) => {
if (ui.reverting || ui.restoring) return if (reverting()) return
const prev = prompt.current().slice() return revertMutation.mutateAsync(input)
const last = info()?.revert
const value = draft(input.messageID)
batch(() => {
setUi("reverting", true)
roll(input.sessionID, { messageID: input.messageID })
prompt.set(value)
})
return halt(input.sessionID)
.then(() => sdk.client.session.revert(input))
.then((result) => {
if (result.data) merge(result.data)
})
.catch((err) => {
batch(() => {
roll(input.sessionID, last)
prompt.set(prev)
})
fail(err)
})
.finally(() => {
setUi("reverting", false)
})
} }
const restore = (id: string) => { const restore = (id: string) => {
const sessionID = params.id if (!params.id || reverting()) return
if (!sessionID || ui.restoring || ui.reverting) return return restoreMutation.mutateAsync(id)
const next = userMessages().find((item) => item.id > id)
const prev = prompt.current().slice()
const last = info()?.revert
batch(() => {
setUi("restoring", id)
setUi("reverting", true)
roll(sessionID, next ? { messageID: next.id } : undefined)
if (next) {
prompt.set(draft(next.id))
return
}
prompt.reset()
})
const task = !next
? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID }))
: halt(sessionID).then(() =>
sdk.client.session.revert({
sessionID,
messageID: next.id,
}),
)
return task
.then((result) => {
if (result.data) merge(result.data)
})
.catch((err) => {
batch(() => {
roll(sessionID, last)
prompt.set(prev)
})
fail(err)
})
.finally(() => {
batch(() => {
setUi("restoring", (value) => (value === id ? undefined : value))
setUi("reverting", false)
})
})
} }
const rolled = createMemo(() => { const rolled = createMemo(() => {
@@ -1585,7 +1593,7 @@ export default function Page() {
const item = queuedFollowups()[0] const item = queuedFollowups()[0]
if (!item) return if (!item) return
if (followup.sending[sessionID]) return if (followupBusy(sessionID)) return
if (followup.failed[sessionID] === item.id) return if (followup.failed[sessionID] === item.id) return
if (followup.paused[sessionID]) return if (followup.paused[sessionID]) return
if (composer.blocked()) return if (composer.blocked()) return
@@ -1780,8 +1788,8 @@ export default function Page() {
rolled().length > 0 rolled().length > 0
? { ? {
items: rolled(), items: rolled(),
restoring: ui.restoring, restoring: restoring(),
disabled: ui.reverting, disabled: reverting(),
onRestore: restore, onRestore: restore,
} }
: undefined : undefined
@@ -1,5 +1,6 @@
import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js" import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useMutation } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/ui/dock-prompt" import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
@@ -24,7 +25,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
custom: cached?.custom ?? ([] as string[]), custom: cached?.custom ?? ([] as string[]),
customOn: cached?.customOn ?? ([] as boolean[]), customOn: cached?.customOn ?? ([] as boolean[]),
editing: false, editing: false,
sending: false,
}) })
let root: HTMLDivElement | undefined let root: HTMLDivElement | undefined
@@ -126,36 +126,40 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
showToast({ title: language.t("common.requestFailed"), description: message }) showToast({ title: language.t("common.requestFailed"), description: message })
} }
const reply = async (answers: QuestionAnswer[]) => { const replyMutation = useMutation(() => ({
if (store.sending) return mutationFn: (answers: QuestionAnswer[]) => sdk.client.question.reply({ requestID: props.request.id, answers }),
onMutate: () => {
props.onSubmit() props.onSubmit()
setStore("sending", true) },
try { onSuccess: () => {
await sdk.client.question.reply({ requestID: props.request.id, answers })
replied = true replied = true
cache.delete(props.request.id) cache.delete(props.request.id)
} catch (err) { },
fail(err) onError: fail,
} finally { }))
setStore("sending", false)
} const rejectMutation = useMutation(() => ({
mutationFn: () => sdk.client.question.reject({ requestID: props.request.id }),
onMutate: () => {
props.onSubmit()
},
onSuccess: () => {
replied = true
cache.delete(props.request.id)
},
onError: fail,
}))
const sending = createMemo(() => replyMutation.isPending || rejectMutation.isPending)
const reply = async (answers: QuestionAnswer[]) => {
if (sending()) return
await replyMutation.mutateAsync(answers)
} }
const reject = async () => { const reject = async () => {
if (store.sending) return if (sending()) return
await rejectMutation.mutateAsync()
props.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 {
setStore("sending", false)
}
} }
const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? [])) const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
@@ -175,7 +179,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
} }
const customToggle = () => { const customToggle = () => {
if (store.sending) return if (sending()) return
if (!multi()) { if (!multi()) {
setStore("customOn", store.tab, true) setStore("customOn", store.tab, true)
@@ -198,14 +202,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
} }
const customOpen = () => { const customOpen = () => {
if (store.sending) return if (sending()) return
if (!on()) setStore("customOn", store.tab, true) if (!on()) setStore("customOn", store.tab, true)
setStore("editing", true) setStore("editing", true)
customUpdate(input(), true) customUpdate(input(), true)
} }
const selectOption = (optIndex: number) => { const selectOption = (optIndex: number) => {
if (store.sending) return if (sending()) return
if (optIndex === options().length) { if (optIndex === options().length) {
customOpen() customOpen()
@@ -227,7 +231,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
} }
const next = () => { const next = () => {
if (store.sending) return if (sending()) return
if (store.editing) commitCustom() if (store.editing) commitCustom()
if (store.tab >= total() - 1) { if (store.tab >= total() - 1) {
@@ -240,14 +244,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
} }
const back = () => { const back = () => {
if (store.sending) return if (sending()) return
if (store.tab <= 0) return if (store.tab <= 0) return
setStore("tab", store.tab - 1) setStore("tab", store.tab - 1)
setStore("editing", false) setStore("editing", false)
} }
const jump = (tab: number) => { const jump = (tab: number) => {
if (store.sending) return if (sending()) return
setStore("tab", tab) setStore("tab", tab)
setStore("editing", false) setStore("editing", false)
} }
@@ -270,7 +274,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
(store.answers[i()]?.length ?? 0) > 0 || (store.answers[i()]?.length ?? 0) > 0 ||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0) (store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
} }
disabled={store.sending} disabled={sending()}
onClick={() => jump(i())} onClick={() => jump(i())}
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`} aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
/> />
@@ -281,16 +285,16 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
} }
footer={ footer={
<> <>
<Button variant="ghost" size="large" disabled={store.sending} onClick={reject}> <Button variant="ghost" size="large" disabled={sending()} onClick={reject}>
{language.t("ui.common.dismiss")} {language.t("ui.common.dismiss")}
</Button> </Button>
<div data-slot="question-footer-actions"> <div data-slot="question-footer-actions">
<Show when={store.tab > 0}> <Show when={store.tab > 0}>
<Button variant="secondary" size="large" disabled={store.sending} onClick={back}> <Button variant="secondary" size="large" disabled={sending()} onClick={back}>
{language.t("ui.common.back")} {language.t("ui.common.back")}
</Button> </Button>
</Show> </Show>
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={store.sending} onClick={next}> <Button variant={last() ? "primary" : "secondary"} size="large" disabled={sending()} onClick={next}>
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")} {last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
</Button> </Button>
</div> </div>
@@ -311,7 +315,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
data-picked={picked()} data-picked={picked()}
role={multi() ? "checkbox" : "radio"} role={multi() ? "checkbox" : "radio"}
aria-checked={picked()} aria-checked={picked()}
disabled={store.sending} disabled={sending()}
onClick={() => selectOption(i())} onClick={() => selectOption(i())}
> >
<span data-slot="question-option-check" aria-hidden="true"> <span data-slot="question-option-check" aria-hidden="true">
@@ -345,7 +349,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
data-picked={on()} data-picked={on()}
role={multi() ? "checkbox" : "radio"} role={multi() ? "checkbox" : "radio"}
aria-checked={on()} aria-checked={on()}
disabled={store.sending} disabled={sending()}
onClick={customOpen} onClick={customOpen}
> >
<span <span
@@ -377,7 +381,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
role={multi() ? "checkbox" : "radio"} role={multi() ? "checkbox" : "radio"}
aria-checked={on()} aria-checked={on()}
onMouseDown={(e) => { onMouseDown={(e) => {
if (store.sending) { if (sending()) {
e.preventDefault() e.preventDefault()
return return
} }
@@ -419,7 +423,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
placeholder={language.t("ui.question.custom.placeholder")} placeholder={language.t("ui.question.custom.placeholder")}
value={input()} value={input()}
rows={1} rows={1}
disabled={store.sending} disabled={sending()}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault() e.preventDefault()
@@ -217,17 +217,6 @@ export function FileTabContent(props: { tab: string }) {
onDelete={controls.remove} onDelete={controls.remove}
/> />
), ),
onDraftPopoverFocusOut: (e: FocusEvent) => {
const current = e.currentTarget as HTMLDivElement
const target = e.relatedTarget
if (target instanceof Node && current.contains(target)) return
setTimeout(() => {
if (!document.activeElement || !current.contains(document.activeElement)) {
setNote("commenting", null)
}
}, 0)
},
}) })
createEffect(() => { createEffect(() => {
@@ -426,7 +415,6 @@ export function FileTabContent(props: { tab: string }) {
commentsUi.onLineSelectionEnd(range) commentsUi.onLineSelectionEnd(range)
}} }}
search={search} search={search}
overflow="scroll"
class="select-text" class="select-text"
media={{ media={{
mode: "auto", mode: "auto",
@@ -1,6 +1,7 @@
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js" import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX, createSignal } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { useMutation } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { FileIcon } from "@opencode-ai/ui/file-icon" import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
@@ -29,6 +30,7 @@ import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { messageAgentColor } from "@/utils/agent" import { messageAgentColor } from "@/utils/agent"
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import { makeTimer } from "@solid-primitives/timer"
type MessageComment = { type MessageComment = {
path: string path: string
@@ -249,38 +251,21 @@ export function MessageTimeline(props: {
const working = createMemo(() => !!pending() || sessionStatus().type !== "idle") const working = createMemo(() => !!pending() || sessionStatus().type !== "idle")
const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent)) const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent))
const [slot, setSlot] = createStore({ const [timeoutDone, setTimeoutDone] = createSignal(true)
open: false,
show: false, const workingStatus = createMemo<"hidden" | "showing" | "hiding">((prev) => {
fade: false, if (working()) return "showing"
if (prev === "showing" || !timeoutDone()) return "hiding"
return "hidden"
}) })
let f: number | undefined createEffect(() => {
const clear = () => { if (workingStatus() !== "hiding") return
if (f !== undefined) window.clearTimeout(f)
f = undefined setTimeoutDone(false)
} makeTimer(() => setTimeoutDone(true), 260, setTimeout)
})
onCleanup(clear)
createEffect(
on(
working,
(on, prev) => {
clear()
if (on) {
setSlot({ open: true, show: true, fade: false })
return
}
if (prev) {
setSlot({ open: false, show: true, fade: true })
f = window.setTimeout(() => setSlot({ show: false, fade: false }), 260)
return
}
setSlot({ open: false, show: false, fade: false })
},
{ defer: true },
),
)
const activeMessageID = createMemo(() => { const activeMessageID = createMemo(() => {
const parentID = pending()?.parentID const parentID = pending()?.parentID
if (parentID) { if (parentID) {
@@ -321,7 +306,6 @@ export function MessageTimeline(props: {
const [title, setTitle] = createStore({ const [title, setTitle] = createStore({
draft: "", draft: "",
editing: false, editing: false,
saving: false,
menuOpen: false, menuOpen: false,
pendingRename: false, pendingRename: false,
pendingShare: false, pendingShare: false,
@@ -335,38 +319,6 @@ export function MessageTimeline(props: {
let more: HTMLButtonElement | undefined let more: HTMLButtonElement | undefined
const [req, setReq] = createStore({ share: false, unshare: false })
const shareSession = () => {
const id = sessionID()
if (!id || req.share) return
if (!shareEnabled()) return
setReq("share", true)
globalSDK.client.session
.share({ sessionID: id, directory: sdk.directory })
.catch((err: unknown) => {
console.error("Failed to share session", err)
})
.finally(() => {
setReq("share", false)
})
}
const unshareSession = () => {
const id = sessionID()
if (!id || req.unshare) return
if (!shareEnabled()) return
setReq("unshare", true)
globalSDK.client.session
.unshare({ sessionID: id, directory: sdk.directory })
.catch((err: unknown) => {
console.error("Failed to unshare session", err)
})
.finally(() => {
setReq("unshare", false)
})
}
const viewShare = () => { const viewShare = () => {
const url = shareUrl() const url = shareUrl()
if (!url) return if (!url) return
@@ -382,6 +334,54 @@ export function MessageTimeline(props: {
return language.t("common.requestFailed") return language.t("common.requestFailed")
} }
const shareMutation = useMutation(() => ({
mutationFn: (id: string) => globalSDK.client.session.share({ sessionID: id, directory: sdk.directory }),
onError: (err) => {
console.error("Failed to share session", err)
},
}))
const unshareMutation = useMutation(() => ({
mutationFn: (id: string) => globalSDK.client.session.unshare({ sessionID: id, directory: sdk.directory }),
onError: (err) => {
console.error("Failed to unshare session", err)
},
}))
const titleMutation = useMutation(() => ({
mutationFn: (input: { id: string; title: string }) =>
sdk.client.session.update({ sessionID: input.id, title: input.title }),
onSuccess: (_, input) => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === input.id)
if (index !== -1) draft.session[index].title = input.title
}),
)
setTitle("editing", false)
},
onError: (err) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
},
}))
const shareSession = () => {
const id = sessionID()
if (!id || shareMutation.isPending) return
if (!shareEnabled()) return
shareMutation.mutate(id)
}
const unshareSession = () => {
const id = sessionID()
if (!id || unshareMutation.isPending) return
if (!shareEnabled()) return
unshareMutation.mutate(id)
}
createEffect( createEffect(
on( on(
sessionKey, sessionKey,
@@ -389,7 +389,6 @@ export function MessageTimeline(props: {
setTitle({ setTitle({
draft: "", draft: "",
editing: false, editing: false,
saving: false,
menuOpen: false, menuOpen: false,
pendingRename: false, pendingRename: false,
pendingShare: false, pendingShare: false,
@@ -408,40 +407,22 @@ export function MessageTimeline(props: {
} }
const closeTitleEditor = () => { const closeTitleEditor = () => {
if (title.saving) return if (titleMutation.isPending) return
setTitle({ editing: false, saving: false }) setTitle("editing", false)
} }
const saveTitleEditor = async () => { const saveTitleEditor = () => {
const id = sessionID() const id = sessionID()
if (!id) return if (!id) return
if (title.saving) return if (titleMutation.isPending) return
const next = title.draft.trim() const next = title.draft.trim()
if (!next || next === (titleValue() ?? "")) { if (!next || next === (titleValue() ?? "")) {
setTitle({ editing: false, saving: false }) setTitle("editing", false)
return return
} }
setTitle("saving", true) titleMutation.mutate({ id, title: next })
await sdk.client.session
.update({ sessionID: id, title: next })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === id)
if (index !== -1) draft.session[index].title = next
}),
)
setTitle({ editing: false, saving: false })
})
.catch((err) => {
setTitle("saving", false)
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
} }
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => { const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
@@ -679,17 +660,15 @@ export function MessageTimeline(props: {
<div <div
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]" class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{ style={{
width: slot.open ? "16px" : "0px", width: working() ? "16px" : "0px",
"margin-right": slot.open ? "8px" : "0px", "margin-right": working() ? "8px" : "0px",
}} }}
aria-hidden="true" aria-hidden="true"
> >
<Show when={slot.show}> <Show when={workingStatus() !== "hidden"}>
<div <div
class="transition-opacity duration-200 ease-out" class="transition-opacity duration-200 ease-out"
classList={{ classList={{ "opacity-0": workingStatus() === "hiding" }}
"opacity-0": slot.fade,
}}
> >
<Spinner class="size-4" style={{ color: tint() ?? "var(--icon-interactive-base)" }} /> <Spinner class="size-4" style={{ color: tint() ?? "var(--icon-interactive-base)" }} />
</div> </div>
@@ -712,7 +691,7 @@ export function MessageTimeline(props: {
titleRef = el titleRef = el
}} }}
value={title.draft} value={title.draft}
disabled={title.saving} disabled={titleMutation.isPending}
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]" class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }} style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
onInput={(event) => setTitle("draft", event.currentTarget.value)} onInput={(event) => setTitle("draft", event.currentTarget.value)}
@@ -863,9 +842,9 @@ export function MessageTimeline(props: {
variant="primary" variant="primary"
class="w-full" class="w-full"
onClick={shareSession} onClick={shareSession}
disabled={req.share} disabled={shareMutation.isPending}
> >
{req.share {shareMutation.isPending
? language.t("session.share.action.publishing") ? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")} : language.t("session.share.action.publish")}
</Button> </Button>
@@ -886,9 +865,9 @@ export function MessageTimeline(props: {
variant="secondary" variant="secondary"
class="w-full shadow-none border border-border-weak-base" class="w-full shadow-none border border-border-weak-base"
onClick={unshareSession} onClick={unshareSession}
disabled={req.unshare} disabled={unshareMutation.isPending}
> >
{req.unshare {unshareMutation.isPending
? language.t("session.share.action.unpublishing") ? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")} : language.t("session.share.action.unpublish")}
</Button> </Button>
@@ -897,7 +876,7 @@ export function MessageTimeline(props: {
variant="primary" variant="primary"
class="w-full" class="w-full"
onClick={viewShare} onClick={viewShare}
disabled={req.unshare} disabled={unshareMutation.isPending}
> >
{language.t("session.share.action.view")} {language.t("session.share.action.view")}
</Button> </Button>
@@ -915,7 +894,6 @@ export function MessageTimeline(props: {
</div> </div>
</div> </div>
</Show> </Show>
<div <div
role="log" role="log"
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]" class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
@@ -438,12 +438,10 @@ export function SessionSidePanel(props: {
size={layout.fileTree.width()} size={layout.fileTree.width()}
min={200} min={200}
max={480} max={480}
collapseThreshold={160}
onResize={(width) => { onResize={(width) => {
props.size.touch() props.size.touch()
layout.fileTree.resize(width) layout.fileTree.resize(width)
}} }}
onCollapse={layout.fileTree.close}
/> />
</div> </div>
</Show> </Show>
@@ -280,21 +280,24 @@ export function TerminalPanel() {
</Tabs> </Tabs>
<div class="flex-1 min-h-0 relative"> <div class="flex-1 min-h-0 relative">
<Show when={terminal.active()} keyed> <Show when={terminal.active()} keyed>
{(id) => ( {(id) => {
<Show when={all().find((pty) => pty.id === id)}> const ops = terminal.bind()
{(pty) => ( return (
<div id={`terminal-wrapper-${id}`} class="absolute inset-0"> <Show when={all().find((pty) => pty.id === id)}>
<Terminal {(pty) => (
pty={pty()} <div id={`terminal-wrapper-${id}`} class="absolute inset-0">
autoFocus={opened()} <Terminal
onConnect={() => terminal.trim(id)} pty={pty()}
onCleanup={terminal.update} autoFocus={opened()}
onConnectError={() => terminal.clone(id)} onConnect={() => ops.trim(id)}
/> onCleanup={ops.update}
</div> onConnectError={() => ops.clone(id)}
)} />
</Show> </div>
)} )}
</Show>
)
}}
</Show> </Show>
</div> </div>
</div> </div>
@@ -255,7 +255,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
id: "file.open", id: "file.open",
title: language.t("command.file.open"), title: language.t("command.file.open"),
description: language.t("palette.search.placeholder"), description: language.t("palette.search.placeholder"),
keybind: "mod+p", keybind: "mod+k,mod+p",
slash: "open", slash: "open",
onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />), onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />),
}), }),
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, test } from "bun:test"
import type { Part } from "@opencode-ai/sdk/v2"
import { extractPromptFromParts } from "./prompt"
describe("extractPromptFromParts", () => {
test("restores multiple uploaded attachments", () => {
const parts = [
{
id: "text_1",
type: "text",
text: "check these",
sessionID: "ses_1",
messageID: "msg_1",
},
{
id: "file_1",
type: "file",
mime: "image/png",
url: "data:image/png;base64,AAA",
filename: "a.png",
sessionID: "ses_1",
messageID: "msg_1",
},
{
id: "file_2",
type: "file",
mime: "application/pdf",
url: "data:application/pdf;base64,BBB",
filename: "b.pdf",
sessionID: "ses_1",
messageID: "msg_1",
},
] satisfies Part[]
const result = extractPromptFromParts(parts)
expect(result).toHaveLength(3)
expect(result[0]).toMatchObject({ type: "text", content: "check these" })
expect(result.slice(1)).toMatchObject([
{ type: "image", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
{ type: "image", filename: "b.pdf", mime: "application/pdf", dataUrl: "data:application/pdf;base64,BBB" },
])
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.2.27", "version": "1.3.0",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
@@ -76,6 +76,19 @@ export function IconAlipay(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
) )
} }
export function IconUpi(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} viewBox="10 16 100 28" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M95.678 42.9 110 29.835l-6.784-13.516Z" />
<path d="M90.854 42.9 105.176 29.835l-6.784-13.516Z" />
<path
d="M22.41 16.47 16.38 37.945l21.407.15 5.88-21.625h5.427l-7.05 25.14c-.27.96-1.298 1.74-2.295 1.74H12.31c-1.664 0-2.65-1.3-2.2-2.9l6.724-23.98Zm66.182-.15h5.427l-7.538 27.03h-5.58ZM49.698 27.582l27.136-.15 1.81-5.707H51.054l1.658-5.256 29.4-.27c1.83-.017 2.92 1.4 2.438 3.167L81.78 29.49c-.483 1.766-2.36 3.197-4.19 3.197H53.316L50.454 43.8h-5.28Z"
fill-rule="evenodd"
/>
</svg>
)
}
export function IconWechat(props: JSX.SvgSVGAttributes<SVGSVGElement>) { export function IconWechat(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return ( return (
<svg {...props} viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> <svg {...props} viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
@@ -62,5 +62,6 @@
font-size: var(--font-size-lg); font-size: var(--font-size-lg);
font-weight: 600; font-weight: 600;
color: var(--color-text); color: var(--color-text);
text-align: center;
} }
} }
+2
View File
@@ -644,6 +644,8 @@ export const dict = {
"تم تصميم الخطة بشكل أساسي للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة للحصول على وصول عالمي مستقر. قد تتغير الأسعار وحدود الاستخدام بناءً على تعلمنا من الاستخدام المبكر والملاحظات.", "تم تصميم الخطة بشكل أساسي للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة للحصول على وصول عالمي مستقر. قد تتغير الأسعار وحدود الاستخدام بناءً على تعلمنا من الاستخدام المبكر والملاحظات.",
"workspace.lite.promo.subscribe": "الاشتراك في Go", "workspace.lite.promo.subscribe": "الاشتراك في Go",
"workspace.lite.promo.subscribing": "جارٍ إعادة التوجيه...", "workspace.lite.promo.subscribing": "جارٍ إعادة التوجيه...",
"workspace.lite.promo.otherMethods": "طرق دفع أخرى",
"workspace.lite.promo.selectMethod": "اختر طريقة الدفع",
"download.title": "OpenCode | تنزيل", "download.title": "OpenCode | تنزيل",
"download.meta.description": "نزّل OpenCode لـ macOS، Windows، وLinux", "download.meta.description": "نزّل OpenCode لـ macOS، Windows، وLinux",
+2
View File
@@ -654,6 +654,8 @@ export const dict = {
"O plano é projetado principalmente para usuários internacionais, com modelos hospedados nos EUA, UE e Singapura para acesso global estável. Preços e limites de uso podem mudar conforme aprendemos com o uso inicial e feedback.", "O plano é projetado principalmente para usuários internacionais, com modelos hospedados nos EUA, UE e Singapura para acesso global estável. Preços e limites de uso podem mudar conforme aprendemos com o uso inicial e feedback.",
"workspace.lite.promo.subscribe": "Assinar Go", "workspace.lite.promo.subscribe": "Assinar Go",
"workspace.lite.promo.subscribing": "Redirecionando...", "workspace.lite.promo.subscribing": "Redirecionando...",
"workspace.lite.promo.otherMethods": "Outros métodos de pagamento",
"workspace.lite.promo.selectMethod": "Selecionar método de pagamento",
"download.title": "OpenCode | Baixar", "download.title": "OpenCode | Baixar",
"download.meta.description": "Baixe o OpenCode para macOS, Windows e Linux", "download.meta.description": "Baixe o OpenCode para macOS, Windows e Linux",
+2
View File
@@ -651,6 +651,8 @@ export const dict = {
"Planen er primært designet til internationale brugere, med modeller hostet i USA, EU og Singapore for stabil global adgang. Priser og forbrugsgrænser kan ændre sig, efterhånden som vi lærer af tidlig brug og feedback.", "Planen er primært designet til internationale brugere, med modeller hostet i USA, EU og Singapore for stabil global adgang. Priser og forbrugsgrænser kan ændre sig, efterhånden som vi lærer af tidlig brug og feedback.",
"workspace.lite.promo.subscribe": "Abonner på Go", "workspace.lite.promo.subscribe": "Abonner på Go",
"workspace.lite.promo.subscribing": "Omdirigerer...", "workspace.lite.promo.subscribing": "Omdirigerer...",
"workspace.lite.promo.otherMethods": "Andre betalingsmetoder",
"workspace.lite.promo.selectMethod": "Vælg betalingsmetode",
"download.title": "OpenCode | Download", "download.title": "OpenCode | Download",
"download.meta.description": "Download OpenCode til macOS, Windows og Linux", "download.meta.description": "Download OpenCode til macOS, Windows og Linux",
+2
View File
@@ -654,6 +654,8 @@ export const dict = {
"Der Plan wurde hauptsächlich für internationale Nutzer entwickelt, wobei die Modelle in den USA, der EU und Singapur gehostet werden, um einen stabilen weltweiten Zugriff zu gewährleisten. Preise und Nutzungslimits können sich ändern, während wir aus der frühen Nutzung und dem Feedback lernen.", "Der Plan wurde hauptsächlich für internationale Nutzer entwickelt, wobei die Modelle in den USA, der EU und Singapur gehostet werden, um einen stabilen weltweiten Zugriff zu gewährleisten. Preise und Nutzungslimits können sich ändern, während wir aus der frühen Nutzung und dem Feedback lernen.",
"workspace.lite.promo.subscribe": "Go abonnieren", "workspace.lite.promo.subscribe": "Go abonnieren",
"workspace.lite.promo.subscribing": "Leite weiter...", "workspace.lite.promo.subscribing": "Leite weiter...",
"workspace.lite.promo.otherMethods": "Andere Zahlungsmethoden",
"workspace.lite.promo.selectMethod": "Zahlungsmethode auswählen",
"download.title": "OpenCode | Download", "download.title": "OpenCode | Download",
"download.meta.description": "Lade OpenCode für macOS, Windows und Linux herunter", "download.meta.description": "Lade OpenCode für macOS, Windows und Linux herunter",
+2
View File
@@ -646,6 +646,8 @@ export const dict = {
"The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access. Pricing and usage limits may change as we learn from early usage and feedback.", "The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access. Pricing and usage limits may change as we learn from early usage and feedback.",
"workspace.lite.promo.subscribe": "Subscribe to Go", "workspace.lite.promo.subscribe": "Subscribe to Go",
"workspace.lite.promo.subscribing": "Redirecting...", "workspace.lite.promo.subscribing": "Redirecting...",
"workspace.lite.promo.otherMethods": "Other payment methods",
"workspace.lite.promo.selectMethod": "Select payment method",
"download.title": "OpenCode | Download", "download.title": "OpenCode | Download",
"download.meta.description": "Download OpenCode for macOS, Windows, and Linux", "download.meta.description": "Download OpenCode for macOS, Windows, and Linux",
+2
View File
@@ -654,6 +654,8 @@ export const dict = {
"El plan está diseñado principalmente para usuarios internacionales, con modelos alojados en EE. UU., la UE y Singapur para un acceso global estable. Los precios y los límites de uso pueden cambiar a medida que aprendemos del uso inicial y los comentarios.", "El plan está diseñado principalmente para usuarios internacionales, con modelos alojados en EE. UU., la UE y Singapur para un acceso global estable. Los precios y los límites de uso pueden cambiar a medida que aprendemos del uso inicial y los comentarios.",
"workspace.lite.promo.subscribe": "Suscribirse a Go", "workspace.lite.promo.subscribe": "Suscribirse a Go",
"workspace.lite.promo.subscribing": "Redirigiendo...", "workspace.lite.promo.subscribing": "Redirigiendo...",
"workspace.lite.promo.otherMethods": "Otros métodos de pago",
"workspace.lite.promo.selectMethod": "Seleccionar método de pago",
"download.title": "OpenCode | Descargar", "download.title": "OpenCode | Descargar",
"download.meta.description": "Descarga OpenCode para macOS, Windows y Linux", "download.meta.description": "Descarga OpenCode para macOS, Windows y Linux",
+2
View File
@@ -661,6 +661,8 @@ export const dict = {
"Le plan est conçu principalement pour les utilisateurs internationaux, avec des modèles hébergés aux États-Unis, dans l'UE et à Singapour pour un accès mondial stable. Les tarifs et les limites d'utilisation peuvent changer à mesure que nous apprenons des premières utilisations et des commentaires.", "Le plan est conçu principalement pour les utilisateurs internationaux, avec des modèles hébergés aux États-Unis, dans l'UE et à Singapour pour un accès mondial stable. Les tarifs et les limites d'utilisation peuvent changer à mesure que nous apprenons des premières utilisations et des commentaires.",
"workspace.lite.promo.subscribe": "S'abonner à Go", "workspace.lite.promo.subscribe": "S'abonner à Go",
"workspace.lite.promo.subscribing": "Redirection...", "workspace.lite.promo.subscribing": "Redirection...",
"workspace.lite.promo.otherMethods": "Autres méthodes de paiement",
"workspace.lite.promo.selectMethod": "Sélectionner la méthode de paiement",
"download.title": "OpenCode | Téléchargement", "download.title": "OpenCode | Téléchargement",
"download.meta.description": "Téléchargez OpenCode pour macOS, Windows et Linux", "download.meta.description": "Téléchargez OpenCode pour macOS, Windows et Linux",
+2
View File
@@ -652,6 +652,8 @@ export const dict = {
"Il piano è progettato principalmente per gli utenti internazionali, con modelli ospitati in US, EU e Singapore per un accesso globale stabile. I prezzi e i limiti di utilizzo potrebbero cambiare man mano che impariamo dall'utilizzo iniziale e dal feedback.", "Il piano è progettato principalmente per gli utenti internazionali, con modelli ospitati in US, EU e Singapore per un accesso globale stabile. I prezzi e i limiti di utilizzo potrebbero cambiare man mano che impariamo dall'utilizzo iniziale e dal feedback.",
"workspace.lite.promo.subscribe": "Abbonati a Go", "workspace.lite.promo.subscribe": "Abbonati a Go",
"workspace.lite.promo.subscribing": "Reindirizzamento...", "workspace.lite.promo.subscribing": "Reindirizzamento...",
"workspace.lite.promo.otherMethods": "Altri metodi di pagamento",
"workspace.lite.promo.selectMethod": "Seleziona metodo di pagamento",
"download.title": "OpenCode | Download", "download.title": "OpenCode | Download",
"download.meta.description": "Scarica OpenCode per macOS, Windows e Linux", "download.meta.description": "Scarica OpenCode per macOS, Windows e Linux",
+2
View File
@@ -653,6 +653,8 @@ export const dict = {
"このプランは主にグローバルユーザー向けに設計されており、米国、EU、シンガポールでホストされたモデルにより安定したグローバルアクセスを提供します。料金と利用制限は、初期の利用状況やフィードバックに基づいて変更される可能性があります。", "このプランは主にグローバルユーザー向けに設計されており、米国、EU、シンガポールでホストされたモデルにより安定したグローバルアクセスを提供します。料金と利用制限は、初期の利用状況やフィードバックに基づいて変更される可能性があります。",
"workspace.lite.promo.subscribe": "Goを購読する", "workspace.lite.promo.subscribe": "Goを購読する",
"workspace.lite.promo.subscribing": "リダイレクト中...", "workspace.lite.promo.subscribing": "リダイレクト中...",
"workspace.lite.promo.otherMethods": "その他の支払い方法",
"workspace.lite.promo.selectMethod": "支払い方法を選択",
"download.title": "OpenCode | ダウンロード", "download.title": "OpenCode | ダウンロード",
"download.meta.description": "OpenCode を macOS、Windows、Linux 向けにダウンロード", "download.meta.description": "OpenCode を macOS、Windows、Linux 向けにダウンロード",
+2
View File
@@ -645,6 +645,8 @@ export const dict = {
"이 플랜은 주로 글로벌 사용자를 위해 설계되었으며, 안정적인 글로벌 액세스를 위해 미국, EU 및 싱가포르에 모델이 호스팅되어 있습니다. 가격 및 사용 한도는 초기 사용을 통해 학습하고 피드백을 수집함에 따라 변경될 수 있습니다.", "이 플랜은 주로 글로벌 사용자를 위해 설계되었으며, 안정적인 글로벌 액세스를 위해 미국, EU 및 싱가포르에 모델이 호스팅되어 있습니다. 가격 및 사용 한도는 초기 사용을 통해 학습하고 피드백을 수집함에 따라 변경될 수 있습니다.",
"workspace.lite.promo.subscribe": "Go 구독하기", "workspace.lite.promo.subscribe": "Go 구독하기",
"workspace.lite.promo.subscribing": "리디렉션 중...", "workspace.lite.promo.subscribing": "리디렉션 중...",
"workspace.lite.promo.otherMethods": "기타 결제 수단",
"workspace.lite.promo.selectMethod": "결제 수단 선택",
"download.title": "OpenCode | 다운로드", "download.title": "OpenCode | 다운로드",
"download.meta.description": "macOS, Windows, Linux용 OpenCode 다운로드", "download.meta.description": "macOS, Windows, Linux용 OpenCode 다운로드",
+2
View File
@@ -651,6 +651,8 @@ export const dict = {
"Planen er primært designet for internasjonale brukere, med modeller driftet i USA, EU og Singapore for stabil global tilgang. Priser og bruksgrenser kan endres etter hvert som vi lærer fra tidlig bruk og tilbakemeldinger.", "Planen er primært designet for internasjonale brukere, med modeller driftet i USA, EU og Singapore for stabil global tilgang. Priser og bruksgrenser kan endres etter hvert som vi lærer fra tidlig bruk og tilbakemeldinger.",
"workspace.lite.promo.subscribe": "Abonner på Go", "workspace.lite.promo.subscribe": "Abonner på Go",
"workspace.lite.promo.subscribing": "Omdirigerer...", "workspace.lite.promo.subscribing": "Omdirigerer...",
"workspace.lite.promo.otherMethods": "Andre betalingsmetoder",
"workspace.lite.promo.selectMethod": "Velg betalingsmetode",
"download.title": "OpenCode | Last ned", "download.title": "OpenCode | Last ned",
"download.meta.description": "Last ned OpenCode for macOS, Windows og Linux", "download.meta.description": "Last ned OpenCode for macOS, Windows og Linux",
+2
View File
@@ -652,6 +652,8 @@ export const dict = {
"Plan został zaprojektowany głównie dla użytkowników międzynarodowych, z modelami hostowanymi w USA, UE i Singapurze, aby zapewnić stabilny globalny dostęp. Ceny i limity użycia mogą ulec zmianie w miarę analizy wczesnego użycia i zbierania opinii.", "Plan został zaprojektowany głównie dla użytkowników międzynarodowych, z modelami hostowanymi w USA, UE i Singapurze, aby zapewnić stabilny globalny dostęp. Ceny i limity użycia mogą ulec zmianie w miarę analizy wczesnego użycia i zbierania opinii.",
"workspace.lite.promo.subscribe": "Subskrybuj Go", "workspace.lite.promo.subscribe": "Subskrybuj Go",
"workspace.lite.promo.subscribing": "Przekierowywanie...", "workspace.lite.promo.subscribing": "Przekierowywanie...",
"workspace.lite.promo.otherMethods": "Inne metody płatności",
"workspace.lite.promo.selectMethod": "Wybierz metodę płatności",
"download.title": "OpenCode | Pobierz", "download.title": "OpenCode | Pobierz",
"download.meta.description": "Pobierz OpenCode na macOS, Windows i Linux", "download.meta.description": "Pobierz OpenCode na macOS, Windows i Linux",

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