Compare commits

..
Author SHA1 Message Date
Dax Raad 3eeeec359a chore: extract misc fixes into #18328 2026-03-19 22:13:38 -04:00
Dax Raad 65e786258a chore: extract OAuth changes into #18327 2026-03-19 21:48:23 -04:00
Dax Raad cbc40a5981 chore: extract node entry point into #18324 2026-03-19 21:30:35 -04:00
Dax Raad b9b210a864 Merge branch 'dev' into opencode-2-0 2026-03-19 21:22:28 -04:00
Dax Raad d473b7e971 chore: extract which/global changes into #18320 2026-03-19 21:22:06 -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 Raad f5783c4313 chore: extract portable process changes into #18318 2026-03-19 21:15:31 -04:00
Dax Raad 9439a5647e chore: revert drizzle upgrade (extracted to sqlite PR) 2026-03-19 21:10:53 -04:00
Dax Raad 2bfe81ee5c chore: extract SQLite abstraction into separate PR (#refactor/sqlite-abstraction) 2026-03-19 21:02:58 -04:00
Dax Raad fcf1bb010c chore: update lockfile and package.json 2026-03-19 20:53:04 -04:00
Dax Raad 08b6d9c6dc sync 2026-03-19 20:48:44 -04:00
Dax Raad 0293a8bb80 chore: revert changes overlapping with #18308 2026-03-19 20:48:23 -04:00
Dax Raad 850dbb93eb Merge remote-tracking branch 'origin/dev' into opencode-2-0 2026-03-19 19:33:19 -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 Raad 48e867ee20 Merge remote-tracking branch 'origin/dev' into opencode-2-0
# Conflicts:
#	.opencode/tool/github-pr-search.ts
#	.opencode/tool/github-triage.ts
2026-03-19 19:03:48 -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 Raad b5ebc541b9 Merge remote-tracking branch 'origin/dev' into opencode-2-0 2026-03-19 18:52:18 -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
Dax Raad bd7a4cec90 sync 2026-03-19 17:53:35 -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
Dax Raad 63af295a17 Merge origin/dev into opencode-2-0 2026-03-19 16:07:13 -04:00
opencode-agent[bot] 4f21757e0d chore: generate 2026-03-19 20:05:33 +00:00
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 AllanandShoubhit Dash 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
OpeOginniandBrendan Allan bd4527b4f2 fix(desktop): remote server switching (#17214)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-03-19 13:32:11 +00:00
Andrew MaguireandShoubhit Dash 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
Dax Raad 04954a9620 Merge remote-tracking branch 'origin/opencode-2-0' into opencode-2-0 2026-03-11 14:32:38 -04:00
Dax Raad fb63fd79a3 cleanup 2026-03-11 14:29:35 -04:00
Dax Raad 2e04b66eab sync 2026-03-11 14:29:03 -04:00
Dax Raad f0b7c8c374 refactor(npm): inline pkgPath and lockPath variables 2026-03-11 14:29:03 -04:00
Dax Raad be6f59035a unbreak 2026-03-11 14:29:03 -04:00
Dax Raad 27ab51f490 sync 2026-03-11 14:29:03 -04:00
Dax Raad bca723e8fe core: enable running in non-Bun environments by using standard Node.js APIs for OAuth servers and retry logic 2026-03-11 14:29:03 -04:00
Dax Raad 1ac39718d8 sync 2026-03-11 14:29:03 -04:00
Dax Raad 190319fb56 core: cleaner error output and more flexible custom tool directories
- Removed debug console.log when dependency installation fails so users see clean warning messages instead of raw error dumps
- Fixed database connection cleanup to prevent resource leaks between sessions
- Added support for loading custom tools from both .opencode/tool (singular) and .opencode/tools (plural) directories, matching common naming conventions
2026-03-11 14:29:03 -04:00
Dax Raad 3154f0a61c core: return structured server info with stop method from workspace server
- Enables graceful server shutdown for workspace management
- Removes unsupported serverUrl getter that threw errors in plugin context
2026-03-11 14:29:03 -04:00
Dax Raad 0b686b8178 core: remove shell execution and server URL from plugin API
Plugins no longer receive shell access or server URL to prevent unauthorized
execution and limit plugin sandbox surface area.
2026-03-11 14:29:03 -04:00
Dax Raad 4cba56171b sync 2026-03-11 14:29:03 -04:00
Dax Raad 66342acd31 core: bundle database migrations into node build and auto-start server on port 1338 2026-03-11 14:29:03 -04:00
Dax Raad 88dae67549 refactor(server): replace Bun serve with Hono node adapters 2026-03-11 14:29:03 -04:00
Dax Raad 0ec42582f3 core: add Node.js runtime support
Enable running opencode on Node.js by adding platform-specific database adapters and replacing Bun-specific shell execution with cross-platform Process utility.
2026-03-11 14:29:02 -04:00
Luke Parker 4f82248a68 fix: work around Bun/Windows UV_FS_O_FILEMAP incompatibility in tar (#16853) 2026-03-11 14:29:02 -04:00
Dax Raad 5e069aab97 tui: fix Windows plugin loading by using direct paths instead of file URLs 2026-03-11 14:29:02 -04:00
Dax Raad 5325b2ec99 core: fix custom tool loading to properly resolve module paths 2026-03-11 14:29:02 -04:00
Dax Raad 2a98920922 sync 2026-03-11 14:29:02 -04:00
Dax Raad 5ea92ea6cb sync 2026-03-11 14:29:02 -04:00
Dax Raad a18528a7ee sync 2026-03-11 14:29:02 -04:00
Dax Raad ced125a974 core: log npm install errors to console for debugging dependency failures 2026-03-11 14:29:02 -04:00
Dax Raad 655fe20beb sync 2026-03-11 14:29:02 -04:00
Dax Raad dd0c258e23 core: fix CLI tools from npm packages not being accessible after install on Windows 2026-03-11 14:29:02 -04:00
Dax Raad 791e27d289 sync 2026-03-11 14:29:02 -04:00
Dax Raad fac0aec69f tui: export sessions using consistent Filesystem API instead of Bun.write 2026-03-11 14:29:02 -04:00
Dax Raad ca26e639f6 core: fix npm dependency installation on Windows CI by disabling bin links when symlink permissions are restricted 2026-03-11 14:29:02 -04:00
Dax Raad 0b5d54f2cb core: enable npm bin links on non-Windows platforms to allow plugin executables to work while keeping them disabled on Windows CI where symlink permissions are restricted 2026-03-11 14:29:02 -04:00
Dax Raad 1b408cf06b core: fix dependency installation failures behind corporate proxies or in CI by disabling Bun cache when network interception is detected 2026-03-11 14:29:02 -04:00
Dax Raad 8e102d19ed core: disable npm bin links to fix package installation in sandboxed environments 2026-03-11 14:29:02 -04:00
Dax Raad 721b2406e9 core: dynamically resolve formatter executable paths at runtime
Formatters now determine their executable location when enabled rather than
using hardcoded paths. This ensures formatters work correctly regardless
of how the tool was installed or where executables are located on the system.
2026-03-11 14:29:01 -04:00
Dax Raad 4a6a18cd79 sync 2026-03-11 14:28:37 -04:00
Daxandgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> c10b5880cc Update packages/opencode/src/util/which.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-11 14:28:37 -04:00
Daxandgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> e6bf83084c Update packages/opencode/src/npm/index.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-11 14:28:37 -04:00
Dax Raad 6722ee22ee sync 2026-03-11 14:28:36 -04:00
Dax Raad 870a5731ac refactor: lsp server and core improvements 2026-03-11 14:28:24 -04:00
Luke Parker 7910ce5d36 fix: guard Npm.which() against infinite loop when .bin is empty (#16961) 2026-03-11 09:34:58 -04:00
Dax 6ad171dba9 Merge branch 'dev' into opencode-2-0 2026-03-10 17:20:19 -04:00
Dax Raad cb5674edc7 sync 2026-03-10 17:00:15 -04:00
Dax Raad b99de4118e refactor(npm): inline pkgPath and lockPath variables 2026-03-10 16:59:01 -04:00
Dax Raad 040700dbc4 unbreak 2026-03-10 16:07:25 -04:00
Dax Raad 4d5da9697e sync 2026-03-10 16:02:40 -04:00
Dax Raad a28648f530 core: enable running in non-Bun environments by using standard Node.js APIs for OAuth servers and retry logic 2026-03-10 16:02:40 -04:00
Dax Raad 4d81e2d4d9 sync 2026-03-10 16:02:40 -04:00
Dax Raad 21e72cbf42 core: cleaner error output and more flexible custom tool directories
- Removed debug console.log when dependency installation fails so users see clean warning messages instead of raw error dumps
- Fixed database connection cleanup to prevent resource leaks between sessions
- Added support for loading custom tools from both .opencode/tool (singular) and .opencode/tools (plural) directories, matching common naming conventions
2026-03-10 16:02:40 -04:00
Dax Raad 5f277d1e62 core: return structured server info with stop method from workspace server
- Enables graceful server shutdown for workspace management
- Removes unsupported serverUrl getter that threw errors in plugin context
2026-03-10 16:02:40 -04:00
Dax Raad d67e877e28 core: remove shell execution and server URL from plugin API
Plugins no longer receive shell access or server URL to prevent unauthorized
execution and limit plugin sandbox surface area.
2026-03-10 16:02:40 -04:00
Dax Raad d4e51e04b3 sync 2026-03-10 16:02:40 -04:00
Dax Raad 070c1679e4 core: bundle database migrations into node build and auto-start server on port 1338 2026-03-10 16:02:40 -04:00
Dax Raad 406d216cd2 refactor(server): replace Bun serve with Hono node adapters 2026-03-10 16:02:40 -04:00
Dax Raad 5dc8b4ef29 core: add Node.js runtime support
Enable running opencode on Node.js by adding platform-specific database adapters and replacing Bun-specific shell execution with cross-platform Process utility.
2026-03-10 16:02:39 -04:00
Luke Parker 2f41d89163 fix: work around Bun/Windows UV_FS_O_FILEMAP incompatibility in tar (#16853) 2026-03-10 16:02:39 -04:00
Dax Raad b2eae867a1 tui: fix Windows plugin loading by using direct paths instead of file URLs 2026-03-10 16:02:39 -04:00
Dax Raad 3c2fda4d91 core: fix custom tool loading to properly resolve module paths 2026-03-10 16:02:39 -04:00
Dax Raad 2678ceb45e sync 2026-03-10 16:02:39 -04:00
Dax Raad 58a4cd00b6 sync 2026-03-10 16:02:39 -04:00
Dax Raad 0faa191b6d sync 2026-03-10 16:02:39 -04:00
Dax Raad 58cf092105 core: log npm install errors to console for debugging dependency failures 2026-03-10 16:02:39 -04:00
Dax Raad 0ff8bfe1d9 sync 2026-03-10 16:02:39 -04:00
Dax Raad ceb79c786a core: fix CLI tools from npm packages not being accessible after install on Windows 2026-03-10 16:02:39 -04:00
Dax Raad b1a15d559b sync 2026-03-10 16:02:39 -04:00
Dax Raad 124a8abf9b tui: export sessions using consistent Filesystem API instead of Bun.write 2026-03-10 16:02:39 -04:00
Dax Raad 85c2bb342b core: fix npm dependency installation on Windows CI by disabling bin links when symlink permissions are restricted 2026-03-10 16:02:39 -04:00
Dax Raad 4c57e39466 core: enable npm bin links on non-Windows platforms to allow plugin executables to work while keeping them disabled on Windows CI where symlink permissions are restricted 2026-03-10 16:02:39 -04:00
Dax Raad 0cdd4e4e16 core: fix dependency installation failures behind corporate proxies or in CI by disabling Bun cache when network interception is detected 2026-03-10 16:02:39 -04:00
Dax Raad a9b01be0c2 core: disable npm bin links to fix package installation in sandboxed environments 2026-03-10 16:02:39 -04:00
Dax Raad 528daf5490 core: dynamically resolve formatter executable paths at runtime
Formatters now determine their executable location when enabled rather than
using hardcoded paths. This ensures formatters work correctly regardless
of how the tool was installed or where executables are located on the system.
2026-03-10 16:02:39 -04:00
Dax Raad 0e176d3ac3 sync 2026-03-10 16:02:39 -04:00
Daxandgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> 27f359852e Update packages/opencode/src/util/which.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-10 16:02:39 -04:00
Daxandgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> 173128d431 Update packages/opencode/src/npm/index.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-10 16:02:39 -04:00
Dax Raad e8ee1e239f sync 2026-03-10 16:02:39 -04:00
Dax Raad 656fa191c1 refactor: lsp server and core improvements 2026-03-10 16:02:39 -04:00
127 changed files with 2005 additions and 1266 deletions
+4 -2
View File
@@ -1,4 +1,6 @@
plans/ node_modules
bun.lock plans
package.json package.json
bun.lock
.gitignore
package-lock.json 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.
+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.
+362 -312
View File
File diff suppressed because it is too large Load Diff
+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,
}, },
}) })
+2 -2
View File
@@ -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",
+25
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"
@@ -361,6 +362,30 @@ 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 () => {
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 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]
@@ -1,7 +1,15 @@
import { base64Decode } from "@opencode-ai/util/encode" import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test" import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions" import {
defocus,
createTestProject,
cleanupTestProject,
openSidebar,
sessionIDFromUrl,
waitDir,
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"
@@ -100,11 +108,8 @@ 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
// settle to the canonical workspace path on Windows, so interact with either and assert
// against the resolved workspace slug.
await waitSlug(page) await waitSlug(page)
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`)) await waitDir(page, space)
// Create a session by sending a prompt // Create a session by sending a prompt
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
@@ -132,6 +137,7 @@ test("switching back to a project opens the latest workspace session", async ({
await expect(rootButton).toBeVisible() await expect(rootButton).toBeVisible()
await rootButton.click() await rootButton.click()
await waitDir(page, space)
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created) await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created)
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`)) await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
}, },
@@ -1,18 +1,25 @@
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, 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
.poll( .poll(
async () => { async () => {
const item = page.locator(workspaceItemSelector(slug)).first() const row = page.locator(item(space)).first()
try { try {
await item.hover({ timeout: 500 }) await row.hover({ timeout: 500 })
return true return true
} catch { } catch {
return false return false
@@ -27,29 +34,30 @@ 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 })
return waitDir(page, space.directory)
}
async function createSessionFromWorkspace(
page: Page,
space: { slug: string; raw: string; directory: string },
text: string,
) {
const next = await openWorkspaceNewSession(page, space)
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible() await expect(prompt).toBeVisible()
@@ -60,13 +68,13 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text) await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
await prompt.press("Enter") await prompt.press("Enter")
await expect.poll(() => slugFromUrl(page.url())).toBe(next) await waitDir(page, next.directory)
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("") await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
const sessionID = sessionIDFromUrl(page.url()) const sessionID = sessionIDFromUrl(page.url())
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`) if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`)) await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
return { sessionID, slug: next } return { sessionID, slug: next.slug }
} }
async function sessionDirectory(directory: string, sessionID: string) { async function sessionDirectory(directory: string, sessionID: string) {
@@ -87,11 +95,11 @@ test("new sessions from sidebar workspace actions stay in selected workspace", a
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()}`) const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
trackSession(firstSession.sessionID, first.directory) trackSession(firstSession.sessionID, 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)
} }
@@ -1,7 +1,6 @@
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, waitSessionIdle, waitSlug } from "../actions"
import { import {
promptAgentSelector, promptAgentSelector,
promptModelSelector, promptModelSelector,
@@ -224,10 +223,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 expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`))
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,8 +255,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(?:[/?#]|$)`)) await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`))
await expect(page.locator(promptSelector)).toBeVisible() await expect(page.locator(promptSelector)).toBeVisible()
return currentDir(page) return currentDir(page)
} }
+31 -26
View File
@@ -46,21 +46,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>
) )
@@ -124,8 +116,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>
) )
} }
@@ -265,6 +259,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 +278,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>
) )
@@ -15,7 +15,6 @@ import { Link } from "@/components/link"
import { useLanguage } from "@/context/language" 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 { DialogSelectModel } from "./dialog-select-model" import { DialogSelectModel } from "./dialog-select-model"
import { DialogSelectProvider } from "./dialog-select-provider" import { DialogSelectProvider } from "./dialog-select-provider"
@@ -23,7 +22,6 @@ 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 +47,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 +76,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 +124,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 +134,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 +145,7 @@ export function DialogConnectProvider(props: { provider: string }) {
{ {
providerID: props.provider, providerID: props.provider,
method: index, method: index,
inputs,
}, },
{ throwOnError: true }, { throwOnError: true },
) )
@@ -163,6 +172,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 +426,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 +439,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 +487,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 +505,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 +585,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">
@@ -291,8 +291,8 @@ 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) => {
@@ -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)
} }
} }
@@ -277,8 +277,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]} />
+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
+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...",
+1
View File
@@ -221,6 +221,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...",
+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": "儲存中...",
+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>
+52 -27
View File
@@ -543,13 +543,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 })
@@ -630,7 +631,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)
@@ -1155,13 +1160,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 +1186,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 +1349,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 +1686,55 @@ 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]) => { const dir = params.dir
if (!ready || !dir) { const directory = dir ? decode64(dir) : undefined
const resolved = directory ? globalSync.child(directory, { bootstrap: false })[0].path.directory : ""
return [pageReady(), dir, params.id, currentProject()?.worktree, directory, resolved] as const
},
([ready, dir, id, root, directory, resolved]) => {
if (!ready || !dir || !directory) {
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 next = resolved || directory
const session = `${dir}/${id}` const session = `${dir}/${id}`
if (session !== activeRoute.session) {
if (!root) {
activeRoute.session = session activeRoute.session = session
activeRoute.sessionProject = syncSessionRoute(directory, id, root) activeRoute.directory = next
activeRoute.sessionProject = ""
return
}
if (server.projects.last() !== root) server.projects.touch(root)
const changed = session !== activeRoute.session || next !== activeRoute.directory
if (changed) {
activeRoute.session = session
activeRoute.directory = next
activeRoute.sessionProject = syncSessionRoute(next, id, root)
return return
} }
if (root === activeRoute.sessionProject) return if (root === activeRoute.sessionProject) return
activeRoute.sessionProject = rememberSessionRoute(directory, id, root) activeRoute.directory = next
activeRoute.sessionProject = rememberSessionRoute(next, id, root)
}, },
), ),
) )
@@ -1778,8 +1798,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])
@@ -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:/")
}) })
+18 -12
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,13 +31,13 @@ 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>,
@@ -40,9 +46,9 @@ export function hasProjectPermissions<T>(
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) {
@@ -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)
@@ -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>
@@ -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",
+2
View File
@@ -658,6 +658,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
@@ -648,6 +648,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
@@ -655,6 +655,8 @@ export const dict = {
"Plan öncelikle uluslararası kullanıcılar için tasarlanmıştır; modeller istikrarlı küresel erişim için ABD, AB ve Singapur'da barındırılmaktadır. Erken kullanımdan öğrendikçe ve geri bildirim topladıkça fiyatlandırma ve kullanım limitleri değişebilir.", "Plan öncelikle uluslararası kullanıcılar için tasarlanmıştır; modeller istikrarlı küresel erişim için ABD, AB ve Singapur'da barındırılmaktadır. Erken kullanımdan öğrendikçe ve geri bildirim topladıkça fiyatlandırma ve kullanım limitleri değişebilir.",
"workspace.lite.promo.subscribe": "Go'ya Abone Ol", "workspace.lite.promo.subscribe": "Go'ya Abone Ol",
"workspace.lite.promo.subscribing": "Yönlendiriliyor...", "workspace.lite.promo.subscribing": "Yönlendiriliyor...",
"workspace.lite.promo.otherMethods": "Diğer ödeme yöntemleri",
"workspace.lite.promo.selectMethod": "Ödeme yöntemini seçin",
"download.title": "OpenCode | İndir", "download.title": "OpenCode | İndir",
"download.meta.description": "OpenCode'u macOS, Windows ve Linux için indirin", "download.meta.description": "OpenCode'u macOS, Windows ve Linux için indirin",
+2
View File
@@ -626,6 +626,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": "下载适用于 macOS, Windows, 和 Linux 的 OpenCode", "download.meta.description": "下载适用于 macOS, Windows, 和 Linux 的 OpenCode",
+2
View File
@@ -626,6 +626,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": "下載適用於 macOS、Windows 與 Linux 的 OpenCode", "download.meta.description": "下載適用於 macOS、Windows 與 Linux 的 OpenCode",
@@ -244,6 +244,7 @@ export async function POST(input: APIEvent) {
customerID, customerID,
enrichment: { enrichment: {
type: productID === LiteData.productID() ? "lite" : "subscription", type: productID === LiteData.productID() ? "lite" : "subscription",
currency: body.data.object.currency === "inr" ? "inr" : undefined,
couponID, couponID,
}, },
}), }),
@@ -331,16 +332,17 @@ export async function POST(input: APIEvent) {
) )
if (!workspaceID) throw new Error("Workspace ID not found") if (!workspaceID) throw new Error("Workspace ID not found")
const amount = await Database.use((tx) => const payment = await Database.use((tx) =>
tx tx
.select({ .select({
amount: PaymentTable.amount, amount: PaymentTable.amount,
enrichment: PaymentTable.enrichment,
}) })
.from(PaymentTable) .from(PaymentTable)
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID))) .where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
.then((rows) => rows[0]?.amount), .then((rows) => rows[0]),
) )
if (!amount) throw new Error("Payment not found") if (!payment) throw new Error("Payment not found")
await Database.transaction(async (tx) => { await Database.transaction(async (tx) => {
await tx await tx
@@ -350,12 +352,15 @@ export async function POST(input: APIEvent) {
}) })
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID))) .where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
await tx // deduct balance only for top up
.update(BillingTable) if (!payment.enrichment?.type) {
.set({ await tx
balance: sql`${BillingTable.balance} - ${amount}`, .update(BillingTable)
}) .set({
.where(eq(BillingTable.workspaceID, workspaceID)) balance: sql`${BillingTable.balance} - ${payment.amount}`,
})
.where(eq(BillingTable.workspaceID, workspaceID))
}
}) })
} }
})() })()
@@ -3,7 +3,7 @@ import { createMemo, Match, Show, Switch, createEffect } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { Billing } from "@opencode-ai/console-core/billing.js" import { Billing } from "@opencode-ai/console-core/billing.js"
import { withActor } from "~/context/auth.withActor" import { withActor } from "~/context/auth.withActor"
import { IconAlipay, IconCreditCard, IconStripe, IconWechat } from "~/component/icon" import { IconAlipay, IconCreditCard, IconStripe, IconUpi, IconWechat } from "~/component/icon"
import styles from "./billing-section.module.css" import styles from "./billing-section.module.css"
import { createCheckoutUrl, formatBalance, queryBillingInfo } from "../../common" import { createCheckoutUrl, formatBalance, queryBillingInfo } from "../../common"
import { useI18n } from "~/context/i18n" import { useI18n } from "~/context/i18n"
@@ -211,6 +211,9 @@ export function BillingSection() {
<Match when={billingInfo()?.paymentMethodType === "wechat_pay"}> <Match when={billingInfo()?.paymentMethodType === "wechat_pay"}>
<IconWechat style={{ width: "24px", height: "24px" }} /> <IconWechat style={{ width: "24px", height: "24px" }} />
</Match> </Match>
<Match when={billingInfo()?.paymentMethodType === "upi"}>
<IconUpi style={{ width: "auto", height: "16px" }} />
</Match>
</Switch> </Switch>
</div> </div>
<div data-slot="card-details"> <div data-slot="card-details">
@@ -6,6 +6,14 @@ import { formatDateUTC, formatDateForTable } from "../../common"
import styles from "./payment-section.module.css" import styles from "./payment-section.module.css"
import { useI18n } from "~/context/i18n" import { useI18n } from "~/context/i18n"
function money(amount: number, currency?: string) {
const formatter =
currency === "inr"
? new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR" })
: new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
return formatter.format(amount / 100_000_000)
}
const getPaymentsInfo = query(async (workspaceID: string) => { const getPaymentsInfo = query(async (workspaceID: string) => {
"use server" "use server"
return withActor(async () => { return withActor(async () => {
@@ -81,6 +89,10 @@ export function PaymentSection() {
const date = new Date(payment.timeCreated) const date = new Date(payment.timeCreated)
const amount = const amount =
payment.enrichment?.type === "subscription" && payment.enrichment.couponID ? 0 : payment.amount payment.enrichment?.type === "subscription" && payment.enrichment.couponID ? 0 : payment.amount
const currency =
payment.enrichment?.type === "subscription" || payment.enrichment?.type === "lite"
? payment.enrichment.currency
: undefined
return ( return (
<tr> <tr>
<td data-slot="payment-date" title={formatDateUTC(date)}> <td data-slot="payment-date" title={formatDateUTC(date)}>
@@ -88,7 +100,7 @@ export function PaymentSection() {
</td> </td>
<td data-slot="payment-id">{payment.id}</td> <td data-slot="payment-id">{payment.id}</td>
<td data-slot="payment-amount" data-refunded={!!payment.timeRefunded}> <td data-slot="payment-amount" data-refunded={!!payment.timeRefunded}>
${((amount ?? 0) / 100000000).toFixed(2)} {money(amount, currency)}
<Switch> <Switch>
<Match when={payment.enrichment?.type === "credit"}> <Match when={payment.enrichment?.type === "credit"}>
{" "} {" "}
@@ -188,8 +188,45 @@
line-height: 1.4; line-height: 1.4;
} }
[data-slot="subscribe-button"] { [data-slot="subscribe-actions"] {
align-self: flex-start; display: flex;
align-items: center;
gap: var(--space-4);
margin-top: var(--space-4); margin-top: var(--space-4);
} }
[data-slot="subscribe-button"] {
align-self: stretch;
}
[data-slot="other-methods"] {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
}
[data-slot="other-methods-icons"] {
display: inline-flex;
align-items: center;
gap: 4px;
}
[data-slot="modal-actions"] {
display: flex;
gap: var(--space-3);
margin-top: var(--space-4);
button {
flex: 1;
}
}
[data-slot="method-button"] {
display: flex;
align-items: center;
justify-content: flex-start;
gap: var(--space-2);
height: 48px;
}
} }
@@ -1,6 +1,7 @@
import { action, useParams, useAction, useSubmission, json, query, createAsync } from "@solidjs/router" import { action, useParams, useAction, useSubmission, json, query, createAsync } from "@solidjs/router"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createMemo, For, Show } from "solid-js" import { createMemo, For, Show } from "solid-js"
import { Modal } from "~/component/modal"
import { Billing } from "@opencode-ai/console-core/billing.js" import { Billing } from "@opencode-ai/console-core/billing.js"
import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.js" import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable, LiteTable } from "@opencode-ai/console-core/schema/billing.sql.js" import { BillingTable, LiteTable } from "@opencode-ai/console-core/schema/billing.sql.js"
@@ -14,6 +15,8 @@ import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language" import { useLanguage } from "~/context/language"
import { formError } from "~/lib/form-error" import { formError } from "~/lib/form-error"
import { IconAlipay, IconUpi } from "~/component/icon"
const queryLiteSubscription = query(async (workspaceID: string) => { const queryLiteSubscription = query(async (workspaceID: string) => {
"use server" "use server"
return withActor(async () => { return withActor(async () => {
@@ -78,22 +81,25 @@ function formatResetTime(seconds: number, i18n: ReturnType<typeof useI18n>) {
return `${minutes} ${minutes === 1 ? i18n.t("workspace.lite.time.minute") : i18n.t("workspace.lite.time.minutes")}` return `${minutes} ${minutes === 1 ? i18n.t("workspace.lite.time.minute") : i18n.t("workspace.lite.time.minutes")}`
} }
const createLiteCheckoutUrl = action(async (workspaceID: string, successUrl: string, cancelUrl: string) => { const createLiteCheckoutUrl = action(
"use server" async (workspaceID: string, successUrl: string, cancelUrl: string, method?: "alipay" | "upi") => {
return json( "use server"
await withActor( return json(
() => await withActor(
Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl }) () =>
.then((data) => ({ error: undefined, data })) Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl, method })
.catch((e) => ({ .then((data) => ({ error: undefined, data }))
error: e.message as string, .catch((e) => ({
data: undefined, error: e.message as string,
})), data: undefined,
workspaceID, })),
), workspaceID,
{ revalidate: [queryBillingInfo.key, queryLiteSubscription.key] }, ),
) { revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
}, "liteCheckoutUrl") )
},
"liteCheckoutUrl",
)
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => { const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
"use server" "use server"
@@ -147,23 +153,30 @@ export function LiteSection() {
const checkoutSubmission = useSubmission(createLiteCheckoutUrl) const checkoutSubmission = useSubmission(createLiteCheckoutUrl)
const useBalanceSubmission = useSubmission(setLiteUseBalance) const useBalanceSubmission = useSubmission(setLiteUseBalance)
const [store, setStore] = createStore({ const [store, setStore] = createStore({
redirecting: false, loading: undefined as undefined | "session" | "checkout" | "alipay" | "upi",
showModal: false,
}) })
const busy = createMemo(() => !!store.loading)
async function onClickSession() { async function onClickSession() {
setStore("loading", "session")
const result = await sessionAction(params.id!, window.location.href) const result = await sessionAction(params.id!, window.location.href)
if (result.data) { if (result.data) {
setStore("redirecting", true)
window.location.href = result.data window.location.href = result.data
return
} }
setStore("loading", undefined)
} }
async function onClickSubscribe() { async function onClickSubscribe(method?: "alipay" | "upi") {
const result = await checkoutAction(params.id!, window.location.href, window.location.href) setStore("loading", method ?? "checkout")
const result = await checkoutAction(params.id!, window.location.href, window.location.href, method)
if (result.data) { if (result.data) {
setStore("redirecting", true)
window.location.href = result.data window.location.href = result.data
return
} }
setStore("loading", undefined)
} }
return ( return (
@@ -179,12 +192,8 @@ export function LiteSection() {
<div data-slot="section-title"> <div data-slot="section-title">
<div data-slot="title-row"> <div data-slot="title-row">
<p>{i18n.t("workspace.lite.subscription.message")}</p> <p>{i18n.t("workspace.lite.subscription.message")}</p>
<button <button data-color="primary" disabled={sessionSubmission.pending || busy()} onClick={onClickSession}>
data-color="primary" {store.loading === "session"
disabled={sessionSubmission.pending || store.redirecting}
onClick={onClickSession}
>
{sessionSubmission.pending || store.redirecting
? i18n.t("workspace.lite.loading") ? i18n.t("workspace.lite.loading")
: i18n.t("workspace.lite.subscription.manage")} : i18n.t("workspace.lite.subscription.manage")}
</button> </button>
@@ -282,16 +291,64 @@ export function LiteSection() {
<li>MiniMax M2.7</li> <li>MiniMax M2.7</li>
</ul> </ul>
<p data-slot="promo-description">{i18n.t("workspace.lite.promo.footer")}</p> <p data-slot="promo-description">{i18n.t("workspace.lite.promo.footer")}</p>
<button <div data-slot="subscribe-actions">
data-slot="subscribe-button" <button
data-color="primary" data-slot="subscribe-button"
disabled={checkoutSubmission.pending || store.redirecting} data-color="primary"
onClick={onClickSubscribe} disabled={checkoutSubmission.pending || busy()}
onClick={() => onClickSubscribe()}
>
{store.loading === "checkout"
? i18n.t("workspace.lite.promo.subscribing")
: i18n.t("workspace.lite.promo.subscribe")}
</button>
<button
type="button"
data-slot="other-methods"
data-color="ghost"
onClick={() => setStore("showModal", true)}
>
<span>{i18n.t("workspace.lite.promo.otherMethods")}</span>
<span data-slot="other-methods-icons">
<span> </span>
<IconAlipay style={{ width: "16px", height: "16px" }} />
<span> </span>
<IconUpi style={{ width: "auto", height: "10px" }} />
</span>
</button>
</div>
<Modal
open={store.showModal}
onClose={() => setStore("showModal", false)}
title={i18n.t("workspace.lite.promo.selectMethod")}
> >
{checkoutSubmission.pending || store.redirecting <div data-slot="modal-actions">
? i18n.t("workspace.lite.promo.subscribing") <button
: i18n.t("workspace.lite.promo.subscribe")} type="button"
</button> data-slot="method-button"
data-color="ghost"
disabled={checkoutSubmission.pending || busy()}
onClick={() => onClickSubscribe("alipay")}
>
<Show when={store.loading !== "alipay"}>
<IconAlipay style={{ width: "24px", height: "24px" }} />
</Show>
{store.loading === "alipay" ? i18n.t("workspace.lite.promo.subscribing") : "Alipay"}
</button>
<button
type="button"
data-slot="method-button"
data-color="ghost"
disabled={checkoutSubmission.pending || busy()}
onClick={() => onClickSubscribe("upi")}
>
<Show when={store.loading !== "upi"}>
<IconUpi style={{ width: "auto", height: "16px" }} />
</Show>
{store.loading === "upi" ? i18n.t("workspace.lite.promo.subscribing") : "UPI"}
</button>
</div>
</Modal>
</section> </section>
</Show> </Show>
</> </>
+95 -30
View File
@@ -239,10 +239,11 @@ export namespace Billing {
z.object({ z.object({
successUrl: z.string(), successUrl: z.string(),
cancelUrl: z.string(), cancelUrl: z.string(),
method: z.enum(["alipay", "upi"]).optional(),
}), }),
async (input) => { async (input) => {
const user = Actor.assert("user") const user = Actor.assert("user")
const { successUrl, cancelUrl } = input const { successUrl, cancelUrl, method } = input
const email = await User.getAuthEmail(user.properties.userID) const email = await User.getAuthEmail(user.properties.userID)
const billing = await Billing.get() const billing = await Billing.get()
@@ -250,38 +251,102 @@ export namespace Billing {
if (billing.subscriptionID) throw new Error("Already subscribed to Black") if (billing.subscriptionID) throw new Error("Already subscribed to Black")
if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite") if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite")
const session = await Billing.stripe().checkout.sessions.create({ const createSession = () =>
mode: "subscription", Billing.stripe().checkout.sessions.create({
billing_address_collection: "required", mode: "subscription",
line_items: [{ price: LiteData.priceID(), quantity: 1 }], discounts: [{ coupon: LiteData.firstMonth50Coupon() }],
discounts: [{ coupon: LiteData.firstMonth50Coupon() }], ...(billing.customerID
...(billing.customerID ? {
? { customer: billing.customerID,
customer: billing.customerID, customer_update: {
customer_update: { name: "auto",
name: "auto", address: "auto",
address: "auto", },
}, }
: {
customer_email: email!,
}),
...(() => {
if (method === "alipay") {
return {
line_items: [{ price: LiteData.priceID(), quantity: 1 }],
payment_method_types: ["alipay"],
adaptive_pricing: {
enabled: false,
},
}
} }
: { if (method === "upi") {
customer_email: email!, return {
}), line_items: [
currency: "usd", {
tax_id_collection: { price_data: {
enabled: true, currency: "inr",
}, product: LiteData.productID(),
success_url: successUrl, recurring: {
cancel_url: cancelUrl, interval: "month",
subscription_data: { interval_count: 1,
metadata: { },
workspaceID: Actor.workspace(), unit_amount: LiteData.priceInr(),
userID: user.properties.userID, },
type: "lite", quantity: 1,
},
],
payment_method_types: ["upi"] as any,
adaptive_pricing: {
enabled: false,
},
}
}
return {
line_items: [{ price: LiteData.priceID(), quantity: 1 }],
billing_address_collection: "required",
}
})(),
tax_id_collection: {
enabled: true,
}, },
}, success_url: successUrl,
}) cancel_url: cancelUrl,
subscription_data: {
metadata: {
workspaceID: Actor.workspace(),
userID: user.properties.userID,
type: "lite",
},
},
})
return session.url try {
const session = await createSession()
return session.url
} catch (e: any) {
if (
e.type !== "StripeInvalidRequestError" ||
!e.message.includes("You cannot combine currencies on a single customer")
)
throw e
// get pending payment intent
const intents = await Billing.stripe().paymentIntents.search({
query: `-status:'canceled' AND -status:'processing' AND -status:'succeeded' AND customer:'${billing.customerID}'`,
})
if (intents.data.length === 0) throw e
for (const intent of intents.data) {
// get checkout session
const sessions = await Billing.stripe().checkout.sessions.list({
customer: billing.customerID!,
payment_intent: intent.id,
})
// delete pending payment intent
await Billing.stripe().checkout.sessions.expire(sessions.data[0].id)
}
const session = await createSession()
return session.url
}
}, },
) )
+1
View File
@@ -10,6 +10,7 @@ export namespace LiteData {
export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product) export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product)
export const priceID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.price) export const priceID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.price)
export const priceInr = fn(z.void(), () => Resource.ZEN_LITE_PRICE.priceInr)
export const firstMonth50Coupon = fn(z.void(), () => Resource.ZEN_LITE_PRICE.firstMonth50Coupon) export const firstMonth50Coupon = fn(z.void(), () => Resource.ZEN_LITE_PRICE.firstMonth50Coupon)
export const planName = fn(z.void(), () => "lite") export const planName = fn(z.void(), () => "lite")
} }
@@ -88,6 +88,7 @@ export const PaymentTable = mysqlTable(
enrichment: json("enrichment").$type< enrichment: json("enrichment").$type<
| { | {
type: "subscription" | "lite" type: "subscription" | "lite"
currency?: "inr"
couponID?: string couponID?: string
} }
| { | {
+1
View File
@@ -145,6 +145,7 @@ declare module "sst" {
"ZEN_LITE_PRICE": { "ZEN_LITE_PRICE": {
"firstMonth50Coupon": string "firstMonth50Coupon": string
"price": string "price": string
"priceInr": number
"product": string "product": string
"type": "sst.sst.Linkable" "type": "sst.sst.Linkable"
} }
+1
View File
@@ -145,6 +145,7 @@ declare module "sst" {
"ZEN_LITE_PRICE": { "ZEN_LITE_PRICE": {
"firstMonth50Coupon": string "firstMonth50Coupon": string
"price": string "price": string
"priceInr": number
"product": string "product": string
"type": "sst.sst.Linkable" "type": "sst.sst.Linkable"
} }
+1
View File
@@ -145,6 +145,7 @@ declare module "sst" {
"ZEN_LITE_PRICE": { "ZEN_LITE_PRICE": {
"firstMonth50Coupon": string "firstMonth50Coupon": string
"price": string "price": string
"priceInr": number
"product": string "product": string
"type": "sst.sst.Linkable" "type": "sst.sst.Linkable"
} }
@@ -152,12 +152,12 @@ const createPlatform = (): Platform => {
storage, storage,
checkUpdate: async () => { checkUpdate: async () => {
if (!UPDATER_ENABLED) return { updateAvailable: false } if (!UPDATER_ENABLED()) return { updateAvailable: false }
return window.api.checkUpdate() return window.api.checkUpdate()
}, },
update: async () => { update: async () => {
if (!UPDATER_ENABLED) return if (!UPDATER_ENABLED()) return
await window.api.installUpdate() await window.api.installUpdate()
}, },
@@ -1,6 +1,6 @@
import { initI18n, t } from "./i18n" import { initI18n, t } from "./i18n"
export const UPDATER_ENABLED = window.__OPENCODE__?.updaterEnabled ?? false export const UPDATER_ENABLED = () => window.__OPENCODE__?.updaterEnabled ?? false
export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) { export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) {
await initI18n() await initI18n()
+1
View File
@@ -145,6 +145,7 @@ declare module "sst" {
"ZEN_LITE_PRICE": { "ZEN_LITE_PRICE": {
"firstMonth50Coupon": string "firstMonth50Coupon": string
"price": string "price": string
"priceInr": number
"product": string "product": string
"type": "sst.sst.Linkable" "type": "sst.sst.Linkable"
} }
+1
View File
@@ -145,6 +145,7 @@ declare module "sst" {
"ZEN_LITE_PRICE": { "ZEN_LITE_PRICE": {
"firstMonth50Coupon": string "firstMonth50Coupon": string
"price": string "price": string
"priceInr": number
"product": string "product": string
"type": "sst.sst.Linkable" "type": "sst.sst.Linkable"
} }
+15 -6
View File
@@ -26,6 +26,13 @@
"exports": { "exports": {
"./*": "./src/*.ts" "./*": "./src/*.ts"
}, },
"imports": {
"#db": {
"bun": "./src/storage/db.bun.ts",
"node": "./src/storage/db.node.ts",
"default": "./src/storage/db.bun.ts"
}
},
"devDependencies": { "devDependencies": {
"@babel/core": "7.28.4", "@babel/core": "7.28.4",
"@effect/language-service": "0.79.0", "@effect/language-service": "0.79.0",
@@ -50,8 +57,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",
@@ -82,8 +89,11 @@
"@ai-sdk/xai": "2.0.51", "@ai-sdk/xai": "2.0.51",
"@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",
"gitlab-ai-provider": "5.2.0", "@effect/platform-node": "catalog:",
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3", "@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/node-server": "1.19.11",
"@hono/node-ws": "1.3.0",
"@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",
@@ -97,7 +107,6 @@
"@openrouter/ai-sdk-provider": "1.5.4", "@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.87", "@opentui/core": "0.1.87",
"@opentui/solid": "0.1.87", "@opentui/solid": "0.1.87",
"@effect/platform-node": "catalog:",
"@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",
@@ -113,7 +122,7 @@
"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",
"glob": "13.0.5", "glob": "13.0.5",
@@ -144,6 +153,6 @@
"zod-to-json-schema": "3.24.5" "zod-to-json-schema": "3.24.5"
}, },
"overrides": { "overrides": {
"drizzle-orm": "1.0.0-beta.16-ea816b6" "drizzle-orm": "catalog:"
} }
} }
+7 -1
View File
@@ -148,6 +148,12 @@ export namespace AccountEffect {
mapAccountServiceError("HTTP request failed"), mapAccountServiceError("HTTP request failed"),
) )
const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => http.execute(req)),
mapAccountServiceError("HTTP request failed"),
)
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) { const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis const now = yield* Clock.currentTimeMillis
if (row.token_expiry && row.token_expiry > now) return row.access_token if (row.token_expiry && row.token_expiry > now) return row.access_token
@@ -290,7 +296,7 @@ export namespace AccountEffect {
}) })
const poll = Effect.fn("Account.poll")(function* (input: Login) { const poll = Effect.fn("Account.poll")(function* (input: Login) {
const response = yield* executeEffectOk( const response = yield* executeEffect(
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe( HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
HttpClientRequest.acceptJson, HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)( HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
+4 -1
View File
@@ -260,7 +260,10 @@ export namespace Agent {
return pipe( return pipe(
await state(), await state(),
values(), values(),
sortBy([(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"]), sortBy(
[(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"],
[(x) => x.name, "asc"],
),
) )
} }
+2 -2
View File
@@ -51,8 +51,8 @@ export namespace Bus {
}) })
const pending = [] const pending = []
for (const key of [def.type, "*"]) { for (const key of [def.type, "*"]) {
const match = state().subscriptions.get(key) const match = [...(state().subscriptions.get(key) ?? [])]
for (const sub of match ?? []) { for (const sub of match) {
pending.push(sub(payload)) pending.push(sub(payload))
} }
} }
+1 -1
View File
@@ -23,7 +23,7 @@ export const AcpCommand = cmd({
process.env.OPENCODE_CLIENT = "acp" process.env.OPENCODE_CLIENT = "acp"
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const opts = await resolveNetworkOptions(args) const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts) const server = await Server.listen(opts)
const sdk = createOpencodeClient({ const sdk = createOpencodeClient({
baseUrl: `http://${server.hostname}:${server.port}`, baseUrl: `http://${server.hostname}:${server.port}`,
@@ -349,7 +349,6 @@ export const ProvidersLoginCommand = cmd({
value: x.id, value: x.id,
hint: { hint: {
opencode: "recommended", opencode: "recommended",
anthropic: "API key",
openai: "ChatGPT Plus/Pro or API key", openai: "ChatGPT Plus/Pro or API key",
}[x.id], }[x.id],
})), })),
+1 -1
View File
@@ -15,7 +15,7 @@ export const ServeCommand = cmd({
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
} }
const opts = await resolveNetworkOptions(args) const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts) const server = await Server.listen(opts)
console.log(`opencode server listening on http://${server.hostname}:${server.port}`) console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
await new Promise(() => {}) await new Promise(() => {})
@@ -9,6 +9,7 @@ import { useToast } from "../ui/toast"
import { useKeybind } from "../context/keybind" import { useKeybind } from "../context/keybind"
import { DialogSessionList } from "./workspace/dialog-session-list" import { DialogSessionList } from "./workspace/dialog-session-list"
import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { setTimeout as sleep } from "node:timers/promises"
async function openWorkspace(input: { async function openWorkspace(input: {
dialog: ReturnType<typeof useDialog> dialog: ReturnType<typeof useDialog>
@@ -56,7 +57,7 @@ async function openWorkspace(input: {
return return
} }
if (result.response.status >= 500 && result.response.status < 600) { if (result.response.status >= 500 && result.response.status < 600) {
await Bun.sleep(1000) await sleep(1000)
continue continue
} }
if (!result.data) { if (!result.data) {
@@ -13,6 +13,7 @@ import { MessageID, PartID } from "@/session/schema"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind" import { useKeybind } from "@tui/context/keybind"
import { usePromptHistory, type PromptInfo } from "./history" import { usePromptHistory, type PromptInfo } from "./history"
import { assign } from "./part"
import { usePromptStash } from "./stash" import { usePromptStash } from "./stash"
import { DialogStash } from "../dialog-stash" import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { type AutocompleteRef, Autocomplete } from "./autocomplete"
@@ -643,10 +644,7 @@ export function Prompt(props: PromptProps) {
type: "text", type: "text",
text: inputText, text: inputText,
}, },
...nonTextParts.map((x) => ({ ...nonTextParts.map(assign),
id: PartID.ascending(),
...x,
})),
], ],
}) })
.catch(() => {}) .catch(() => {})
@@ -0,0 +1,16 @@
import { PartID } from "@/session/schema"
import type { PromptInfo } from "./history"
type Item = PromptInfo["parts"][number]
export function strip(part: Item & { id: string; messageID: string; sessionID: string }): Item {
const { id: _id, messageID: _messageID, sessionID: _sessionID, ...rest } = part
return rest
}
export function assign(part: Item): Item & { id: PartID } {
return {
...part,
id: PartID.ascending(),
}
}
@@ -7,6 +7,7 @@ import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route" import { useRoute } from "@tui/context/route"
import { useDialog } from "../../ui/dialog" import { useDialog } from "../../ui/dialog"
import type { PromptInfo } from "@tui/component/prompt/history" import type { PromptInfo } from "@tui/component/prompt/history"
import { strip } from "@tui/component/prompt/part"
export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID: string) => void }) { export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID: string) => void }) {
const sync = useSync() const sync = useSync()
@@ -42,7 +43,7 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
if (part.type === "text") { if (part.type === "text") {
if (!part.synthetic) agg.input += part.text if (!part.synthetic) agg.input += part.text
} }
if (part.type === "file") agg.parts.push(part) if (part.type === "file") agg.parts.push(strip(part))
return agg return agg
}, },
{ input: "", parts: [] as PromptInfo["parts"] }, { input: "", parts: [] as PromptInfo["parts"] },
@@ -5,6 +5,7 @@ import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route" import { useRoute } from "@tui/context/route"
import { Clipboard } from "@tui/util/clipboard" import { Clipboard } from "@tui/util/clipboard"
import type { PromptInfo } from "@tui/component/prompt/history" import type { PromptInfo } from "@tui/component/prompt/history"
import { strip } from "@tui/component/prompt/part"
export function DialogMessage(props: { export function DialogMessage(props: {
messageID: string messageID: string
@@ -40,7 +41,7 @@ export function DialogMessage(props: {
if (part.type === "text") { if (part.type === "text") {
if (!part.synthetic) agg.input += part.text if (!part.synthetic) agg.input += part.text
} }
if (part.type === "file") agg.parts.push(part) if (part.type === "file") agg.parts.push(strip(part))
return agg return agg
}, },
{ input: "", parts: [] as PromptInfo["parts"] }, { input: "", parts: [] as PromptInfo["parts"] },
@@ -907,12 +907,12 @@ export function Session() {
const filename = options.filename.trim() const filename = options.filename.trim()
const filepath = path.join(exportDir, filename) const filepath = path.join(exportDir, filename)
await Bun.write(filepath, transcript) await Filesystem.write(filepath, transcript)
// Open with EDITOR if available // Open with EDITOR if available
const result = await Editor.open({ value: transcript, renderer }) const result = await Editor.open({ value: transcript, renderer })
if (result !== undefined) { if (result !== undefined) {
await Bun.write(filepath, result) await Filesystem.write(filepath, result)
} }
toast.show({ message: `Session exported to ${filename}`, variant: "success" }) toast.show({ message: `Session exported to ${filename}`, variant: "success" })
+3 -4
View File
@@ -8,7 +8,6 @@ import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2" import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
import type { BunWebSocketData } from "hono/bun"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { setTimeout as sleep } from "node:timers/promises" import { setTimeout as sleep } from "node:timers/promises"
@@ -38,7 +37,7 @@ GlobalBus.on("event", (event) => {
Rpc.emit("global.event", event) Rpc.emit("global.event", event)
}) })
let server: Bun.Server<BunWebSocketData> | undefined let server: Awaited<ReturnType<typeof Server.listen>> | undefined
const eventStream = { const eventStream = {
abort: undefined as AbortController | undefined, abort: undefined as AbortController | undefined,
@@ -120,7 +119,7 @@ export const rpc = {
}, },
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) { async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
if (server) await server.stop(true) if (server) await server.stop(true)
server = Server.listen(input) server = await Server.listen(input)
return { url: server.url.toString() } return { url: server.url.toString() }
}, },
async checkUpgrade(input: { directory: string }) { async checkUpgrade(input: { directory: string }) {
@@ -143,7 +142,7 @@ export const rpc = {
Log.Default.info("worker shutting down") Log.Default.info("worker shutting down")
if (eventStream.abort) eventStream.abort.abort() if (eventStream.abort) eventStream.abort.abort()
await Instance.disposeAll() await Instance.disposeAll()
if (server) server.stop(true) if (server) await server.stop(true)
}, },
} }
+1 -1
View File
@@ -37,7 +37,7 @@ export const WebCommand = cmd({
UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
} }
const opts = await resolveNetworkOptions(args) const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts) const server = await Server.listen(opts)
UI.empty() UI.empty()
UI.println(UI.logo(" ")) UI.println(UI.logo(" "))
UI.empty() UI.empty()
@@ -1,3 +1,4 @@
import { createAdaptorServer } from "@hono/node-server"
import { Hono } from "hono" import { Hono } from "hono"
import { Instance } from "../../project/instance" import { Instance } from "../../project/instance"
import { InstanceBootstrap } from "../../project/bootstrap" import { InstanceBootstrap } from "../../project/bootstrap"
@@ -56,10 +57,24 @@ export namespace WorkspaceServer {
} }
export function Listen(opts: { hostname: string; port: number }) { export function Listen(opts: { hostname: string; port: number }) {
return Bun.serve({ const server = createAdaptorServer({
hostname: opts.hostname,
port: opts.port,
fetch: App().fetch, fetch: App().fetch,
}) })
server.listen(opts.port, opts.hostname)
return {
hostname: opts.hostname,
port: opts.port,
stop() {
return new Promise<void>((resolve, reject) => {
server.close((err) => {
if (err) {
reject(err)
return
}
resolve()
})
})
},
}
} }
} }
@@ -1,4 +1,5 @@
import z from "zod" import z from "zod"
import { setTimeout as sleep } from "node:timers/promises"
import { fn } from "@/util/fn" import { fn } from "@/util/fn"
import { Database, eq } from "@/storage/db" import { Database, eq } from "@/storage/db"
import { Project } from "@/project/project" import { Project } from "@/project/project"
@@ -117,7 +118,7 @@ export namespace Workspace {
const adaptor = await getAdaptor(space.type) const adaptor = await getAdaptor(space.type)
const res = await adaptor.fetch(space, "/event", { method: "GET", signal: stop }).catch(() => undefined) const res = await adaptor.fetch(space, "/event", { method: "GET", signal: stop }).catch(() => undefined)
if (!res || !res.ok || !res.body) { if (!res || !res.ok || !res.body) {
await Bun.sleep(1000) await sleep(1000)
continue continue
} }
await parseSSE(res.body, stop, (event) => { await parseSSE(res.body, stop, (event) => {
@@ -127,7 +128,7 @@ export namespace Workspace {
}) })
}) })
// Wait 250ms and retry if SSE connection fails // Wait 250ms and retry if SSE connection fails
await Bun.sleep(250) await sleep(250)
} }
} }
+1 -1
View File
@@ -18,7 +18,7 @@ export namespace Global {
return process.env.OPENCODE_TEST_HOME || os.homedir() return process.env.OPENCODE_TEST_HOME || os.homedir()
}, },
data, data,
bin: path.join(data, "bin"), bin: path.join(cache, "bin"),
log: path.join(data, "log"), log: path.join(data, "log"),
cache, cache,
config, config,
+4 -7
View File
@@ -11,6 +11,7 @@ import {
} from "@modelcontextprotocol/sdk/types.js" } from "@modelcontextprotocol/sdk/types.js"
import { Config } from "../config/config" import { Config } from "../config/config"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Process } from "../util/process"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import z from "zod/v4" import z from "zod/v4"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
@@ -166,14 +167,10 @@ export namespace MCP {
const queue = [pid] const queue = [pid]
while (queue.length > 0) { while (queue.length > 0) {
const current = queue.shift()! const current = queue.shift()!
const proc = Bun.spawn(["pgrep", "-P", String(current)], { stdout: "pipe", stderr: "pipe" }) const lines = await Process.lines(["pgrep", "-P", String(current)], { nothrow: true })
const [code, out] = await Promise.all([proc.exited, new Response(proc.stdout).text()]).catch( for (const tok of lines) {
() => [-1, ""] as const,
)
if (code !== 0) continue
for (const tok of out.trim().split(/\s+/)) {
const cpid = parseInt(tok, 10) const cpid = parseInt(tok, 10)
if (!isNaN(cpid) && pids.indexOf(cpid) === -1) { if (!isNaN(cpid) && !pids.includes(cpid)) {
pids.push(cpid) pids.push(cpid)
queue.push(cpid) queue.push(cpid)
} }
+1 -1
View File
@@ -1,7 +1,7 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin" import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Installation } from "../installation" import { Installation } from "../installation"
import { OAUTH_DUMMY_KEY } from "../auth" import { Auth, OAUTH_DUMMY_KEY } from "../auth"
import os from "os" import os from "os"
import { ProviderTransform } from "@/provider/transform" import { ProviderTransform } from "@/provider/transform"
import { ModelID, ProviderID } from "@/provider/schema" import { ModelID, ProviderID } from "@/provider/schema"
-86
View File
@@ -1,86 +0,0 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { discoverWorkflowModels } from "gitlab-ai-provider"
import { Log } from "@/util/log"
import { ProviderTransform } from "@/provider/transform"
import { ModelID, ProviderID } from "@/provider/schema"
const log = Log.create({ service: "plugin.gitlab" })
function str(value: unknown, fallback: string) {
if (typeof value === "string" && value) return value
return fallback
}
export async function GitlabPlugin(input: PluginInput): Promise<Hooks> {
return {
provider: {
id: "gitlab",
models: {
async reconcile(args) {
const url = str(args.options?.instanceUrl, "https://gitlab.com")
const token = str(args.options?.apiKey, "")
if (!token) return
const headers = (): Record<string, string> => {
if (args.auth?.type === "api") return { "PRIVATE-TOKEN": token }
return { Authorization: `Bearer ${token}` }
}
try {
log.info("gitlab model discovery starting", { instanceUrl: url })
const res = await discoverWorkflowModels(
{ instanceUrl: url, getHeaders: headers },
{ workingDirectory: input.directory },
)
if (!res.models.length) {
log.info("gitlab model discovery skipped: no models found", {
project: res.project ? { id: res.project.id, path: res.project.pathWithNamespace } : null,
})
return
}
for (const model of res.models) {
if (args.models[model.id]) {
continue
}
const m = {
id: ModelID.make(model.id),
providerID: ProviderID.make("gitlab"),
name: `Agent Platform (${model.name})`,
api: {
id: model.id,
url,
npm: "gitlab-ai-provider",
},
status: "active" as const,
headers: {},
options: { workflowRef: model.ref },
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: model.context, output: model.output },
capabilities: {
temperature: false,
reasoning: true,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: true },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
release_date: "",
variants: {} as Record<string, Record<string, any>>,
}
m.variants = ProviderTransform.variants(m)
args.models[model.id] = m
}
return args.models
} catch (err) {
log.warn("gitlab model discovery failed", { error: err })
return
}
},
},
},
}
}
+4 -10
View File
@@ -12,15 +12,12 @@ import { Session } from "../session"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import { CopilotAuthPlugin } from "./copilot" import { CopilotAuthPlugin } from "./copilot"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-auth" import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-auth"
import { GitlabPlugin } from "./gitlab"
export namespace Plugin { export namespace Plugin {
const log = Log.create({ service: "plugin" }) const log = Log.create({ service: "plugin" })
const BUILTIN = ["opencode-anthropic-auth@0.0.13"]
// Built-in plugins that are directly imported (not installed from npm) // Built-in plugins that are directly imported (not installed from npm)
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin, GitlabPlugin] const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin]
const state = Instance.state(async () => { const state = Instance.state(async () => {
const client = createOpencodeClient({ const client = createOpencodeClient({
@@ -56,9 +53,6 @@ export namespace Plugin {
let plugins = config.plugin ?? [] let plugins = config.plugin ?? []
if (plugins.length) await Config.waitForDependencies() if (plugins.length) await Config.waitForDependencies()
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
plugins = [...BUILTIN, ...plugins]
}
for (let plugin of plugins) { for (let plugin of plugins) {
// ignore old codex plugin since it is supported first party now // ignore old codex plugin since it is supported first party now
@@ -111,9 +105,9 @@ export namespace Plugin {
}) })
export async function trigger< export async function trigger<
Name extends Exclude<keyof Required<Hooks>, "auth" | "event" | "tool" | "provider">, Name extends Exclude<keyof Required<Hooks>, "auth" | "event" | "tool">,
Input = Parameters<Extract<Required<Hooks>[Name], (...args: any) => any>>[0], Input = Parameters<Required<Hooks>[Name]>[0],
Output = Parameters<Extract<Required<Hooks>[Name], (...args: any) => any>>[1], Output = Parameters<Required<Hooks>[Name]>[1],
>(name: Name, input: Input, output: Output): Promise<Output> { >(name: Name, input: Input, output: Output): Promise<Output> {
if (!name) return output if (!name) return output
for (const hook of await state().then((x) => x.hooks)) { for (const hook of await state().then((x) => x.hooks)) {

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