mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-16 10:54:52 +00:00
Compare commits
73 Commits
fix-azure-
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7341718f92 | ||
|
|
ef90b93205 | ||
|
|
3f7df08be9 | ||
|
|
ef6c26c730 | ||
|
|
8b3b608ba9 | ||
|
|
97918500d4 | ||
|
|
e2c0803962 | ||
|
|
f418fd5632 | ||
|
|
675a46e23e | ||
|
|
150ab07a83 | ||
|
|
6b20838981 | ||
|
|
c8af8f96ce | ||
|
|
5011465c81 | ||
|
|
f6cc228684 | ||
|
|
9f4b73b6a3 | ||
|
|
bd29004831 | ||
|
|
8aa0f9fe95 | ||
|
|
c802695ee9 | ||
|
|
225a769411 | ||
|
|
0e20382396 | ||
|
|
509bc11f81 | ||
|
|
f24207844f | ||
|
|
1ca257e356 | ||
|
|
d4cfbd020d | ||
|
|
581d5208ca | ||
|
|
a427a28fa9 | ||
|
|
0beaf04df5 | ||
|
|
80f1f1b5b8 | ||
|
|
343a564183 | ||
|
|
b0eae5e12f | ||
|
|
702f741267 | ||
|
|
665a843086 | ||
|
|
1508196c0f | ||
|
|
f6243603f8 | ||
|
|
379e40d772 | ||
|
|
6c7e9f6f3a | ||
|
|
48f88af9aa | ||
|
|
60c927cf4f | ||
|
|
069cef8a44 | ||
|
|
cf423d2769 | ||
|
|
62ddb9d3ad | ||
|
|
0b975b01fb | ||
|
|
bb90aa6cb2 | ||
|
|
ce4e47a2e3 | ||
|
|
e3677c2ba2 | ||
|
|
a653a4b887 | ||
|
|
f7edffc11a | ||
|
|
dc16488bd7 | ||
|
|
d7a072dd46 | ||
|
|
5ae91aa810 | ||
|
|
18538e359b | ||
|
|
47577ae857 | ||
|
|
d22b5f026d | ||
|
|
26cdbc20b2 | ||
|
|
360d8dd940 | ||
|
|
426815a829 | ||
|
|
c6286d1bb9 | ||
|
|
710c81984a | ||
|
|
a1dbfb5967 | ||
|
|
64cc4623b5 | ||
|
|
5eae926846 | ||
|
|
cce05c1665 | ||
|
|
34213d4446 | ||
|
|
70aeebf2df | ||
|
|
d6b14e2467 | ||
|
|
6625766350 | ||
|
|
7baf998752 | ||
|
|
1d81335ab5 | ||
|
|
f7d4665e40 | ||
|
|
bbdbc107ae | ||
|
|
0fb0135e51 | ||
|
|
02f2cf439e | ||
|
|
6d42f97644 |
@@ -7,7 +7,7 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
||||
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
...(options.headers instanceof Headers ? Object.fromEntries(options.headers.entries()) : options.headers),
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -28,7 +28,7 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
||||
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
...(options.headers instanceof Headers ? Object.fromEntries(options.headers.entries()) : options.headers),
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -1,10 +1,51 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/oxc-project.github.io/refs/heads/json-schema/src/public/.oxlintrc.schema.json",
|
||||
"options": {
|
||||
"typeAware": true
|
||||
},
|
||||
"categories": {
|
||||
"suspicious": "warn"
|
||||
},
|
||||
"rules": {
|
||||
"typescript/no-base-to-string": "warn",
|
||||
// Effect uses `function*` with Effect.gen/Effect.fnUntraced that don't always yield
|
||||
"require-yield": "off",
|
||||
// SolidJS uses `let ref: T | undefined` for JSX ref bindings assigned at runtime
|
||||
"no-unassigned-vars": "off"
|
||||
"no-unassigned-vars": "off",
|
||||
// SolidJS tracks reactive deps by reading properties inside createEffect
|
||||
"no-unused-expressions": "off",
|
||||
// Intentional control char matching (ANSI escapes, null byte sanitization)
|
||||
"no-control-regex": "off",
|
||||
// SST and plugin tools require triple-slash references
|
||||
"triple-slash-reference": "off",
|
||||
|
||||
// Suspicious category: suppress noisy rules
|
||||
// Effect's nested function* closures inherently shadow outer scope
|
||||
"no-shadow": "off",
|
||||
// Namespace-heavy codebase makes this too noisy
|
||||
"unicorn/consistent-function-scoping": "off",
|
||||
// Opinionated — .sort()/.reverse() mutation is fine in this codebase
|
||||
"unicorn/no-array-sort": "off",
|
||||
"unicorn/no-array-reverse": "off",
|
||||
// Not relevant — this isn't a DOM event handler codebase
|
||||
"unicorn/prefer-add-event-listener": "off",
|
||||
// Bundler handles module resolution
|
||||
"unicorn/require-module-specifiers": "off",
|
||||
// postMessage target origin not relevant for this codebase
|
||||
"unicorn/require-post-message-target-origin": "off",
|
||||
// Side-effectful constructors are intentional in some places
|
||||
"no-new": "off",
|
||||
|
||||
// Type-aware: catch unhandled promises
|
||||
"typescript/no-floating-promises": "warn",
|
||||
// Warn when spreading non-plain objects (Headers, class instances, etc.)
|
||||
"typescript/no-misused-spread": "warn"
|
||||
},
|
||||
"ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts"]
|
||||
"options": {
|
||||
"typeAware": true
|
||||
},
|
||||
"options": {
|
||||
"typeAware": true
|
||||
},
|
||||
"ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts", "**/sdk.gen.ts"]
|
||||
}
|
||||
|
||||
31
bun.lock
31
bun.lock
@@ -20,6 +20,7 @@
|
||||
"glob": "13.0.5",
|
||||
"husky": "9.1.7",
|
||||
"oxlint": "1.60.0",
|
||||
"oxlint-tsgolint": "0.21.0",
|
||||
"prettier": "3.6.2",
|
||||
"semver": "^7.6.0",
|
||||
"sst": "3.18.10",
|
||||
@@ -358,7 +359,6 @@
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "2.5.1",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "2.6.1",
|
||||
@@ -506,17 +506,6 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode-ai/server",
|
||||
"version": "1.4.6",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/shared": {
|
||||
"name": "@opencode-ai/shared",
|
||||
"version": "1.4.6",
|
||||
@@ -534,7 +523,9 @@
|
||||
"zod": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/npmcli__arborist": "6.3.3",
|
||||
"@types/semver": "catalog:",
|
||||
},
|
||||
},
|
||||
@@ -1568,8 +1559,6 @@
|
||||
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"],
|
||||
|
||||
"@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"],
|
||||
|
||||
"@opencode-ai/shared": ["@opencode-ai/shared@workspace:packages/shared"],
|
||||
|
||||
"@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"],
|
||||
@@ -1694,6 +1683,18 @@
|
||||
|
||||
"@oxc-transform/binding-win32-x64-msvc": ["@oxc-transform/binding-win32-x64-msvc@0.96.0", "", { "os": "win32", "cpu": "x64" }, "sha512-0fI0P0W7bSO/GCP/N5dkmtB9vBqCA4ggo1WmXTnxNJVmFFOtcA1vYm1I9jl8fxo+sucW2WnlpnI4fjKdo3JKxA=="],
|
||||
|
||||
"@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@0.21.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-P20j3MLqfwIT+94qGU3htC7dWp4pXGZW1p1p7FRUzu1aopq7c9nPCgf0W/WjktqQ57+iuTq9mbSlwWinl6+H1A=="],
|
||||
|
||||
"@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@0.21.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-81TmmuBcPedEA0MwRmObuQuXnCprS1UiHQWGe7pseqNAJzUWXeAPrayqKTACX92VpruJI+yvY0XJrFp11PpcTA=="],
|
||||
|
||||
"@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@0.21.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-sbjBr6zDduX8rNO0PTjhf7VYLCPWqdijWiMPp8e10qu6Tam1GdaVLaLlX8QrNupTgglO1GvqqgY/jcacWL8a6g=="],
|
||||
|
||||
"@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@0.21.0", "", { "os": "linux", "cpu": "x64" }, "sha512-jNrOcy53R5TJQfrK444Cm60bW9437xDoxPbm3AdvFSo/fhdFMllawc7uZC2Wzr+EAjTkW13K8R4QHzsUdBG9fQ=="],
|
||||
|
||||
"@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@0.21.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-xWeRxJJILDE4b9UqHEWGBxcBc1TUS6zWHhxcyxTZMwf4q3wdKeu0OHYAcwLGJzoSjEIf6FTjyfPiRNil2oqsdg=="],
|
||||
|
||||
"@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.21.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Ob9AA9teI8ckPo1whV1smLr5NrqwgBv/8boDbK0YZG+fKgNGRwr1hBj1ORgFWOQaUBv+5njp5A0RAfJJjQ95QQ=="],
|
||||
|
||||
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.60.0", "", { "os": "android", "cpu": "arm" }, "sha512-YdeJKaZckDQL1qa62a1aKq/goyq48aX3yOxaaWqWb4sau4Ee4IiLbamftNLU3zbePky6QsDj6thnSSzHRBjDfA=="],
|
||||
|
||||
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.60.0", "", { "os": "android", "cpu": "arm64" }, "sha512-7ANS7PpXCfq84xZQ8E5WPs14gwcuPcl+/8TFNXfpSu0CQBXz3cUo2fDpHT8v8HJN+Ut02eacvMAzTnc9s6X4tw=="],
|
||||
@@ -4114,6 +4115,8 @@
|
||||
|
||||
"oxlint": ["oxlint@1.60.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.60.0", "@oxlint/binding-android-arm64": "1.60.0", "@oxlint/binding-darwin-arm64": "1.60.0", "@oxlint/binding-darwin-x64": "1.60.0", "@oxlint/binding-freebsd-x64": "1.60.0", "@oxlint/binding-linux-arm-gnueabihf": "1.60.0", "@oxlint/binding-linux-arm-musleabihf": "1.60.0", "@oxlint/binding-linux-arm64-gnu": "1.60.0", "@oxlint/binding-linux-arm64-musl": "1.60.0", "@oxlint/binding-linux-ppc64-gnu": "1.60.0", "@oxlint/binding-linux-riscv64-gnu": "1.60.0", "@oxlint/binding-linux-riscv64-musl": "1.60.0", "@oxlint/binding-linux-s390x-gnu": "1.60.0", "@oxlint/binding-linux-x64-gnu": "1.60.0", "@oxlint/binding-linux-x64-musl": "1.60.0", "@oxlint/binding-openharmony-arm64": "1.60.0", "@oxlint/binding-win32-arm64-msvc": "1.60.0", "@oxlint/binding-win32-ia32-msvc": "1.60.0", "@oxlint/binding-win32-x64-msvc": "1.60.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.18.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-tnRzTWiWJ9pg3ftRWnD0+Oqh78L6ZSwcEudvCZaER0PIqiAnNyXj5N1dPwjmNpDalkKS9m/WMLN1CTPUBPmsgw=="],
|
||||
|
||||
"oxlint-tsgolint": ["oxlint-tsgolint@0.21.0", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.21.0", "@oxlint-tsgolint/darwin-x64": "0.21.0", "@oxlint-tsgolint/linux-arm64": "0.21.0", "@oxlint-tsgolint/linux-x64": "0.21.0", "@oxlint-tsgolint/win32-arm64": "0.21.0", "@oxlint-tsgolint/win32-x64": "0.21.0" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-HiWPhANwRnN1pZJQ2SgNB3WRR+1etLJHmRzQ/MJhyINsEIaOUCjxhlXJKbEaVUwdnyXwRWqo/P9Fx21lz0/mSg=="],
|
||||
|
||||
"p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="],
|
||||
|
||||
"p-defer": ["p-defer@3.0.0", "", {}, "sha512-ugZxsxmtTln604yeYd29EGrNhazN2lywetzpKhfmQjW/VJmhpDmWbiX+h0zL8V91R0UXkhb3KtPmyq9PZw3aYw=="],
|
||||
|
||||
@@ -281,7 +281,7 @@ async function assertOpencodeConnected() {
|
||||
})
|
||||
connected = true
|
||||
break
|
||||
} catch (e) {}
|
||||
} catch {}
|
||||
await sleep(300)
|
||||
} while (retry++ < 30)
|
||||
|
||||
@@ -513,7 +513,7 @@ async function subscribeSessionEvents() {
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
let text = ""
|
||||
;(async () => {
|
||||
void (async () => {
|
||||
while (true) {
|
||||
try {
|
||||
const { done, value } = await reader.read()
|
||||
@@ -542,7 +542,7 @@ async function subscribeSessionEvents() {
|
||||
? JSON.stringify(part.state.input)
|
||||
: "Unknown"
|
||||
console.log()
|
||||
console.log(color + `|`, "\x1b[0m\x1b[2m" + ` ${tool.padEnd(7, " ")}`, "", "\x1b[0m" + title)
|
||||
console.log(`${color}|`, `\x1b[0m\x1b[2m ${tool.padEnd(7, " ")}`, "", `\x1b[0m${title}`)
|
||||
}
|
||||
|
||||
if (part.type === "text") {
|
||||
@@ -561,7 +561,7 @@ async function subscribeSessionEvents() {
|
||||
if (evt.properties.info.id !== session.id) continue
|
||||
session = evt.properties.info
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
@@ -576,7 +576,7 @@ async function subscribeSessionEvents() {
|
||||
async function summarize(response: string) {
|
||||
try {
|
||||
return await chat(`Summarize the following in less than 40 characters:\n\n${response}`)
|
||||
} catch (e) {
|
||||
} catch {
|
||||
if (isScheduleEvent()) {
|
||||
return "Scheduled task changes"
|
||||
}
|
||||
@@ -776,7 +776,7 @@ async function assertPermissions() {
|
||||
console.log(` permission: ${permission}`)
|
||||
} catch (error) {
|
||||
console.error(`Failed to check permissions: ${error}`)
|
||||
throw new Error(`Failed to check permissions for user ${actor}: ${error}`)
|
||||
throw new Error(`Failed to check permissions for user ${actor}: ${error}`, { cause: error })
|
||||
}
|
||||
|
||||
if (!["admin", "write"].includes(permission)) throw new Error(`User ${actor} does not have write permissions`)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { SECRET } from "./secret"
|
||||
import { domain, shortDomain } from "./stage"
|
||||
import { shortDomain } from "./stage"
|
||||
|
||||
const storage = new sst.cloudflare.Bucket("EnterpriseStorage")
|
||||
|
||||
const teams = new sst.cloudflare.x.SolidStart("Teams", {
|
||||
new sst.cloudflare.x.SolidStart("Teams", {
|
||||
domain: shortDomain,
|
||||
path: "packages/enterprise",
|
||||
buildCommand: "bun run build:cloudflare",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-PvIx2g1J5QIUIzkz2ABaAM4K/k/+xlBPDUExoOJNNuo=",
|
||||
"aarch64-linux": "sha256-YTAL+P13L5hgNJdDSiBED/UNa5zdTntnUUYDYL+Jdzo=",
|
||||
"aarch64-darwin": "sha256-y2VCJifYAp+H0lpDcJ0QfKNMG00Q/usFElaUIpdc8Vs=",
|
||||
"x86_64-darwin": "sha256-yz8edIlqLp06Y95ad8YjKz5azP7YATPle4TcDx6lM+U="
|
||||
"x86_64-linux": "sha256-NJAK+cPjwn+2ojDLyyDmBQyx2pD+rILetp7VCylgjek=",
|
||||
"aarch64-linux": "sha256-q8NTtFQJoyM7TTvErGA6RtmUscxoZKD/mj9N6S5YhkA=",
|
||||
"aarch64-darwin": "sha256-/ccoSZNLef6j9j14HzpVqhKCR+czM3mhPKPH51mHO24=",
|
||||
"x86_64-darwin": "sha256-6Pd10sMHL/5ZoWNvGPwPn4/AIs1TKjt/3gFyrVpBaE0="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
"glob": "13.0.5",
|
||||
"husky": "9.1.7",
|
||||
"oxlint": "1.60.0",
|
||||
"oxlint-tsgolint": "0.21.0",
|
||||
"prettier": "3.6.2",
|
||||
"semver": "^7.6.0",
|
||||
"sst": "3.18.10",
|
||||
|
||||
@@ -180,8 +180,8 @@ describe("SerializeAddon", () => {
|
||||
await writeAndWait(term, input)
|
||||
|
||||
const origLine = term.buffer.active.getLine(0)
|
||||
const origFg = origLine!.getCell(0)!.getFgColor()
|
||||
const origBg = origLine!.getCell(0)!.getBgColor()
|
||||
const _origFg = origLine!.getCell(0)!.getFgColor()
|
||||
const _origBg = origLine!.getCell(0)!.getBgColor()
|
||||
expect(origLine!.getCell(0)!.isBold()).toBe(1)
|
||||
|
||||
const serialized = addon.serialize({ range: { start: 0, end: 0 } })
|
||||
|
||||
@@ -258,8 +258,8 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
||||
}
|
||||
|
||||
protected _beforeSerialize(rows: number, start: number, _end: number): void {
|
||||
this._allRows = new Array<string>(rows)
|
||||
this._allRowSeparators = new Array<string>(rows)
|
||||
this._allRows = Array.from<string>({ length: rows })
|
||||
this._allRowSeparators = Array.from<string>({ length: rows })
|
||||
this._rowIndex = 0
|
||||
|
||||
this._currentRow = ""
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { type Duration, Effect } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
type Component,
|
||||
createMemo,
|
||||
@@ -121,10 +121,10 @@ function SessionProviders(props: ParentProps) {
|
||||
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
|
||||
return (
|
||||
<AppShellProviders>
|
||||
<Suspense fallback={<Loading />}>
|
||||
{props.appChildren}
|
||||
{props.children}
|
||||
</Suspense>
|
||||
{/*<Suspense fallback={<Loading />}>*/}
|
||||
{props.appChildren}
|
||||
{props.children}
|
||||
{/*</Suspense>*/}
|
||||
</AppShellProviders>
|
||||
)
|
||||
}
|
||||
@@ -156,11 +156,6 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
||||
)
|
||||
}
|
||||
|
||||
const effectMinDuration =
|
||||
(duration: Duration.Input) =>
|
||||
<A, E, R>(e: Effect.Effect<A, E, R>) =>
|
||||
Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0]))
|
||||
|
||||
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
|
||||
const server = useServer()
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
@@ -189,32 +184,41 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
|
||||
)
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
|
||||
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/*<Show
|
||||
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
|
||||
fallback={
|
||||
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
|
||||
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
||||
</div>
|
||||
}
|
||||
>*/}
|
||||
{checkMode() === "blocking" ? startupHealthCheck() : startupHealthCheck.latest}
|
||||
<Show
|
||||
when={startupHealthCheck()}
|
||||
fallback={
|
||||
<ConnectionError
|
||||
onRetry={() => {
|
||||
if (checkMode() === "background") healthCheckActions.refetch()
|
||||
if (checkMode() === "background") void healthCheckActions.refetch()
|
||||
}}
|
||||
onServerSelected={(key) => {
|
||||
setCheckMode("blocking")
|
||||
server.setActive(key)
|
||||
healthCheckActions.refetch()
|
||||
void healthCheckActions.refetch()
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</Show>
|
||||
</Show>
|
||||
{/*</Show>*/}
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -327,7 +327,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
if (loading()) return
|
||||
if (methods().length === 1) {
|
||||
auto = true
|
||||
selectMethod(0)
|
||||
void selectMethod(0)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -373,7 +373,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
key={(m) => m?.label}
|
||||
onSelect={async (selected, index) => {
|
||||
if (!selected) return
|
||||
selectMethod(index)
|
||||
void selectMethod(index)
|
||||
}}
|
||||
>
|
||||
{(i) => (
|
||||
|
||||
@@ -348,8 +348,8 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
|
||||
const open = (path: string) => {
|
||||
const value = file.tab(path)
|
||||
tabs().open(value)
|
||||
file.load(path)
|
||||
void tabs().open(value)
|
||||
void file.load(path)
|
||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||
layout.fileTree.setTab("all")
|
||||
props.onOpenFile?.(path)
|
||||
|
||||
@@ -344,7 +344,7 @@ export function DialogSelectServer() {
|
||||
|
||||
createEffect(() => {
|
||||
items()
|
||||
refreshHealth()
|
||||
void refreshHealth()
|
||||
const interval = setInterval(refreshHealth, 10_000)
|
||||
onCleanup(() => clearInterval(interval))
|
||||
})
|
||||
@@ -498,7 +498,7 @@ export function DialogSelectServer() {
|
||||
async function handleRemove(url: ServerConnection.Key) {
|
||||
server.remove(url)
|
||||
if ((await platform.getDefaultServer?.()) === url) {
|
||||
platform.setDefaultServer?.(null)
|
||||
void platform.setDefaultServer?.(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -536,7 +536,7 @@ export function DialogSelectServer() {
|
||||
items={sortedItems}
|
||||
key={(x) => x.http.url}
|
||||
onSelect={(x) => {
|
||||
if (x) select(x)
|
||||
if (x) void select(x)
|
||||
}}
|
||||
divider={true}
|
||||
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
Switch,
|
||||
untrack,
|
||||
type ComponentProps,
|
||||
type JSXElement,
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
|
||||
@@ -54,6 +54,8 @@ import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
||||
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
||||
import { promptPlaceholder } from "./prompt-input/placeholder"
|
||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
import { loadAgentsQuery, loadProvidersQuery } from "@/context/global-sync/bootstrap"
|
||||
|
||||
interface PromptInputProps {
|
||||
class?: string
|
||||
@@ -100,6 +102,7 @@ const NON_EMPTY_TEXT = /[^\s\u200B]/
|
||||
|
||||
export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const sdk = useSDK()
|
||||
|
||||
const sync = useSync()
|
||||
const local = useLocal()
|
||||
const files = useFile()
|
||||
@@ -212,9 +215,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||
layout.fileTree.setTab("all")
|
||||
const tab = files.tab(item.path)
|
||||
tabs().open(tab)
|
||||
void tabs().open(tab)
|
||||
tabs().setActive(tab)
|
||||
Promise.resolve(files.load(item.path)).finally(() => queueCommentFocus())
|
||||
void Promise.resolve(files.load(item.path)).finally(() => queueCommentFocus())
|
||||
}
|
||||
|
||||
const recent = createMemo(() => {
|
||||
@@ -1139,7 +1142,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
|
||||
if (working()) {
|
||||
abort()
|
||||
void abort()
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
@@ -1205,7 +1208,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
return
|
||||
}
|
||||
if (working()) {
|
||||
abort()
|
||||
void abort()
|
||||
event.preventDefault()
|
||||
}
|
||||
return
|
||||
@@ -1245,10 +1248,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
) {
|
||||
return
|
||||
}
|
||||
handleSubmit(event)
|
||||
void handleSubmit(event)
|
||||
}
|
||||
}
|
||||
|
||||
const agentsQuery = useQuery(() => loadAgentsQuery(sdk.directory))
|
||||
const agentsLoading = () => agentsQuery.isLoading
|
||||
|
||||
const globalProvidersQuery = useQuery(() => loadProvidersQuery(null))
|
||||
const providersQuery = useQuery(() => loadProvidersQuery(sdk.directory))
|
||||
|
||||
const providersLoading = () => agentsLoading() || providersQuery.isLoading || globalProvidersQuery.isLoading
|
||||
|
||||
return (
|
||||
<div class="relative size-full _max-h-[320px] flex flex-col gap-0">
|
||||
<PromptPopover
|
||||
@@ -1444,53 +1455,89 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
|
||||
<div class="size-4 shrink-0" />
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<div data-component="prompt-agent-control">
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.agent.cycle")}
|
||||
keybind={command.keybind("agent.cycle")}
|
||||
>
|
||||
<Select
|
||||
size="normal"
|
||||
options={agentNames()}
|
||||
current={local.agent.current()?.name ?? ""}
|
||||
onSelect={(value) => {
|
||||
local.agent.set(value)
|
||||
restoreFocus()
|
||||
}}
|
||||
class="capitalize max-w-[160px] text-text-base"
|
||||
valueClass="truncate text-13-regular text-text-base"
|
||||
triggerStyle={control()}
|
||||
triggerProps={{ "data-action": "prompt-agent" }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<Show when={store.mode !== "shell"}>
|
||||
<div data-component="prompt-model-control">
|
||||
<Show
|
||||
when={providers.paid().length > 0}
|
||||
fallback={
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1 h-7">
|
||||
<Show when={!agentsLoading()}>
|
||||
<div data-component="prompt-agent-control">
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.agent.cycle")}
|
||||
keybind={command.keybind("agent.cycle")}
|
||||
>
|
||||
<Select
|
||||
size="normal"
|
||||
options={agentNames()}
|
||||
current={local.agent.current()?.name ?? ""}
|
||||
onSelect={(value) => {
|
||||
local.agent.set(value)
|
||||
restoreFocus()
|
||||
}}
|
||||
class="capitalize max-w-[160px] text-text-base"
|
||||
valueClass="truncate text-13-regular text-text-base"
|
||||
triggerStyle={control()}
|
||||
triggerProps={{ "data-action": "prompt-agent" }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!providersLoading()}>
|
||||
<Show when={store.mode !== "shell"}>
|
||||
<div data-component="prompt-model-control">
|
||||
<Show
|
||||
when={providers.paid().length > 0}
|
||||
fallback={
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.model.choose")}
|
||||
keybind={command.keybind("model.choose")}
|
||||
>
|
||||
<Button
|
||||
data-action="prompt-model"
|
||||
as="div"
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
|
||||
style={control()}
|
||||
onClick={() => {
|
||||
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
id={local.model.current()?.provider?.id ?? ""}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
</Show>
|
||||
<span class="truncate">
|
||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.model.choose")}
|
||||
keybind={command.keybind("model.choose")}
|
||||
>
|
||||
<Button
|
||||
data-action="prompt-model"
|
||||
as="div"
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
|
||||
style={control()}
|
||||
onClick={() => {
|
||||
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
|
||||
})
|
||||
<ModelSelectorPopover
|
||||
model={local.model}
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
size: "normal",
|
||||
style: control(),
|
||||
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
|
||||
"data-action": "prompt-model",
|
||||
}}
|
||||
onClose={restoreFocus}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
@@ -1503,67 +1550,35 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||
</Button>
|
||||
</ModelSelectorPopover>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
>
|
||||
</Show>
|
||||
</div>
|
||||
<div data-component="prompt-variant-control">
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.model.choose")}
|
||||
keybind={command.keybind("model.choose")}
|
||||
title={language.t("command.model.variant.cycle")}
|
||||
keybind={command.keybind("model.variant.cycle")}
|
||||
>
|
||||
<ModelSelectorPopover
|
||||
model={local.model}
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
size: "normal",
|
||||
style: control(),
|
||||
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
|
||||
"data-action": "prompt-model",
|
||||
<Select
|
||||
size="normal"
|
||||
options={variants()}
|
||||
current={local.model.variant.current() ?? "default"}
|
||||
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||
onSelect={(value) => {
|
||||
local.model.variant.set(value === "default" ? undefined : value)
|
||||
restoreFocus()
|
||||
}}
|
||||
onClose={restoreFocus}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
id={local.model.current()?.provider?.id ?? ""}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
</Show>
|
||||
<span class="truncate">
|
||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||
</ModelSelectorPopover>
|
||||
class="capitalize max-w-[160px] text-text-base"
|
||||
valueClass="truncate text-13-regular text-text-base"
|
||||
triggerStyle={control()}
|
||||
triggerProps={{ "data-action": "prompt-model-variant" }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
</div>
|
||||
<div data-component="prompt-variant-control">
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.model.variant.cycle")}
|
||||
keybind={command.keybind("model.variant.cycle")}
|
||||
>
|
||||
<Select
|
||||
size="normal"
|
||||
options={variants()}
|
||||
current={local.model.variant.current() ?? "default"}
|
||||
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||
onSelect={(value) => {
|
||||
local.model.variant.set(value === "default" ? undefined : value)
|
||||
restoreFocus()
|
||||
}}
|
||||
class="capitalize max-w-[160px] text-text-base"
|
||||
valueClass="truncate text-13-regular text-text-base"
|
||||
triggerStyle={control()}
|
||||
triggerProps={{ "data-action": "prompt-model-variant" }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -295,7 +295,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const mode = input.mode()
|
||||
|
||||
if (text.trim().length === 0 && images.length === 0 && input.commentCount() === 0) {
|
||||
if (input.working()) abort()
|
||||
if (input.working()) void abort()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ function openSessionContext(args: {
|
||||
}) {
|
||||
if (!args.view.reviewPanel.opened()) args.view.reviewPanel.open()
|
||||
if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all")
|
||||
args.tabs.open("context")
|
||||
void args.tabs.open("context")
|
||||
args.tabs.setActive("context")
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { getFilename } from "@opencode-ai/shared/util/path"
|
||||
import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
|
||||
import { createEffect, createMemo, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { useCommand } from "@/context/command"
|
||||
|
||||
@@ -44,7 +44,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
||||
|
||||
const close = () => {
|
||||
const count = terminal.all().length
|
||||
terminal.close(props.terminal.id)
|
||||
void terminal.close(props.terminal.id)
|
||||
if (count === 1) {
|
||||
props.onClose?.()
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const scrollY = typeof local.pty.scrollY === "number" ? local.pty.scrollY : undefined
|
||||
let ws: WebSocket | undefined
|
||||
let term: Term | undefined
|
||||
let ghostty: Ghostty
|
||||
let _ghostty: Ghostty
|
||||
let serializeAddon: SerializeAddon
|
||||
let fitAddon: FitAddon
|
||||
let handleResize: () => void
|
||||
@@ -372,7 +372,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
ghostty = g
|
||||
_ghostty = g
|
||||
term = t
|
||||
output = terminalWriter((data, done) =>
|
||||
t.write(data, () => {
|
||||
@@ -415,7 +415,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
if (local.autoFocus !== false) focusTerminal()
|
||||
|
||||
if (typeof document !== "undefined" && document.fonts) {
|
||||
document.fonts.ready.then(scheduleFit)
|
||||
void document.fonts.ready.then(scheduleFit)
|
||||
}
|
||||
|
||||
const onResize = t.onResize((size) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js"
|
||||
import { createEffect, createMemo, Show, untrack } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
@@ -252,41 +252,48 @@ export function Titlebar() {
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={hasProjects()}>
|
||||
<div
|
||||
class="flex items-center gap-0 transition-transform"
|
||||
classList={{
|
||||
"translate-x-0": !layout.sidebar.opened(),
|
||||
"-translate-x-[36px]": layout.sidebar.opened(),
|
||||
"duration-180 ease-out": !layout.sidebar.opened(),
|
||||
"duration-180 ease-in": layout.sidebar.opened(),
|
||||
}}
|
||||
>
|
||||
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="chevron-left"
|
||||
class="titlebar-icon w-6 h-6 p-0 box-border"
|
||||
disabled={!canBack()}
|
||||
onClick={back}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="chevron-right"
|
||||
class="titlebar-icon w-6 h-6 p-0 box-border"
|
||||
disabled={!canForward()}
|
||||
onClick={forward}
|
||||
aria-label={language.t("common.goForward")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
class="flex items-center shrink-0"
|
||||
classList={{
|
||||
"translate-x-0": !layout.sidebar.opened(),
|
||||
"-translate-x-[36px]": layout.sidebar.opened(),
|
||||
"duration-180 ease-out": !layout.sidebar.opened(),
|
||||
"duration-180 ease-in": layout.sidebar.opened(),
|
||||
}}
|
||||
>
|
||||
<Show when={hasProjects()}>
|
||||
<div class="flex items-center gap-0 transition-transform">
|
||||
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="chevron-left"
|
||||
class="titlebar-icon w-6 h-6 p-0 box-border"
|
||||
disabled={!canBack()}
|
||||
onClick={back}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="chevron-right"
|
||||
class="titlebar-icon w-6 h-6 p-0 box-border"
|
||||
disabled={!canForward()}
|
||||
onClick={forward}
|
||||
aria-label={language.t("common.goForward")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Show>
|
||||
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
||||
{["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && (
|
||||
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
|
||||
{import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex items-center justify-center pointer-events-none">
|
||||
|
||||
@@ -128,6 +128,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
if (started) return run
|
||||
started = true
|
||||
run = (async () => {
|
||||
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
|
||||
while (!abort.signal.aborted && started) {
|
||||
attempt = new AbortController()
|
||||
lastEventAt = Date.now()
|
||||
|
||||
@@ -26,6 +26,7 @@ import type { ProjectMeta } from "./global-sync/types"
|
||||
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
|
||||
import { sanitizeProject } from "./global-sync/utils"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { queryOptions, skipToken, useQueryClient } from "@tanstack/solid-query"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
@@ -41,6 +42,9 @@ type GlobalStore = {
|
||||
reload: undefined | "pending" | "complete"
|
||||
}
|
||||
|
||||
export const loadSessionsQuery = (directory: string) =>
|
||||
queryOptions<null>({ queryKey: [directory, "loadSessions"], queryFn: skipToken })
|
||||
|
||||
function createGlobalSync() {
|
||||
const globalSDK = useGlobalSDK()
|
||||
const language = useLanguage()
|
||||
@@ -67,6 +71,7 @@ function createGlobalSync() {
|
||||
config: {},
|
||||
reload: undefined,
|
||||
})
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
let active = true
|
||||
let projectWritten = false
|
||||
@@ -198,46 +203,53 @@ function createGlobalSync() {
|
||||
}
|
||||
|
||||
const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
|
||||
const promise = loadRootSessionsWithFallback({
|
||||
directory,
|
||||
limit,
|
||||
list: (query) => globalSDK.client.session.list(query),
|
||||
})
|
||||
.then((x) => {
|
||||
const nonArchived = (x.data ?? [])
|
||||
.filter((s) => !!s?.id)
|
||||
.filter((s) => !s.time?.archived)
|
||||
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||
const limit = store.limit
|
||||
const childSessions = store.session.filter((s) => !!s.parentID)
|
||||
const sessions = trimSessions([...nonArchived, ...childSessions], {
|
||||
limit,
|
||||
permission: store.permission,
|
||||
})
|
||||
setStore(
|
||||
"sessionTotal",
|
||||
estimateRootSessionTotal({
|
||||
count: nonArchived.length,
|
||||
limit: x.limit,
|
||||
limited: x.limited,
|
||||
}),
|
||||
)
|
||||
setStore("session", reconcile(sessions, { key: "id" }))
|
||||
cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
|
||||
sessionMeta.set(directory, { limit })
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to load sessions", err)
|
||||
const project = getFilename(directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.session.listFailed.title", { project }),
|
||||
description: formatServerError(err, language.t),
|
||||
})
|
||||
const promise = queryClient
|
||||
.ensureQueryData({
|
||||
...loadSessionsQuery(directory),
|
||||
queryFn: () =>
|
||||
loadRootSessionsWithFallback({
|
||||
directory,
|
||||
limit,
|
||||
list: (query) => globalSDK.client.session.list(query),
|
||||
})
|
||||
.then((x) => {
|
||||
const nonArchived = (x.data ?? [])
|
||||
.filter((s) => !!s?.id)
|
||||
.filter((s) => !s.time?.archived)
|
||||
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||
const limit = store.limit
|
||||
const childSessions = store.session.filter((s) => !!s.parentID)
|
||||
const sessions = trimSessions([...nonArchived, ...childSessions], {
|
||||
limit,
|
||||
permission: store.permission,
|
||||
})
|
||||
setStore(
|
||||
"sessionTotal",
|
||||
estimateRootSessionTotal({
|
||||
count: nonArchived.length,
|
||||
limit: x.limit,
|
||||
limited: x.limited,
|
||||
}),
|
||||
)
|
||||
setStore("session", reconcile(sessions, { key: "id" }))
|
||||
cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
|
||||
sessionMeta.set(directory, { limit })
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to load sessions", err)
|
||||
const project = getFilename(directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.session.listFailed.title", { project }),
|
||||
description: formatServerError(err, language.t),
|
||||
})
|
||||
})
|
||||
.then(() => null),
|
||||
})
|
||||
.then(() => {})
|
||||
|
||||
sessionLoads.set(directory, promise)
|
||||
promise.finally(() => {
|
||||
void promise.finally(() => {
|
||||
sessionLoads.delete(directory)
|
||||
children.unpin(directory)
|
||||
})
|
||||
@@ -250,8 +262,9 @@ function createGlobalSync() {
|
||||
if (pending) return pending
|
||||
|
||||
children.pin(directory)
|
||||
const promise = (async () => {
|
||||
const promise = Promise.resolve().then(async () => {
|
||||
const child = children.ensureChild(directory)
|
||||
child[1]("bootstrapPromise", promise!)
|
||||
const cache = children.vcsCache.get(directory)
|
||||
if (!cache) return
|
||||
const sdk = sdkFor(directory)
|
||||
@@ -269,11 +282,12 @@ function createGlobalSync() {
|
||||
vcsCache: cache,
|
||||
loadSessions,
|
||||
translate: language.t,
|
||||
queryClient,
|
||||
})
|
||||
})()
|
||||
})
|
||||
|
||||
booting.set(directory, promise)
|
||||
promise.finally(() => {
|
||||
void promise.finally(() => {
|
||||
booting.delete(directory)
|
||||
children.unpin(directory)
|
||||
})
|
||||
@@ -317,7 +331,7 @@ function createGlobalSync() {
|
||||
setSessionTodo,
|
||||
vcsCache: children.vcsCache.get(directory),
|
||||
loadLsp: () => {
|
||||
sdkFor(directory)
|
||||
void sdkFor(directory)
|
||||
.lsp.status()
|
||||
.then((x) => {
|
||||
setStore("lsp", x.data ?? [])
|
||||
@@ -346,6 +360,7 @@ function createGlobalSync() {
|
||||
translate: language.t,
|
||||
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
||||
setGlobalStore: setBootStore,
|
||||
queryClient,
|
||||
})
|
||||
bootedAt = Date.now()
|
||||
} finally {
|
||||
@@ -359,13 +374,13 @@ function createGlobalSync() {
|
||||
eventFrame = undefined
|
||||
eventTimer = setTimeout(() => {
|
||||
eventTimer = undefined
|
||||
globalSDK.event.start()
|
||||
void globalSDK.event.start()
|
||||
}, 0)
|
||||
})
|
||||
} else {
|
||||
eventTimer = setTimeout(() => {
|
||||
eventTimer = undefined
|
||||
globalSDK.event.start()
|
||||
void globalSDK.event.start()
|
||||
}, 0)
|
||||
}
|
||||
void bootstrap()
|
||||
|
||||
@@ -18,6 +18,8 @@ import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { QueryClient, queryOptions, skipToken } from "@tanstack/solid-query"
|
||||
import { loadSessionsQuery } from "../global-sync"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
@@ -65,28 +67,13 @@ function runAll(list: Array<() => Promise<unknown>>) {
|
||||
return Promise.allSettled(list.map((item) => item()))
|
||||
}
|
||||
|
||||
function showErrors(input: {
|
||||
errors: unknown[]
|
||||
title: string
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
formatMoreCount: (count: number) => string
|
||||
}) {
|
||||
if (input.errors.length === 0) return
|
||||
const message = formatServerError(input.errors[0], input.translate)
|
||||
const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : ""
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.title,
|
||||
description: message + more,
|
||||
})
|
||||
}
|
||||
|
||||
export async function bootstrapGlobal(input: {
|
||||
globalSDK: OpencodeClient
|
||||
requestFailedTitle: string
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
formatMoreCount: (count: number) => string
|
||||
setGlobalStore: SetStoreFunction<GlobalStore>
|
||||
queryClient: QueryClient
|
||||
}) {
|
||||
const fast = [
|
||||
() =>
|
||||
@@ -96,11 +83,16 @@ export async function bootstrapGlobal(input: {
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.globalSDK.provider.list().then((x) => {
|
||||
input.setGlobalStore("provider", normalizeProviderList(x.data!))
|
||||
}),
|
||||
),
|
||||
input.queryClient.fetchQuery({
|
||||
...loadProvidersQuery(null),
|
||||
queryFn: () =>
|
||||
retry(() =>
|
||||
input.globalSDK.provider.list().then((x) => {
|
||||
input.setGlobalStore("provider", normalizeProviderList(x.data!))
|
||||
return null
|
||||
}),
|
||||
),
|
||||
}),
|
||||
]
|
||||
|
||||
const slow = [
|
||||
@@ -188,6 +180,12 @@ function warmSessions(input: {
|
||||
).then(() => undefined)
|
||||
}
|
||||
|
||||
export const loadProvidersQuery = (directory: string | null) =>
|
||||
queryOptions<null>({ queryKey: [directory, "providers"], queryFn: skipToken })
|
||||
|
||||
export const loadAgentsQuery = (directory: string | null) =>
|
||||
queryOptions<null>({ queryKey: [directory, "agents"], queryFn: skipToken })
|
||||
|
||||
export async function bootstrapDirectory(input: {
|
||||
directory: string
|
||||
sdk: OpencodeClient
|
||||
@@ -202,6 +200,7 @@ export async function bootstrapDirectory(input: {
|
||||
project: Project[]
|
||||
provider: ProviderListResponse
|
||||
}
|
||||
queryClient: QueryClient
|
||||
}) {
|
||||
const loading = input.store.status !== "complete"
|
||||
const seededProject = projectID(input.directory, input.global.project)
|
||||
@@ -223,97 +222,7 @@ export async function bootstrapDirectory(input: {
|
||||
input.setStore("lsp", [])
|
||||
if (loading) input.setStore("status", "partial")
|
||||
|
||||
const fast = [
|
||||
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
|
||||
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
|
||||
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
|
||||
]
|
||||
|
||||
const slow = [
|
||||
() =>
|
||||
seededProject
|
||||
? Promise.resolve()
|
||||
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
|
||||
() =>
|
||||
seededPath
|
||||
? Promise.resolve()
|
||||
: retry(() =>
|
||||
input.sdk.path.get().then((x) => {
|
||||
input.setStore("path", x.data!)
|
||||
const next = projectID(x.data?.directory ?? input.directory, input.global.project)
|
||||
if (next) input.setStore("project", next)
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.vcs.get().then((x) => {
|
||||
const next = x.data ?? input.store.vcs
|
||||
input.setStore("vcs", next)
|
||||
if (next) input.vcsCache.setStore("value", next)
|
||||
}),
|
||||
),
|
||||
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.permission.list().then((x) => {
|
||||
const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id)
|
||||
const grouped = groupBySession(
|
||||
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
|
||||
)
|
||||
return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(input.store.permission)) {
|
||||
if (grouped[sessionID]) continue
|
||||
input.setStore("permission", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||
input.setStore(
|
||||
"permission",
|
||||
sessionID,
|
||||
reconcile(
|
||||
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.question.list().then((x) => {
|
||||
const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
|
||||
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
||||
return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(input.store.question)) {
|
||||
if (grouped[sessionID]) continue
|
||||
input.setStore("question", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||
input.setStore(
|
||||
"question",
|
||||
sessionID,
|
||||
reconcile(
|
||||
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.mcp.status().then((x) => {
|
||||
input.setStore("mcp", x.data!)
|
||||
input.setStore("mcp_ready", true)
|
||||
}),
|
||||
),
|
||||
]
|
||||
const fast = [() => Promise.resolve(input.loadSessions(input.directory))]
|
||||
|
||||
const errs = errors(await runAll(fast))
|
||||
if (errs.length > 0) {
|
||||
@@ -326,36 +235,138 @@ export async function bootstrapDirectory(input: {
|
||||
})
|
||||
}
|
||||
|
||||
await waitForPaint()
|
||||
const slowErrs = errors(await runAll(slow))
|
||||
if (slowErrs.length > 0) {
|
||||
console.error("Failed to finish bootstrap instance", slowErrs[0])
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||
description: formatServerError(slowErrs[0], input.translate),
|
||||
})
|
||||
}
|
||||
;(async () => {
|
||||
const slow = [
|
||||
() =>
|
||||
input.queryClient.ensureQueryData({
|
||||
...loadAgentsQuery(input.directory),
|
||||
queryFn: () =>
|
||||
retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))).then(
|
||||
() => null,
|
||||
),
|
||||
}),
|
||||
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
|
||||
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
|
||||
() =>
|
||||
seededProject
|
||||
? Promise.resolve()
|
||||
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
|
||||
() =>
|
||||
seededPath
|
||||
? Promise.resolve()
|
||||
: retry(() =>
|
||||
input.sdk.path.get().then((x) => {
|
||||
input.setStore("path", x.data!)
|
||||
const next = projectID(x.data?.directory ?? input.directory, input.global.project)
|
||||
if (next) input.setStore("project", next)
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.vcs.get().then((x) => {
|
||||
const next = x.data ?? input.store.vcs
|
||||
input.setStore("vcs", next)
|
||||
if (next) input.vcsCache.setStore("value", next)
|
||||
}),
|
||||
),
|
||||
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.permission.list().then((x) => {
|
||||
const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id)
|
||||
const grouped = groupBySession(
|
||||
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
|
||||
)
|
||||
return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(input.store.permission)) {
|
||||
if (grouped[sessionID]) continue
|
||||
input.setStore("permission", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||
input.setStore(
|
||||
"permission",
|
||||
sessionID,
|
||||
reconcile(
|
||||
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.question.list().then((x) => {
|
||||
const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
|
||||
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
||||
return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(input.store.question)) {
|
||||
if (grouped[sessionID]) continue
|
||||
input.setStore("question", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||
input.setStore(
|
||||
"question",
|
||||
sessionID,
|
||||
reconcile(
|
||||
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.mcp.status().then((x) => {
|
||||
input.setStore("mcp", x.data!)
|
||||
input.setStore("mcp_ready", true)
|
||||
}),
|
||||
),
|
||||
]
|
||||
|
||||
if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete")
|
||||
|
||||
const rev = (providerRev.get(input.directory) ?? 0) + 1
|
||||
providerRev.set(input.directory, rev)
|
||||
void retry(() => input.sdk.provider.list())
|
||||
.then((x) => {
|
||||
if (providerRev.get(input.directory) !== rev) return
|
||||
input.setStore("provider", normalizeProviderList(x.data!))
|
||||
input.setStore("provider_ready", true)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (providerRev.get(input.directory) !== rev) return
|
||||
console.error("Failed to refresh provider list", err)
|
||||
await waitForPaint()
|
||||
const slowErrs = errors(await runAll(slow))
|
||||
if (slowErrs.length > 0) {
|
||||
console.error("Failed to finish bootstrap instance", slowErrs[0])
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||
description: formatServerError(err, input.translate),
|
||||
description: formatServerError(slowErrs[0], input.translate),
|
||||
})
|
||||
}
|
||||
|
||||
if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete")
|
||||
|
||||
const rev = (providerRev.get(input.directory) ?? 0) + 1
|
||||
providerRev.set(input.directory, rev)
|
||||
void input.queryClient.ensureQueryData({
|
||||
...loadSessionsQuery(input.directory),
|
||||
queryFn: () =>
|
||||
retry(() => input.sdk.provider.list())
|
||||
.then((x) => {
|
||||
if (providerRev.get(input.directory) !== rev) return
|
||||
input.setStore("provider", normalizeProviderList(x.data!))
|
||||
input.setStore("provider_ready", true)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (providerRev.get(input.directory) !== rev) console.error("Failed to refresh provider list", err)
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||
description: formatServerError(err, input.translate),
|
||||
})
|
||||
})
|
||||
.then(() => null),
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
@@ -182,6 +182,7 @@ export function createChildStoreManager(input: {
|
||||
limit: 5,
|
||||
message: {},
|
||||
part: {},
|
||||
bootstrapPromise: Promise.resolve(),
|
||||
})
|
||||
children[directory] = child
|
||||
disposers.set(directory, dispose)
|
||||
|
||||
@@ -63,6 +63,7 @@ export function createRefreshQueue(input: QueueInput) {
|
||||
}
|
||||
} finally {
|
||||
running = false
|
||||
// oxlint-disable-next-line no-unsafe-finally -- intentional: early return skips schedule() when paused
|
||||
if (input.paused()) return
|
||||
if (root || queued.size) schedule()
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
Part,
|
||||
Path,
|
||||
PermissionRequest,
|
||||
Project,
|
||||
ProviderListResponse,
|
||||
QuestionRequest,
|
||||
Session,
|
||||
@@ -73,6 +72,7 @@ export type State = {
|
||||
part: {
|
||||
[messageID: string]: Part[]
|
||||
}
|
||||
bootstrapPromise: Promise<void>
|
||||
}
|
||||
|
||||
export type VcsCache = {
|
||||
|
||||
@@ -582,7 +582,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
open(directory: string) {
|
||||
const root = rootFor(directory)
|
||||
if (server.projects.list().find((x) => x.worktree === root)) return
|
||||
globalSync.project.loadSessions(root)
|
||||
void globalSync.project.loadSessions(root)
|
||||
server.projects.open(root)
|
||||
},
|
||||
close(directory: string) {
|
||||
|
||||
@@ -117,7 +117,7 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat
|
||||
entry?.value.clear()
|
||||
}
|
||||
|
||||
removePersisted(Persist.workspace(dir, "terminal"), platform)
|
||||
void removePersisted(Persist.workspace(dir, "terminal"), platform)
|
||||
|
||||
const legacy = new Set(getLegacyTerminalStorageKeys(dir))
|
||||
for (const id of sessionIDs ?? []) {
|
||||
@@ -126,7 +126,7 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat
|
||||
}
|
||||
}
|
||||
for (const key of legacy) {
|
||||
removePersisted({ key }, platform)
|
||||
void removePersisted({ key }, platform)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1
packages/app/src/env.d.ts
vendored
1
packages/app/src/env.d.ts
vendored
@@ -3,6 +3,7 @@ import "solid-js"
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_OPENCODE_SERVER_HOST: string
|
||||
readonly VITE_OPENCODE_SERVER_PORT: string
|
||||
readonly OPENCODE_CHANNEL?: "dev" | "beta" | "prod"
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { dict as en } from "./en"
|
||||
|
||||
type Keys = keyof typeof en
|
||||
|
||||
export const dict = {
|
||||
"command.category.suggested": "추천",
|
||||
"command.category.view": "보기",
|
||||
|
||||
@@ -132,9 +132,11 @@ export default function Layout(props: ParentProps) {
|
||||
if (!slug) return { slug, dir: "" }
|
||||
const dir = decode64(slug)
|
||||
if (!dir) return { slug, dir: "" }
|
||||
const store = globalSync.peek(dir, { bootstrap: false })
|
||||
return {
|
||||
slug,
|
||||
dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir,
|
||||
store,
|
||||
dir: store[0].path.directory || dir,
|
||||
}
|
||||
})
|
||||
const availableThemeEntries = createMemo(() => theme.ids().map((id) => [id, theme.themes()[id]] as const))
|
||||
@@ -704,7 +706,7 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
createEffect(() => {
|
||||
const active = new Set(visibleSessionDirs())
|
||||
for (const directory of [...prefetchedByDir.keys()]) {
|
||||
for (const directory of prefetchedByDir.keys()) {
|
||||
if (active.has(directory)) continue
|
||||
prefetchedByDir.delete(directory)
|
||||
}
|
||||
@@ -956,7 +958,7 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
// warm up child store to prevent flicker
|
||||
globalSync.child(target.worktree)
|
||||
openProject(target.worktree)
|
||||
void openProject(target.worktree)
|
||||
}
|
||||
|
||||
function navigateSessionByUnseen(offset: number) {
|
||||
@@ -1094,7 +1096,7 @@ export default function Layout(props: ParentProps) {
|
||||
disabled: !params.dir || !params.id,
|
||||
onSelect: () => {
|
||||
const session = currentSessions().find((s) => s.id === params.id)
|
||||
if (session) archiveSession(session)
|
||||
if (session) void archiveSession(session)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1360,11 +1362,11 @@ export default function Layout(props: ParentProps) {
|
||||
if (!server.isLocal()) return
|
||||
|
||||
for (const directory of collectOpenProjectDeepLinks(urls)) {
|
||||
openProject(directory)
|
||||
void openProject(directory)
|
||||
}
|
||||
|
||||
for (const link of collectNewSessionDeepLinks(urls)) {
|
||||
openProject(link.directory, false)
|
||||
void openProject(link.directory, false)
|
||||
const slug = base64Encode(link.directory)
|
||||
if (link.prompt) {
|
||||
setSessionHandoff(slug, { prompt: link.prompt })
|
||||
@@ -1453,11 +1455,11 @@ export default function Layout(props: ParentProps) {
|
||||
function resolve(result: string | string[] | null) {
|
||||
if (Array.isArray(result)) {
|
||||
for (const directory of result) {
|
||||
openProject(directory, false)
|
||||
void openProject(directory, false)
|
||||
}
|
||||
navigateToProject(result[0])
|
||||
void navigateToProject(result[0])
|
||||
} else if (result) {
|
||||
openProject(result)
|
||||
void openProject(result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1825,7 +1827,7 @@ export default function Layout(props: ParentProps) {
|
||||
const next = new Set(dirs)
|
||||
for (const directory of next) {
|
||||
if (loadedSessionDirs.has(directory)) continue
|
||||
globalSync.project.loadSessions(directory)
|
||||
void globalSync.project.loadSessions(directory)
|
||||
}
|
||||
|
||||
loadedSessionDirs.clear()
|
||||
@@ -2110,7 +2112,7 @@ export default function Layout(props: ParentProps) {
|
||||
onSave={(next) => {
|
||||
const item = project()
|
||||
if (!item) return
|
||||
renameProject(item, next)
|
||||
void renameProject(item, next)
|
||||
}}
|
||||
class="text-14-medium text-text-strong truncate"
|
||||
displayClass="text-14-medium text-text-strong truncate"
|
||||
@@ -2242,7 +2244,7 @@ export default function Layout(props: ParentProps) {
|
||||
onClick={() => {
|
||||
const item = project()
|
||||
if (!item) return
|
||||
createWorkspace(item)
|
||||
void createWorkspace(item)
|
||||
}}
|
||||
>
|
||||
{language.t("workspace.new")}
|
||||
@@ -2353,8 +2355,14 @@ export default function Layout(props: ParentProps) {
|
||||
/>
|
||||
)
|
||||
|
||||
const [loading] = createResource(
|
||||
() => route()?.store?.[0]?.bootstrapPromise,
|
||||
(p) => p,
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
{(autoselecting(), loading()) ?? ""}
|
||||
<Titlebar />
|
||||
<div class="flex-1 min-h-0 min-w-0 flex">
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
|
||||
@@ -14,10 +14,11 @@ import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { type Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { type LocalProject } from "@/context/layout"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { loadSessionsQuery, useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
|
||||
import { sortedRootSessions, workspaceKey } from "./helpers"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
|
||||
type InlineEditorComponent = (props: {
|
||||
id: string
|
||||
@@ -277,7 +278,7 @@ const WorkspaceSessionList = (props: {
|
||||
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-2 pr-10"
|
||||
size="large"
|
||||
onClick={(e: MouseEvent) => {
|
||||
props.loadMore()
|
||||
void props.loadMore()
|
||||
;(e.currentTarget as HTMLButtonElement).blur()
|
||||
}}
|
||||
>
|
||||
@@ -454,7 +455,8 @@ export const LocalWorkspace = (props: {
|
||||
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
|
||||
const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
|
||||
const count = createMemo(() => sessions()?.length ?? 0)
|
||||
const loading = createMemo(() => !booted() && count() === 0)
|
||||
const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) }))
|
||||
const loading = createMemo(() => query.isPending && count() === 0)
|
||||
const hasMore = createMemo(() => workspace().store.sessionTotal > count())
|
||||
const loadMore = async () => {
|
||||
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
|
||||
@@ -471,7 +473,7 @@ export const LocalWorkspace = (props: {
|
||||
mobile={props.mobile}
|
||||
ctx={props.ctx}
|
||||
showNew={() => false}
|
||||
loading={loading}
|
||||
loading={() => query.isLoading}
|
||||
sessions={sessions}
|
||||
hasMore={hasMore}
|
||||
loadMore={loadMore}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
on,
|
||||
onMount,
|
||||
untrack,
|
||||
createResource,
|
||||
} from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
@@ -432,8 +433,6 @@ export default function Page() {
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const isChildSession = createMemo(() => !!info()?.parentID)
|
||||
const diffs = createMemo(() => (params.id ? list(sync.data.session_diff[params.id]) : []))
|
||||
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
||||
const hasSessionReview = createMemo(() => sessionCount() > 0)
|
||||
const canReview = createMemo(() => !!sync.project)
|
||||
const reviewTab = createMemo(() => isDesktop())
|
||||
const tabState = createSessionTabs({
|
||||
@@ -443,8 +442,6 @@ export default function Page() {
|
||||
review: reviewTab,
|
||||
hasReview: canReview,
|
||||
})
|
||||
const contextOpen = tabState.contextOpen
|
||||
const openedTabs = tabState.openedTabs
|
||||
const activeTab = tabState.activeTab
|
||||
const activeFileTab = tabState.activeFileTab
|
||||
const revertMessageID = createMemo(() => info()?.revert?.messageID)
|
||||
@@ -487,7 +484,7 @@ export default function Page() {
|
||||
if (!tab) return
|
||||
|
||||
const path = file.pathFromTab(tab)
|
||||
if (path) file.load(path)
|
||||
if (path) void file.load(path)
|
||||
})
|
||||
|
||||
createEffect(
|
||||
@@ -808,8 +805,9 @@ export default function Page() {
|
||||
|
||||
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
|
||||
|
||||
createEffect(
|
||||
on([() => sdk.directory, () => params.id] as const, ([, id]) => {
|
||||
const [sessionSync] = createResource(
|
||||
() => [sdk.directory, params.id] as const,
|
||||
([directory, id]) => {
|
||||
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
|
||||
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
|
||||
refreshFrame = undefined
|
||||
@@ -820,13 +818,10 @@ export default function Page() {
|
||||
const stale = !cached
|
||||
? false
|
||||
: (() => {
|
||||
const info = getSessionPrefetch(sdk.directory, id)
|
||||
const info = getSessionPrefetch(directory, id)
|
||||
if (!info) return true
|
||||
return Date.now() - info.at > SESSION_PREFETCH_TTL
|
||||
})()
|
||||
untrack(() => {
|
||||
void sync.session.sync(id)
|
||||
})
|
||||
|
||||
refreshFrame = requestAnimationFrame(() => {
|
||||
refreshFrame = undefined
|
||||
@@ -838,7 +833,9 @@ export default function Page() {
|
||||
})
|
||||
}, 0)
|
||||
})
|
||||
}),
|
||||
|
||||
return sync.session.sync(id)
|
||||
},
|
||||
)
|
||||
|
||||
createEffect(
|
||||
@@ -1885,6 +1882,7 @@ export default function Page() {
|
||||
|
||||
return (
|
||||
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
|
||||
{sessionSync() ?? ""}
|
||||
<SessionHeader />
|
||||
<div class="flex-1 min-h-0 flex flex-col md:flex-row">
|
||||
<Show when={!isDesktop() && !!params.id}>
|
||||
|
||||
@@ -378,12 +378,6 @@ export function FileTabContent(props: { tab: string }) {
|
||||
requestAnimationFrame(() => comments.clearFocus())
|
||||
})
|
||||
|
||||
const cancelCommenting = () => {
|
||||
const p = path()
|
||||
if (p) file.setSelectedLines(p, null)
|
||||
setNote("commenting", null)
|
||||
}
|
||||
|
||||
let prev = {
|
||||
loaded: false,
|
||||
ready: false,
|
||||
|
||||
@@ -117,7 +117,7 @@ export const createOpenReviewFile = (input: {
|
||||
input.openTab(tab)
|
||||
input.setActive(tab)
|
||||
}
|
||||
if (maybePromise instanceof Promise) maybePromise.then(open)
|
||||
if (maybePromise instanceof Promise) void maybePromise.then(open)
|
||||
else open()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createSignal, onCleanup, type JSX } from "solid-js"
|
||||
import { createEffect, onCleanup, type JSX } from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { SessionReview } from "@opencode-ai/ui/session-review"
|
||||
|
||||
@@ -46,7 +46,9 @@ describe("runtime adapters", () => {
|
||||
})
|
||||
|
||||
test("resolves speech recognition constructor with webkit precedence", () => {
|
||||
// oxlint-disable-next-line no-extraneous-class
|
||||
class SpeechCtor {}
|
||||
// oxlint-disable-next-line no-extraneous-class
|
||||
class WebkitCtor {}
|
||||
const ctor = getSpeechRecognitionCtor({
|
||||
SpeechRecognition: SpeechCtor,
|
||||
|
||||
@@ -16,7 +16,10 @@ export function createSdkForServer({
|
||||
|
||||
return createOpencodeClient({
|
||||
...config,
|
||||
headers: { ...config.headers, ...auth },
|
||||
headers: {
|
||||
...(config.headers instanceof Headers ? Object.fromEntries(config.headers.entries()) : config.headers),
|
||||
...auth,
|
||||
},
|
||||
baseUrl: server.url,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { LOCALES, route } from "../src/lib/language.js"
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const BASE_URL = config.baseUrl
|
||||
const PUBLIC_DIR = join(__dirname, "../public")
|
||||
const ROUTES_DIR = join(__dirname, "../src/routes")
|
||||
const DOCS_DIR = join(__dirname, "../../../web/src/content/docs")
|
||||
|
||||
interface SitemapEntry {
|
||||
@@ -106,4 +105,4 @@ async function main() {
|
||||
console.log(`✓ Sitemap generated at ${outputPath}`)
|
||||
}
|
||||
|
||||
main()
|
||||
void main()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { action, useSubmission } from "@solidjs/router"
|
||||
import dock from "../asset/lander/dock.png"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { Show } from "solid-js"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
|
||||
@@ -47,7 +47,7 @@ export function Header(props: { zen?: boolean; go?: boolean; hideGetStarted?: bo
|
||||
notation: "compact",
|
||||
compactDisplay: "short",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(githubData()?.stars!)
|
||||
}).format(githubData()?.stars)
|
||||
: config.github.starsFormatted.compact,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { JSX } from "solid-js"
|
||||
|
||||
export function IconZen(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||
export function IconZen(_props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||
return (
|
||||
<svg width="84" height="30" viewBox="0 0 84 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M24 24H6V18H18V12H24V24ZM6 18H0V12H6V18Z" fill="currentColor" fill-opacity="0.2" />
|
||||
@@ -13,7 +13,7 @@ export function IconZen(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function IconGo(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||
export function IconGo(_props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||
return (
|
||||
<svg width="54" height="30" viewBox="0 0 54 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M24 30H0V0H24V6H6V24H18V18H12V12H24V30Z" fill="currentColor" />
|
||||
|
||||
@@ -766,7 +766,7 @@ export default function Spotlight(props: SpotlightProps) {
|
||||
}
|
||||
}
|
||||
|
||||
initializeWebGPU()
|
||||
void initializeWebGPU()
|
||||
|
||||
onCleanup(() => {
|
||||
if (cleanupFunctionRef) {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export {}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { APIEvent } from "@solidjs/start"
|
||||
import { useAuthSession } from "~/context/auth"
|
||||
|
||||
export async function GET(input: APIEvent) {
|
||||
export async function GET(_input: APIEvent) {
|
||||
const session = await useAuthSession()
|
||||
return Response.json(session.data)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Title } from "@solidjs/meta"
|
||||
import { createAsync, query, useParams } from "@solidjs/router"
|
||||
import { createSignal, For, Show } from "solid-js"
|
||||
import { Database, desc, eq } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
|
||||
|
||||
@@ -298,7 +298,7 @@ export default function BlackSubscribe() {
|
||||
|
||||
// Resolve stripe promise once
|
||||
createEffect(() => {
|
||||
stripePromise.then((s) => {
|
||||
void stripePromise.then((s) => {
|
||||
if (s) setStripe(s)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { json } from "@solidjs/router"
|
||||
import { Database } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
||||
|
||||
export async function GET(evt: APIEvent) {
|
||||
export async function GET(_evt: APIEvent) {
|
||||
return json({
|
||||
data: await Database.use(async (tx) => {
|
||||
const result = await tx.$count(UserTable)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { APIEvent } from "@solidjs/start"
|
||||
import type { DownloadPlatform } from "../types"
|
||||
|
||||
const assetNames: Record<string, string> = {
|
||||
const prodAssetNames: Record<string, string> = {
|
||||
"darwin-aarch64-dmg": "opencode-desktop-darwin-aarch64.dmg",
|
||||
"darwin-x64-dmg": "opencode-desktop-darwin-x64.dmg",
|
||||
"windows-x64-nsis": "opencode-desktop-windows-x64.exe",
|
||||
@@ -10,6 +10,15 @@ const assetNames: Record<string, string> = {
|
||||
"linux-x64-rpm": "opencode-desktop-linux-x86_64.rpm",
|
||||
} satisfies Record<DownloadPlatform, string>
|
||||
|
||||
const betaAssetNames: Record<string, string> = {
|
||||
"darwin-aarch64-dmg": "opencode-electron-mac-arm64.dmg",
|
||||
"darwin-x64-dmg": "opencode-electron-mac-x64.dmg",
|
||||
"windows-x64-nsis": "opencode-electron-win-x64.exe",
|
||||
"linux-x64-deb": "opencode-electron-linux-amd64.deb",
|
||||
"linux-x64-appimage": "opencode-electron-linux-x86_64.AppImage",
|
||||
"linux-x64-rpm": "opencode-electron-linux-x86_64.rpm",
|
||||
} satisfies Record<DownloadPlatform, string>
|
||||
|
||||
// Doing this on the server lets us preserve the original name for platforms we don't care to rename for
|
||||
const downloadNames: Record<string, string> = {
|
||||
"darwin-aarch64-dmg": "OpenCode Desktop.dmg",
|
||||
@@ -18,7 +27,7 @@ const downloadNames: Record<string, string> = {
|
||||
} satisfies { [K in DownloadPlatform]?: string }
|
||||
|
||||
export async function GET({ params: { platform, channel } }: APIEvent) {
|
||||
const assetName = assetNames[platform]
|
||||
const assetName = channel === "stable" ? prodAssetNames[platform] : betaAssetNames[platform]
|
||||
if (!assetName) return new Response(null, { status: 404 })
|
||||
|
||||
const resp = await fetch(
|
||||
@@ -37,5 +46,5 @@ export async function GET({ params: { platform, channel } }: APIEvent) {
|
||||
const headers = new Headers(resp.headers)
|
||||
if (downloadName) headers.set("content-disposition", `attachment; filename="${downloadName}"`)
|
||||
|
||||
return new Response(resp.body, { ...resp, headers })
|
||||
return new Response(resp.body, { status: resp.status, statusText: resp.statusText, headers })
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export default function Download() {
|
||||
|
||||
const handleCopyClick = (command: string) => (event: Event) => {
|
||||
const button = event.currentTarget as HTMLButtonElement
|
||||
navigator.clipboard.writeText(command)
|
||||
void navigator.clipboard.writeText(command)
|
||||
button.setAttribute("data-copied", "")
|
||||
setTimeout(() => {
|
||||
button.removeAttribute("data-copied")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "./index.css"
|
||||
import { createAsync, query, redirect } from "@solidjs/router"
|
||||
import { createAsync, query } from "@solidjs/router"
|
||||
import { Title, Meta } from "@solidjs/meta"
|
||||
import { For, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
//import { HttpHeader } from "@solidjs/start"
|
||||
|
||||
@@ -12,7 +12,6 @@ import { Header } from "~/component/header"
|
||||
import { Footer } from "~/component/footer"
|
||||
import { Legal } from "~/component/legal"
|
||||
import { github } from "~/lib/github"
|
||||
import { createMemo } from "solid-js"
|
||||
import { config } from "~/config"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
import { useLanguage } from "~/context/language"
|
||||
@@ -30,14 +29,12 @@ function CopyStatus() {
|
||||
export default function Home() {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const githubData = createAsync(() => github())
|
||||
const release = createMemo(() => githubData()?.release)
|
||||
|
||||
const _githubData = createAsync(() => github())
|
||||
const handleCopyClick = (event: Event) => {
|
||||
const button = event.currentTarget as HTMLButtonElement
|
||||
const text = button.textContent
|
||||
if (text) {
|
||||
navigator.clipboard.writeText(text)
|
||||
void navigator.clipboard.writeText(text)
|
||||
button.setAttribute("data-copied", "")
|
||||
setTimeout(() => {
|
||||
button.removeAttribute("data-copied")
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function Home() {
|
||||
const callback = () => {
|
||||
const text = button.textContent
|
||||
if (text) {
|
||||
navigator.clipboard.writeText(text)
|
||||
void navigator.clipboard.writeText(text)
|
||||
button.setAttribute("data-copied", "")
|
||||
setTimeout(() => {
|
||||
button.removeAttribute("data-copied")
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useI18n } from "~/context/i18n"
|
||||
import { useLanguage } from "~/context/language"
|
||||
import "./user-menu.css"
|
||||
|
||||
const logout = action(async () => {
|
||||
const _logout = action(async () => {
|
||||
"use server"
|
||||
const auth = await useAuthSession()
|
||||
const event = getRequestEvent()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { query, useParams, action, createAsync, redirect, useSubmission } from "@solidjs/router"
|
||||
import { For, Show, createEffect } from "solid-js"
|
||||
import { For, createEffect } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
|
||||
@@ -116,9 +116,9 @@ const createSessionUrl = action(async (workspaceID: string, returnUrl: string) =
|
||||
|
||||
const setUseBalance = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
const useBalance = form.get("useBalance")?.toString() === "true"
|
||||
const useBalance = (form.get("useBalance") as string | null) === "true"
|
||||
|
||||
return json(
|
||||
await withActor(async () => {
|
||||
|
||||
@@ -10,11 +10,11 @@ import { formError, localizeError } from "~/lib/form-error"
|
||||
|
||||
const setMonthlyLimit = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const limit = form.get("limit")?.toString()
|
||||
const limit = form.get("limit") as string | null
|
||||
if (!limit) return { error: formError.limitRequired }
|
||||
const numericLimit = parseInt(limit)
|
||||
if (numericLimit < 0) return { error: formError.monthlyLimitInvalid }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
return json(
|
||||
await withActor(
|
||||
|
||||
@@ -12,7 +12,7 @@ import { formError, formErrorReloadAmountMin, formErrorReloadTriggerMin, localiz
|
||||
|
||||
const reload = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
return json(await withActor(() => Billing.reload(), workspaceID), {
|
||||
revalidate: queryBillingInfo.key,
|
||||
@@ -21,11 +21,11 @@ const reload = action(async (form: FormData) => {
|
||||
|
||||
const setReload = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
const reloadValue = form.get("reload")?.toString() === "true"
|
||||
const amountStr = form.get("reloadAmount")?.toString()
|
||||
const triggerStr = form.get("reloadTrigger")?.toString()
|
||||
const reloadValue = (form.get("reload") as string | null) === "true"
|
||||
const amountStr = form.get("reloadAmount") as string | null
|
||||
const triggerStr = form.get("reloadTrigger") as string | null
|
||||
|
||||
const reloadAmount = amountStr && amountStr.trim() !== "" ? parseInt(amountStr) : null
|
||||
const reloadTrigger = triggerStr && triggerStr.trim() !== "" ? parseInt(triggerStr) : null
|
||||
@@ -90,9 +90,9 @@ export function ReloadSection() {
|
||||
}
|
||||
const info = billingInfo()!
|
||||
setStore("show", true)
|
||||
setStore("reload", info.reload ? true : true)
|
||||
setStore("reloadAmount", info.reloadAmount.toString())
|
||||
setStore("reloadTrigger", info.reloadTrigger.toString())
|
||||
setStore("reload", true)
|
||||
setStore("reloadAmount", String(info.reloadAmount))
|
||||
setStore("reloadTrigger", String(info.reloadTrigger))
|
||||
}
|
||||
|
||||
function hide() {
|
||||
@@ -152,11 +152,11 @@ export function ReloadSection() {
|
||||
data-component="input"
|
||||
name="reloadAmount"
|
||||
type="number"
|
||||
min={billingInfo()?.reloadAmountMin.toString()}
|
||||
min={String(billingInfo()?.reloadAmountMin ?? "")}
|
||||
step="1"
|
||||
value={store.reloadAmount}
|
||||
onInput={(e) => setStore("reloadAmount", e.currentTarget.value)}
|
||||
placeholder={billingInfo()?.reloadAmount.toString()}
|
||||
placeholder={String(billingInfo()?.reloadAmount ?? "")}
|
||||
disabled={!store.reload}
|
||||
/>
|
||||
</div>
|
||||
@@ -166,11 +166,11 @@ export function ReloadSection() {
|
||||
data-component="input"
|
||||
name="reloadTrigger"
|
||||
type="number"
|
||||
min={billingInfo()?.reloadTriggerMin.toString()}
|
||||
min={String(billingInfo()?.reloadTriggerMin ?? "")}
|
||||
step="1"
|
||||
value={store.reloadTrigger}
|
||||
onInput={(e) => setStore("reloadTrigger", e.currentTarget.value)}
|
||||
placeholder={billingInfo()?.reloadTrigger.toString()}
|
||||
placeholder={String(billingInfo()?.reloadTrigger ?? "")}
|
||||
disabled={!store.reload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -120,9 +120,9 @@ const createSessionUrl = action(async (workspaceID: string, returnUrl: string) =
|
||||
|
||||
const setLiteUseBalance = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
const useBalance = form.get("useBalance")?.toString() === "true"
|
||||
const useBalance = (form.get("useBalance") as string | null) === "true"
|
||||
|
||||
return json(
|
||||
await withActor(async () => {
|
||||
|
||||
@@ -12,18 +12,18 @@ import { formError, localizeError } from "~/lib/form-error"
|
||||
|
||||
const removeKey = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const id = form.get("id")?.toString()
|
||||
const id = form.get("id") as string | null
|
||||
if (!id) return { error: formError.idRequired }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
return json(await withActor(() => Key.remove({ id }), workspaceID), { revalidate: listKeys.key })
|
||||
}, "key.remove")
|
||||
|
||||
const createKey = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const name = form.get("name")?.toString().trim()
|
||||
const name = (form.get("name") as string | null)?.trim()
|
||||
if (!name) return { error: formError.nameRequired }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
return json(
|
||||
await withActor(
|
||||
|
||||
@@ -24,13 +24,13 @@ const listMembers = query(async (workspaceID: string) => {
|
||||
|
||||
const inviteMember = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const email = form.get("email")?.toString().trim()
|
||||
const email = (form.get("email") as string | null)?.trim()
|
||||
if (!email) return { error: formError.emailRequired }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
const role = form.get("role")?.toString() as (typeof UserRole)[number]
|
||||
const role = form.get("role") as (typeof UserRole)[number] | null
|
||||
if (!role) return { error: formError.roleRequired }
|
||||
const limit = form.get("limit")?.toString()
|
||||
const limit = form.get("limit") as string | null
|
||||
const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null
|
||||
if (monthlyLimit !== null && monthlyLimit < 0) return { error: formError.monthlyLimitInvalid }
|
||||
return json(
|
||||
@@ -47,9 +47,9 @@ const inviteMember = action(async (form: FormData) => {
|
||||
|
||||
const removeMember = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const id = form.get("id")?.toString()
|
||||
const id = form.get("id") as string | null
|
||||
if (!id) return { error: formError.idRequired }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
return json(
|
||||
await withActor(
|
||||
@@ -66,13 +66,13 @@ const removeMember = action(async (form: FormData) => {
|
||||
const updateMember = action(async (form: FormData) => {
|
||||
"use server"
|
||||
|
||||
const id = form.get("id")?.toString()
|
||||
const id = form.get("id") as string | null
|
||||
if (!id) return { error: formError.idRequired }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
const role = form.get("role")?.toString() as (typeof UserRole)[number]
|
||||
const role = form.get("role") as (typeof UserRole)[number] | null
|
||||
if (!role) return { error: formError.roleRequired }
|
||||
const limit = form.get("limit")?.toString()
|
||||
const limit = form.get("limit") as string | null
|
||||
const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null
|
||||
if (monthlyLimit !== null && monthlyLimit < 0) return { error: formError.monthlyLimitInvalid }
|
||||
|
||||
@@ -118,7 +118,7 @@ function MemberRow(props: {
|
||||
}
|
||||
setStore("editing", true)
|
||||
setStore("selectedRole", props.member.role)
|
||||
setStore("limit", props.member.monthlyLimit?.toString() ?? "")
|
||||
setStore("limit", props.member.monthlyLimit != null ? String(props.member.monthlyLimit) : "")
|
||||
}
|
||||
|
||||
function hide() {
|
||||
|
||||
@@ -67,11 +67,11 @@ const getModelsInfo = query(async (workspaceID: string) => {
|
||||
|
||||
const updateModel = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const model = form.get("model")?.toString()
|
||||
const model = form.get("model") as string | null
|
||||
if (!model) return { error: formError.modelRequired }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
const enabled = form.get("enabled")?.toString() === "true"
|
||||
const enabled = (form.get("enabled") as string | null) === "true"
|
||||
return json(
|
||||
withActor(async () => {
|
||||
if (enabled) {
|
||||
@@ -163,7 +163,7 @@ export function ModelSection() {
|
||||
<form action={updateModel} method="post">
|
||||
<input type="hidden" name="model" value={id} />
|
||||
<input type="hidden" name="workspaceID" value={params.id} />
|
||||
<input type="hidden" name="enabled" value={isEnabled().toString()} />
|
||||
<input type="hidden" name="enabled" value={String(isEnabled())} />
|
||||
<label data-slot="model-toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -21,9 +21,9 @@ function maskCredentials(credentials: string) {
|
||||
|
||||
const removeProvider = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const provider = form.get("provider")?.toString()
|
||||
const provider = form.get("provider") as string | null
|
||||
if (!provider) return { error: formError.providerRequired }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
return json(await withActor(() => Provider.remove({ provider }), workspaceID), {
|
||||
revalidate: listProviders.key,
|
||||
@@ -32,11 +32,11 @@ const removeProvider = action(async (form: FormData) => {
|
||||
|
||||
const saveProvider = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const provider = form.get("provider")?.toString()
|
||||
const credentials = form.get("credentials")?.toString()
|
||||
const provider = form.get("provider") as string | null
|
||||
const credentials = form.get("credentials") as string | null
|
||||
if (!provider) return { error: formError.providerRequired }
|
||||
if (!credentials) return { error: formError.apiKeyRequired }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
return json(
|
||||
await withActor(
|
||||
@@ -59,10 +59,13 @@ function ProviderRow(props: { provider: Provider }) {
|
||||
const params = useParams()
|
||||
const i18n = useI18n()
|
||||
const providers = createAsync(() => listProviders(params.id!))
|
||||
const saveSubmission = useSubmission(saveProvider, ([fd]) => fd.get("provider")?.toString() === props.provider.key)
|
||||
const saveSubmission = useSubmission(
|
||||
saveProvider,
|
||||
([fd]) => (fd.get("provider") as string | null) === props.provider.key,
|
||||
)
|
||||
const removeSubmission = useSubmission(
|
||||
removeProvider,
|
||||
([fd]) => fd.get("provider")?.toString() === props.provider.key,
|
||||
([fd]) => (fd.get("provider") as string | null) === props.provider.key,
|
||||
)
|
||||
const [store, setStore] = createStore({ editing: false })
|
||||
|
||||
|
||||
@@ -30,10 +30,10 @@ const getWorkspaceInfo = query(async (workspaceID: string) => {
|
||||
|
||||
const updateWorkspace = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const name = form.get("name")?.toString().trim()
|
||||
const name = (form.get("name") as string | null)?.trim()
|
||||
if (!name) return { error: formError.workspaceNameRequired }
|
||||
if (name.length > 255) return { error: formError.nameTooLong }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
const workspaceID = form.get("workspaceID") as string | null
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
return json(
|
||||
await withActor(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "./index.css"
|
||||
import { createAsync, query, redirect } from "@solidjs/router"
|
||||
import { createAsync, query } from "@solidjs/router"
|
||||
import { Title, Meta } from "@solidjs/meta"
|
||||
//import { HttpHeader } from "@solidjs/start"
|
||||
import zenLogoLight from "../../asset/zen-ornate-light.svg"
|
||||
|
||||
@@ -26,14 +26,14 @@ export function createDataDumper(sessionId: string, requestId: string, projectId
|
||||
const minute = timestamp.substring(10, 12)
|
||||
const second = timestamp.substring(12, 14)
|
||||
|
||||
waitUntil(
|
||||
void waitUntil(
|
||||
Resource.ZenDataNew.put(
|
||||
`data/${data.modelName}/${year}/${month}/${day}/${hour}/${minute}/${second}/${requestId}.json`,
|
||||
JSON.stringify({ timestamp, ...data }),
|
||||
),
|
||||
)
|
||||
|
||||
waitUntil(
|
||||
void waitUntil(
|
||||
Resource.ZenDataNew.put(
|
||||
`meta/${data.modelName}/${sessionId}/${requestId}.json`,
|
||||
JSON.stringify({ timestamp, ...metadata }),
|
||||
|
||||
@@ -345,7 +345,7 @@ export async function handler(
|
||||
logger.metric({
|
||||
"error.cause2": JSON.stringify(error.cause),
|
||||
})
|
||||
} catch (e) {}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message.
|
||||
|
||||
@@ -153,7 +153,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(data.slice(6))
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ export const googleHelper: ProviderHelper = ({ providerModel }) => ({
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(chunk.slice(6)) as { usageMetadata?: Usage }
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentif
|
||||
headers.set("authorization", `Bearer ${apiKey}`)
|
||||
headers.set("x-session-affinity", headers.get("x-opencode-session") ?? "")
|
||||
},
|
||||
modifyBody: (body: Record<string, any>, workspaceID?: string) => {
|
||||
modifyBody: (body: Record<string, any>, _workspaceID?: string) => {
|
||||
return {
|
||||
...body,
|
||||
...(body.stream ? { stream_options: { include_usage: true } } : {}),
|
||||
@@ -49,7 +49,7 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentif
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(chunk.slice(6)) as { usage?: Usage }
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -289,7 +289,7 @@ export function fromOaCompatibleResponse(resp: any): CommonResponse {
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant" as const,
|
||||
...(content.length > 0 && content.some((c) => c.type === "text")
|
||||
...(content.some((c) => c.type === "text")
|
||||
? {
|
||||
content: content
|
||||
.filter((c) => c.type === "text")
|
||||
@@ -297,7 +297,7 @@ export function fromOaCompatibleResponse(resp: any): CommonResponse {
|
||||
.join(""),
|
||||
}
|
||||
: {}),
|
||||
...(content.length > 0 && content.some((c) => c.type === "tool_use")
|
||||
...(content.some((c) => c.type === "tool_use")
|
||||
? {
|
||||
tool_calls: content
|
||||
.filter((c) => c.type === "tool_use")
|
||||
|
||||
@@ -36,7 +36,7 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(data.slice(6)) as { response?: { usage?: Usage } }
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.j
|
||||
import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
|
||||
import { ZenData } from "@opencode-ai/console-core/model.js"
|
||||
|
||||
export async function OPTIONS(input: APIEvent) {
|
||||
export async function OPTIONS(_input: APIEvent) {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
|
||||
@@ -6,8 +6,8 @@ export function POST(input: APIEvent) {
|
||||
format: "google",
|
||||
modelList: "full",
|
||||
parseApiKey: (headers: Headers) => headers.get("x-goog-api-key") ?? undefined,
|
||||
parseModel: (url: string, body: any) => url.split("/").pop()?.split(":")?.[0] ?? "",
|
||||
parseIsStream: (url: string, body: any) =>
|
||||
parseModel: (url: string, _body: any) => url.split("/").pop()?.split(":")?.[0] ?? "",
|
||||
parseIsStream: (url: string, _body: any) =>
|
||||
// ie. url: https://opencode.ai/zen/v1/models/gemini-3-pro:streamGenerateContent?alt=sse'
|
||||
url.split("/").pop()?.split(":")?.[1]?.startsWith("streamGenerateContent") ?? false,
|
||||
})
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { subscribe } from "diagnostics_channel"
|
||||
import { Billing } from "../src/billing.js"
|
||||
import { and, Database, eq } from "../src/drizzle/index.js"
|
||||
import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js"
|
||||
import { Database, eq } from "../src/drizzle/index.js"
|
||||
import { BillingTable } from "../src/schema/billing.sql.js"
|
||||
|
||||
const workspaceID = process.argv[2]
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Billing } from "../src/billing.js"
|
||||
import { and, Database, eq, isNull, sql } from "../src/drizzle/index.js"
|
||||
import { and, Database, eq, isNull } from "../src/drizzle/index.js"
|
||||
import { UserTable } from "../src/schema/user.sql.js"
|
||||
import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js"
|
||||
import { BillingTable, SubscriptionTable } from "../src/schema/billing.sql.js"
|
||||
import { Identifier } from "../src/identifier.js"
|
||||
import { centsToMicroCents } from "../src/util/price.js"
|
||||
import { AuthTable } from "../src/schema/auth.sql.js"
|
||||
import { BlackData } from "../src/black.js"
|
||||
import { Actor } from "../src/actor.js"
|
||||
|
||||
const plan = "200"
|
||||
const couponID = "JAIr0Pe1"
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { subscribe } from "diagnostics_channel"
|
||||
import { Billing } from "../src/billing.js"
|
||||
import { and, Database, eq } from "../src/drizzle/index.js"
|
||||
import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js"
|
||||
import { Database, eq } from "../src/drizzle/index.js"
|
||||
import { BillingTable } from "../src/schema/billing.sql.js"
|
||||
|
||||
const workspaceID = process.argv[2]
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Database, eq, and, sql, inArray, isNull, count } from "../src/drizzle/index.js"
|
||||
import { Database, eq, and, sql, inArray, isNull } from "../src/drizzle/index.js"
|
||||
import { BillingTable, BlackPlans } from "../src/schema/billing.sql.js"
|
||||
import { UserTable } from "../src/schema/user.sql.js"
|
||||
import { AuthTable } from "../src/schema/auth.sql.js"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export {}
|
||||
|
||||
@@ -48,7 +48,7 @@ export namespace Log {
|
||||
function use() {
|
||||
try {
|
||||
return ctx.use()
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return { tags: {} }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,9 @@ export default defineConfig({
|
||||
plugins: [appPlugin],
|
||||
publicDir: "../../../app/public",
|
||||
root: "src/renderer",
|
||||
define: {
|
||||
"import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel),
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
|
||||
@@ -28,7 +28,7 @@ export function wslPath(path: string, mode: "windows" | "linux" | null): string
|
||||
const output = execFileSync("wsl", ["-e", "wslpath", flag, path])
|
||||
return output.toString().trim()
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to run wslpath: ${String(error)}`)
|
||||
throw new Error(`Failed to run wslpath: ${String(error)}`, { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
if (location.pathname === "/loading") {
|
||||
import("./loading")
|
||||
void import("./loading")
|
||||
} else {
|
||||
import("./")
|
||||
void import("./")
|
||||
}
|
||||
|
||||
@@ -410,7 +410,7 @@ const createPlatform = (): Platform => {
|
||||
}
|
||||
|
||||
let menuTrigger = null as null | ((id: string) => void)
|
||||
createMenu((id) => {
|
||||
void createMenu((id) => {
|
||||
menuTrigger?.(id)
|
||||
})
|
||||
void listenForDeepLinks()
|
||||
|
||||
@@ -48,7 +48,7 @@ render(() => {
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
listener.then((cb) => cb())
|
||||
void listener.then((cb) => cb())
|
||||
timers.forEach(clearTimeout)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -186,5 +186,5 @@ export async function createMenu(trigger: (id: string) => void) {
|
||||
}),
|
||||
],
|
||||
})
|
||||
menu.setAsAppMenu()
|
||||
void menu.setAsAppMenu()
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_Z
|
||||
|
||||
const applyZoom = (next: number) => {
|
||||
setWebviewZoom(next)
|
||||
invoke("plugin:webview|set_webview_zoom", {
|
||||
void invoke("plugin:webview|set_webview_zoom", {
|
||||
value: next,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -37,4 +37,4 @@ async function test() {
|
||||
await Share.remove({ id: shareInfo.id, secret: shareInfo.secret })
|
||||
}
|
||||
|
||||
test()
|
||||
void test()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test, afterAll } from "bun:test"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Share } from "../../src/core/share"
|
||||
import { Storage } from "../../src/core/storage"
|
||||
import { Identifier } from "@opencode-ai/shared/util/identifier"
|
||||
|
||||
@@ -12,21 +12,8 @@ type Env = {
|
||||
WEB_DOMAIN: string
|
||||
}
|
||||
|
||||
async function getFeishuTenantToken(): Promise<string> {
|
||||
const response = await fetch("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
app_id: Resource.FEISHU_APP_ID.value,
|
||||
app_secret: Resource.FEISHU_APP_SECRET.value,
|
||||
}),
|
||||
})
|
||||
const data = (await response.json()) as { tenant_access_token?: string }
|
||||
if (!data.tenant_access_token) throw new Error("Failed to get Feishu tenant token")
|
||||
return data.tenant_access_token
|
||||
}
|
||||
|
||||
export class SyncServer extends DurableObject<Env> {
|
||||
// oxlint-disable-next-line no-useless-constructor
|
||||
constructor(ctx: DurableObjectState, env: Env) {
|
||||
super(ctx, env)
|
||||
}
|
||||
@@ -49,9 +36,9 @@ export class SyncServer extends DurableObject<Env> {
|
||||
})
|
||||
}
|
||||
|
||||
async webSocketMessage(ws, message) {}
|
||||
async webSocketMessage(_ws, _message) {}
|
||||
|
||||
async webSocketClose(ws, code, reason, wasClean) {
|
||||
async webSocketClose(ws, code, _reason, _wasClean) {
|
||||
ws.close(code, "Durable Object is closing WebSocket")
|
||||
}
|
||||
|
||||
@@ -195,7 +182,7 @@ export default new Hono<{ Bindings: Env }>()
|
||||
let info
|
||||
const messages: Record<string, any> = {}
|
||||
data.forEach((d) => {
|
||||
const [root, type, ...splits] = d.key.split("/")
|
||||
const [root, type] = d.key.split("/")
|
||||
if (root !== "session") return
|
||||
if (type === "info") {
|
||||
info = d.content
|
||||
|
||||
3
packages/opencode/.gitignore
vendored
3
packages/opencode/.gitignore
vendored
@@ -1,6 +1,9 @@
|
||||
research
|
||||
dist
|
||||
dist-*
|
||||
gen
|
||||
app.log
|
||||
src/provider/models-snapshot.js
|
||||
src/provider/models-snapshot.d.ts
|
||||
script/build-*.ts
|
||||
temporary-*.md
|
||||
|
||||
31
packages/opencode/.opencode/package-lock.json
generated
31
packages/opencode/.opencode/package-lock.json
generated
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"name": ".opencode",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/plugin": {
|
||||
"version": "1.2.6",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
"zod": "4.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/sdk": {
|
||||
"version": "1.2.6",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.1.8",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,12 @@ See `specs/effect/migration.md` for the compact pattern reference and examples.
|
||||
- Do the work directly in the `InstanceState.make` closure — `ScopedCache` handles run-once semantics. Don't add fibers, `ensure()` callbacks, or `started` flags on top.
|
||||
- Use `Effect.addFinalizer` or `Effect.acquireRelease` inside the `InstanceState.make` closure for cleanup (subscriptions, process teardown, etc.).
|
||||
- Use `Effect.forkScoped` inside the closure for background stream consumers — the fiber is interrupted when the instance is disposed.
|
||||
- To make a service's `init()` non-blocking, fork `InstanceState.get(state)` at the `init()` call site (e.g. `Effect.forkIn(scope)`), not by forking work inside the `InstanceState.make` closure. Forking inside the closure leaves state incomplete for other methods that read it.
|
||||
- `src/project/bootstrap.ts` already wraps every service `init()` in `Effect.forkDetach`, so `init()` is fire-and-forget in production. Keep `init()` methods synchronous internally; the caller controls concurrency.
|
||||
|
||||
## Effect v4 beta API
|
||||
|
||||
- `Effect.fork` and `Effect.forkDaemon` do not exist. Use `Effect.forkIn(scope)` to fork a fiber into a specific scope.
|
||||
|
||||
## Preferred Effect services
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
||||
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
||||
"dev": "bun run --conditions=browser ./src/index.ts",
|
||||
"dev:temporary": "bun run --conditions=browser ./src/temporary.ts",
|
||||
"db": "bun drizzle-kit"
|
||||
},
|
||||
"bin": {
|
||||
@@ -115,7 +116,6 @@
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "2.5.1",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "2.6.1",
|
||||
|
||||
@@ -64,36 +64,7 @@ function findBinary() {
|
||||
|
||||
return { binaryPath, binaryName }
|
||||
} catch (error) {
|
||||
throw new Error(`Could not find package ${packageName}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function prepareBinDirectory(binaryName) {
|
||||
const binDir = path.join(__dirname, "bin")
|
||||
const targetPath = path.join(binDir, binaryName)
|
||||
|
||||
// Ensure bin directory exists
|
||||
if (!fs.existsSync(binDir)) {
|
||||
fs.mkdirSync(binDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Remove existing binary/symlink if it exists
|
||||
if (fs.existsSync(targetPath)) {
|
||||
fs.unlinkSync(targetPath)
|
||||
}
|
||||
|
||||
return { binDir, targetPath }
|
||||
}
|
||||
|
||||
function symlinkBinary(sourcePath, binaryName) {
|
||||
const { targetPath } = prepareBinDirectory(binaryName)
|
||||
|
||||
fs.symlinkSync(sourcePath, targetPath)
|
||||
console.log(`opencode binary symlinked: ${targetPath} -> ${sourcePath}`)
|
||||
|
||||
// Verify the file exists after operation
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
throw new Error(`Failed to symlink binary to ${targetPath}`)
|
||||
throw new Error(`Could not find package ${packageName}: ${error.message}`, { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +95,7 @@ async function main() {
|
||||
}
|
||||
|
||||
try {
|
||||
main()
|
||||
void main()
|
||||
} catch (error) {
|
||||
console.error("Postinstall script error:", error.message)
|
||||
process.exit(0)
|
||||
|
||||
@@ -107,7 +107,7 @@ if (!Script.preview) {
|
||||
await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${Script.version}"`
|
||||
await $`cd ./dist/aur-${pkg} && git push`
|
||||
break
|
||||
} catch (e) {
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { z } from "zod"
|
||||
import { Config } from "../src/config/config"
|
||||
import { TuiConfig } from "../src/config/tui"
|
||||
import { Config } from "../src/config"
|
||||
import { TuiConfig } from "../src/cli/cmd/tui/config/tui"
|
||||
|
||||
function generate(schema: z.ZodType) {
|
||||
const result = z.toJSONSchema(schema, {
|
||||
@@ -33,7 +33,7 @@ function generate(schema: z.ZodType) {
|
||||
schema.examples = [schema.default]
|
||||
}
|
||||
|
||||
schema.description = [schema.description || "", `default: \`${schema.default}\``]
|
||||
schema.description = [schema.description || "", `default: \`${String(schema.default)}\``]
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
.trim()
|
||||
|
||||
305
packages/opencode/script/unwrap-namespace.ts
Normal file
305
packages/opencode/script/unwrap-namespace.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Unwrap a TypeScript `export namespace` into flat exports + barrel.
|
||||
*
|
||||
* Usage:
|
||||
* bun script/unwrap-namespace.ts src/bus/index.ts
|
||||
* bun script/unwrap-namespace.ts src/bus/index.ts --dry-run
|
||||
* bun script/unwrap-namespace.ts src/pty/index.ts --name service # avoid collision with pty.ts
|
||||
*
|
||||
* What it does:
|
||||
* 1. Reads the file and finds the `export namespace Foo { ... }` block
|
||||
* (uses ast-grep for accurate AST-based boundary detection)
|
||||
* 2. Removes the namespace wrapper and dedents the body
|
||||
* 3. Fixes self-references (e.g. Config.PermissionAction → PermissionAction)
|
||||
* 4. If the file is index.ts, renames it to <lowercase-name>.ts
|
||||
* 5. Creates/updates index.ts with `export * as Foo from "./<file>"`
|
||||
* 6. Rewrites import paths across src/, test/, and script/
|
||||
* 7. Fixes sibling imports within the same directory
|
||||
*
|
||||
* Requires: ast-grep (`brew install ast-grep` or `cargo install ast-grep`)
|
||||
*/
|
||||
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const dryRun = args.includes("--dry-run")
|
||||
const nameFlag = args.find((a, i) => args[i - 1] === "--name")
|
||||
const filePath = args.find((a) => !a.startsWith("--") && args[args.indexOf(a) - 1] !== "--name")
|
||||
|
||||
if (!filePath) {
|
||||
console.error("Usage: bun script/unwrap-namespace.ts <file> [--dry-run] [--name <impl-name>]")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const absPath = path.resolve(filePath)
|
||||
if (!fs.existsSync(absPath)) {
|
||||
console.error(`File not found: ${absPath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const src = fs.readFileSync(absPath, "utf-8")
|
||||
const lines = src.split("\n")
|
||||
|
||||
// Use ast-grep to find the namespace boundaries accurately.
|
||||
// This avoids false matches from braces in strings, templates, comments, etc.
|
||||
const astResult = Bun.spawnSync(
|
||||
["ast-grep", "run", "--pattern", "export namespace $NAME { $$$BODY }", "--lang", "typescript", "--json", absPath],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
)
|
||||
|
||||
if (astResult.exitCode !== 0) {
|
||||
console.error("ast-grep failed:", astResult.stderr.toString())
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const matches = JSON.parse(astResult.stdout.toString()) as Array<{
|
||||
text: string
|
||||
range: { start: { line: number; column: number }; end: { line: number; column: number } }
|
||||
metaVariables: { single: Record<string, { text: string }>; multi: Record<string, Array<{ text: string }>> }
|
||||
}>
|
||||
|
||||
if (matches.length === 0) {
|
||||
console.error("No `export namespace Foo { ... }` found in file")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (matches.length > 1) {
|
||||
console.error(`Found ${matches.length} namespaces — this script handles one at a time`)
|
||||
console.error("Namespaces found:")
|
||||
for (const m of matches) console.error(` ${m.metaVariables.single.NAME.text} (line ${m.range.start.line + 1})`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const match = matches[0]
|
||||
const nsName = match.metaVariables.single.NAME.text
|
||||
const nsLine = match.range.start.line // 0-indexed
|
||||
const closeLine = match.range.end.line // 0-indexed, the line with closing `}`
|
||||
|
||||
console.log(`Found: export namespace ${nsName} { ... }`)
|
||||
console.log(` Lines ${nsLine + 1}–${closeLine + 1} (${closeLine - nsLine + 1} lines)`)
|
||||
|
||||
// Build the new file content:
|
||||
// 1. Everything before the namespace declaration (imports, etc.)
|
||||
// 2. The namespace body, dedented by one level (2 spaces)
|
||||
// 3. Everything after the closing brace (rare, but possible)
|
||||
const before = lines.slice(0, nsLine)
|
||||
const body = lines.slice(nsLine + 1, closeLine)
|
||||
const after = lines.slice(closeLine + 1)
|
||||
|
||||
// Dedent: remove exactly 2 leading spaces from each line
|
||||
const dedented = body.map((line) => {
|
||||
if (line === "") return ""
|
||||
if (line.startsWith(" ")) return line.slice(2)
|
||||
return line
|
||||
})
|
||||
|
||||
let newContent = [...before, ...dedented, ...after].join("\n")
|
||||
|
||||
// --- Fix self-references ---
|
||||
// After unwrapping, references like `Config.PermissionAction` inside the same file
|
||||
// need to become just `PermissionAction`. Only fix code positions, not strings.
|
||||
const exportedNames = new Set<string>()
|
||||
const exportRegex = /export\s+(?:const|function|class|interface|type|enum|abstract\s+class)\s+(\w+)/g
|
||||
for (const line of dedented) {
|
||||
for (const m of line.matchAll(exportRegex)) exportedNames.add(m[1])
|
||||
}
|
||||
const reExportRegex = /export\s*\{\s*([^}]+)\}/g
|
||||
for (const line of dedented) {
|
||||
for (const m of line.matchAll(reExportRegex)) {
|
||||
for (const name of m[1].split(",")) {
|
||||
const trimmed = name
|
||||
.trim()
|
||||
.split(/\s+as\s+/)
|
||||
.pop()!
|
||||
.trim()
|
||||
if (trimmed) exportedNames.add(trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let selfRefCount = 0
|
||||
if (exportedNames.size > 0) {
|
||||
const fixedLines = newContent.split("\n").map((line) => {
|
||||
// Split line into string-literal and code segments to avoid replacing inside strings
|
||||
const segments: Array<{ text: string; isString: boolean }> = []
|
||||
let i = 0
|
||||
let current = ""
|
||||
let inString: string | null = null
|
||||
|
||||
while (i < line.length) {
|
||||
const ch = line[i]
|
||||
if (inString) {
|
||||
current += ch
|
||||
if (ch === "\\" && i + 1 < line.length) {
|
||||
current += line[i + 1]
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if (ch === inString) {
|
||||
segments.push({ text: current, isString: true })
|
||||
current = ""
|
||||
inString = null
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (ch === '"' || ch === "'" || ch === "`") {
|
||||
if (current) segments.push({ text: current, isString: false })
|
||||
current = ch
|
||||
inString = ch
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (ch === "/" && i + 1 < line.length && line[i + 1] === "/") {
|
||||
current += line.slice(i)
|
||||
segments.push({ text: current, isString: true })
|
||||
current = ""
|
||||
i = line.length
|
||||
continue
|
||||
}
|
||||
current += ch
|
||||
i++
|
||||
}
|
||||
if (current) segments.push({ text: current, isString: !!inString })
|
||||
|
||||
return segments
|
||||
.map((seg) => {
|
||||
if (seg.isString) return seg.text
|
||||
let result = seg.text
|
||||
for (const name of exportedNames) {
|
||||
const pattern = `${nsName}.${name}`
|
||||
while (result.includes(pattern)) {
|
||||
const idx = result.indexOf(pattern)
|
||||
const charBefore = idx > 0 ? result[idx - 1] : " "
|
||||
const charAfter = idx + pattern.length < result.length ? result[idx + pattern.length] : " "
|
||||
if (/\w/.test(charBefore) || /\w/.test(charAfter)) break
|
||||
result = result.slice(0, idx) + name + result.slice(idx + pattern.length)
|
||||
selfRefCount++
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
.join("")
|
||||
})
|
||||
newContent = fixedLines.join("\n")
|
||||
}
|
||||
|
||||
// Figure out file naming
|
||||
const dir = path.dirname(absPath)
|
||||
const basename = path.basename(absPath, ".ts")
|
||||
const isIndex = basename === "index"
|
||||
const implName = nameFlag ?? (isIndex ? nsName.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase() : basename)
|
||||
const implFile = path.join(dir, `${implName}.ts`)
|
||||
const indexFile = path.join(dir, "index.ts")
|
||||
const barrelLine = `export * as ${nsName} from "./${implName}"\n`
|
||||
|
||||
console.log("")
|
||||
if (isIndex) {
|
||||
console.log(`Plan: rename ${basename}.ts → ${implName}.ts, create new index.ts barrel`)
|
||||
} else {
|
||||
console.log(`Plan: rewrite ${basename}.ts in place, create index.ts barrel`)
|
||||
}
|
||||
if (selfRefCount > 0) console.log(`Fixed ${selfRefCount} self-reference(s) (${nsName}.X → X)`)
|
||||
console.log("")
|
||||
|
||||
if (dryRun) {
|
||||
console.log("--- DRY RUN ---")
|
||||
console.log("")
|
||||
console.log(`=== ${implName}.ts (first 30 lines) ===`)
|
||||
newContent
|
||||
.split("\n")
|
||||
.slice(0, 30)
|
||||
.forEach((l, i) => console.log(` ${i + 1}: ${l}`))
|
||||
console.log(" ...")
|
||||
console.log("")
|
||||
console.log(`=== index.ts ===`)
|
||||
console.log(` ${barrelLine.trim()}`)
|
||||
console.log("")
|
||||
if (!isIndex) {
|
||||
const relDir = path.relative(path.resolve("src"), dir)
|
||||
console.log(`=== Import rewrites (would apply) ===`)
|
||||
console.log(` ${relDir}/${basename}" → ${relDir}" across src/, test/, script/`)
|
||||
} else {
|
||||
console.log("No import rewrites needed (was index.ts)")
|
||||
}
|
||||
} else {
|
||||
if (isIndex) {
|
||||
fs.writeFileSync(implFile, newContent)
|
||||
fs.writeFileSync(indexFile, barrelLine)
|
||||
console.log(`Wrote ${implName}.ts (${newContent.split("\n").length} lines)`)
|
||||
console.log(`Wrote index.ts (barrel)`)
|
||||
} else {
|
||||
fs.writeFileSync(absPath, newContent)
|
||||
if (fs.existsSync(indexFile)) {
|
||||
const existing = fs.readFileSync(indexFile, "utf-8")
|
||||
if (!existing.includes(`export * as ${nsName}`)) {
|
||||
fs.appendFileSync(indexFile, barrelLine)
|
||||
console.log(`Appended to existing index.ts`)
|
||||
} else {
|
||||
console.log(`index.ts already has ${nsName} export`)
|
||||
}
|
||||
} else {
|
||||
fs.writeFileSync(indexFile, barrelLine)
|
||||
console.log(`Wrote index.ts (barrel)`)
|
||||
}
|
||||
console.log(`Rewrote ${basename}.ts (${newContent.split("\n").length} lines)`)
|
||||
}
|
||||
|
||||
// --- Rewrite import paths across src/, test/, script/ ---
|
||||
const relDir = path.relative(path.resolve("src"), dir)
|
||||
if (!isIndex) {
|
||||
const oldTail = `${relDir}/${basename}`
|
||||
const searchDirs = ["src", "test", "script"].filter((d) => fs.existsSync(d))
|
||||
const rgResult = Bun.spawnSync(["rg", "-l", `from.*${oldTail}"`, ...searchDirs], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const filesToRewrite = rgResult.stdout
|
||||
.toString()
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((f) => f.length > 0)
|
||||
|
||||
if (filesToRewrite.length > 0) {
|
||||
console.log(`\nRewriting imports in ${filesToRewrite.length} file(s)...`)
|
||||
for (const file of filesToRewrite) {
|
||||
const content = fs.readFileSync(file, "utf-8")
|
||||
fs.writeFileSync(file, content.replaceAll(`${oldTail}"`, `${relDir}"`))
|
||||
}
|
||||
console.log(` Done: ${oldTail}" → ${relDir}"`)
|
||||
} else {
|
||||
console.log("\nNo import rewrites needed")
|
||||
}
|
||||
} else {
|
||||
console.log("\nNo import rewrites needed (was index.ts)")
|
||||
}
|
||||
|
||||
// --- Fix sibling imports within the same directory ---
|
||||
const siblingFiles = fs.readdirSync(dir).filter((f) => {
|
||||
if (!f.endsWith(".ts")) return false
|
||||
if (f === "index.ts" || f === `${implName}.ts`) return false
|
||||
return true
|
||||
})
|
||||
|
||||
let siblingFixCount = 0
|
||||
for (const sibFile of siblingFiles) {
|
||||
const sibPath = path.join(dir, sibFile)
|
||||
const content = fs.readFileSync(sibPath, "utf-8")
|
||||
const pattern = new RegExp(`from\\s+["']\\./${basename}["']`, "g")
|
||||
if (pattern.test(content)) {
|
||||
fs.writeFileSync(sibPath, content.replace(pattern, `from "."`))
|
||||
siblingFixCount++
|
||||
}
|
||||
}
|
||||
if (siblingFixCount > 0) {
|
||||
console.log(`Fixed ${siblingFixCount} sibling import(s) in ${path.basename(dir)}/ (./${basename} → .)`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log("")
|
||||
console.log("=== Verify ===")
|
||||
console.log("")
|
||||
console.log("bunx --bun tsgo --noEmit # typecheck")
|
||||
console.log("bun run test # run tests")
|
||||
@@ -121,17 +121,46 @@ Why `question` first:
|
||||
|
||||
Do not re-architect business logic during the HTTP migration. `HttpApi` handlers should call the same Effect services already used by the Hono handlers.
|
||||
|
||||
### 4. Build in parallel, do not bridge into Hono
|
||||
### 4. Bridge into Hono behind a feature flag
|
||||
|
||||
The `HttpApi` implementation lives under `src/server/instance/httpapi/` as a standalone Effect HTTP server. It is **not mounted into the Hono app**. There is no `toWebHandler` bridge, no Hono `Handler` export, and no `.route()` call wiring it into `experimental.ts`.
|
||||
The `HttpApi` routes are bridged into the Hono server via `HttpRouter.toWebHandler` with a shared `memoMap`. This means:
|
||||
|
||||
The standalone server (`httpapi/server.ts`) can be started independently and proves the routes work. Tests exercise it via `HttpRouter.serve` with `NodeHttpServer.layerTest`.
|
||||
- one process, one port — no separate server
|
||||
- the Effect handler shares layer instances with `AppRuntime` (same `Question.Service`, etc.)
|
||||
- Effect middleware handles auth and instance lookup independently from Hono middleware
|
||||
- Hono's `.all()` catch-all intercepts matching paths before the Hono route handlers
|
||||
|
||||
The goal is to build enough route coverage in the Effect server that the Hono server can eventually be replaced entirely. Until then, the two implementations exist side by side but are completely separate processes.
|
||||
The bridge is gated behind `OPENCODE_EXPERIMENTAL_HTTPAPI` (or `OPENCODE_EXPERIMENTAL`). When the flag is off (default), all requests go through the original Hono handlers unchanged.
|
||||
|
||||
### 5. Migrate JSON route groups gradually
|
||||
```ts
|
||||
// in instance/index.ts
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) {
|
||||
const handler = ExperimentalHttpApiServer.webHandler().handler
|
||||
app.all("/question", (c) => handler(c.req.raw)).all("/question/*", (c) => handler(c.req.raw))
|
||||
}
|
||||
```
|
||||
|
||||
If the parallel slice works well, migrate additional JSON route groups one at a time. Leave streaming-style endpoints on Hono until there is a clear reason to move them.
|
||||
The Hono route handlers are always registered (after the bridge) so `hono-openapi` generates the OpenAPI spec entries that feed SDK codegen. When the flag is on, these handlers are dead code — the `.all()` bridge matches first.
|
||||
|
||||
### 5. Observability
|
||||
|
||||
The `webHandler` provides `Observability.layer` via `Layer.provideMerge`. Since the `memoMap` is shared with `AppRuntime`, the tracing provider is deduplicated — no extra initialization cost.
|
||||
|
||||
This gives:
|
||||
|
||||
- **spans**: `Effect.fn("QuestionHttpApi.list")` etc. appear in traces alongside service-layer spans
|
||||
- **HTTP logs**: `HttpMiddleware.logger` emits structured `Effect.log` entries with `http.method`, `http.url`, `http.status` annotations, flowing to motel via `OtlpLogger`
|
||||
|
||||
### 6. Migrate JSON route groups gradually
|
||||
|
||||
As each route group is ported to `HttpApi`:
|
||||
|
||||
1. change its `root` path from `/experimental/httpapi/<group>` to `/<group>`
|
||||
2. add `.all("/<group>", handler)` / `.all("/<group>/*", handler)` to the flag block in `instance/index.ts`
|
||||
3. for partial ports (e.g. only `GET /provider/auth`), bridge only the specific path
|
||||
4. verify SDK output is unchanged
|
||||
|
||||
Leave streaming-style endpoints on Hono until there is a clear reason to move them.
|
||||
|
||||
## Schema rule for HttpApi work
|
||||
|
||||
@@ -156,6 +185,14 @@ Ordering for a route-group migration:
|
||||
3. move tagged route-facing errors to `Schema.TaggedErrorClass` where needed
|
||||
4. switch existing Zod boundary validators to derived `.zod`
|
||||
5. define the `HttpApi` contract from the canonical Effect schemas
|
||||
6. regenerate the SDK (`./packages/sdk/js/script/build.ts`) and verify zero diff against `dev`
|
||||
|
||||
SDK shape rule:
|
||||
|
||||
- every schema migration must preserve the generated SDK output byte-for-byte
|
||||
- `Schema.Class` emits a named `$ref` in OpenAPI via its identifier — use it only for types that already had `.meta({ ref })` in the old Zod schema
|
||||
- inner / nested types that were anonymous in the old Zod schema should stay as `Schema.Struct` (not `Schema.Class`) to avoid introducing new named components in the OpenAPI spec
|
||||
- if a diff appears in `packages/sdk/js/src/v2/gen/types.gen.ts`, the migration introduced an unintended API surface change — fix it before merging
|
||||
|
||||
Temporary exception:
|
||||
|
||||
@@ -195,8 +232,9 @@ Use the same sequence for each route group.
|
||||
4. Define the `HttpApi` contract separately from the handlers.
|
||||
5. Implement handlers by yielding the existing service from context.
|
||||
6. Mount the new surface in parallel under an experimental prefix.
|
||||
7. Add one end-to-end test and one OpenAPI-focused test.
|
||||
8. Compare ergonomics before migrating the next endpoint.
|
||||
7. Regenerate the SDK and verify zero diff against `dev` (see SDK shape rule above).
|
||||
8. Add one end-to-end test and one OpenAPI-focused test.
|
||||
9. Compare ergonomics before migrating the next endpoint.
|
||||
|
||||
Rule of thumb:
|
||||
|
||||
@@ -293,36 +331,43 @@ The first slice is successful if:
|
||||
- OpenAPI is generated from the `HttpApi` contract
|
||||
- the tests are straightforward enough that the next slice feels mechanical
|
||||
|
||||
## Learnings from the question slice
|
||||
## Learnings
|
||||
|
||||
The first parallel `question` spike gave us a concrete pattern to reuse.
|
||||
### Schema
|
||||
|
||||
- `Schema.Class` works well for route DTOs such as `Question.Request`, `Question.Info`, and `Question.Reply`.
|
||||
- scalar or collection schemas such as `Question.Answer` should stay as schemas and use helpers like `withStatics(...)` instead of being forced into classes.
|
||||
- if an `HttpApi` success schema uses `Schema.Class`, the handler or underlying service needs to return real schema instances rather than plain objects.
|
||||
- internal event payloads can stay anonymous when we want to avoid adding extra named OpenAPI component churn for non-route shapes.
|
||||
- the experimental slice should stay as a standalone Effect server and keep calling the existing service layer unchanged.
|
||||
- compare generated OpenAPI semantically at the route and schema level.
|
||||
- `Schema.Class` emits named `$ref` in OpenAPI — only use it for types that already had `.meta({ ref })` in the old Zod schema. Inner/nested types should stay as `Schema.Struct` to avoid SDK shape changes.
|
||||
|
||||
### Integration
|
||||
|
||||
- `HttpRouter.toWebHandler` with the shared `memoMap` from `run-service.ts` cleanly bridges Effect routes into Hono — one process, one port, shared layer instances.
|
||||
- `Observability.layer` must be explicitly provided via `Layer.provideMerge` in the routes layer for OTEL spans and HTTP logs to flow. The `memoMap` deduplicates it with `AppRuntime` — no extra cost.
|
||||
- `HttpMiddleware.logger` (enabled by default when `disableLogger` is not set) emits structured `Effect.log` entries with `http.method`, `http.url`, `http.status` — these flow through `OtlpLogger` to motel.
|
||||
- Hono OpenAPI stubs must remain registered for SDK codegen until the SDK pipeline reads from the Effect OpenAPI spec instead.
|
||||
- the `OPENCODE_EXPERIMENTAL_HTTPAPI` flag gates the bridge at the Hono router level — default off, no behavior change unless opted in.
|
||||
|
||||
## Route inventory
|
||||
|
||||
Status legend:
|
||||
|
||||
- `done` - parallel `HttpApi` slice exists
|
||||
- `bridged` - Effect HttpApi slice exists and is bridged into Hono behind the flag
|
||||
- `done` - Effect HttpApi slice exists but not yet bridged
|
||||
- `next` - good near-term candidate
|
||||
- `later` - possible, but not first wave
|
||||
- `defer` - not a good early `HttpApi` target
|
||||
|
||||
Current instance route inventory:
|
||||
|
||||
- `question` - `done`
|
||||
endpoints in slice: `GET /question`, `POST /question/:requestID/reply`
|
||||
- `permission` - `done`
|
||||
endpoints in slice: `GET /permission`, `POST /permission/:requestID/reply`
|
||||
- `provider` - `next`
|
||||
best next endpoint: `GET /provider/auth`
|
||||
later endpoint: `GET /provider`
|
||||
defer first-wave OAuth mutations
|
||||
- `question` - `bridged`
|
||||
endpoints: `GET /question`, `POST /question/:requestID/reply`, `POST /question/:requestID/reject`
|
||||
- `permission` - `bridged`
|
||||
endpoints: `GET /permission`, `POST /permission/:requestID/reply`
|
||||
- `provider` - `bridged` (partial)
|
||||
bridged endpoint: `GET /provider/auth`
|
||||
not yet ported: `GET /provider`, OAuth mutations
|
||||
- `config` - `next`
|
||||
best next endpoint: `GET /config/providers`
|
||||
later endpoint: `GET /config`
|
||||
@@ -362,7 +407,13 @@ Recommended near-term sequence after the first spike:
|
||||
- [x] keep the underlying service calls identical to the current handlers
|
||||
- [x] compare generated OpenAPI against the current Hono/OpenAPI setup
|
||||
- [x] document how auth, instance lookup, and error mapping would compose in the new stack
|
||||
- [ ] decide after the spike whether `HttpApi` should stay parallel, replace only some groups, or become the long-term default
|
||||
- [x] bridge Effect routes into Hono via `toWebHandler` with shared `memoMap`
|
||||
- [x] gate behind `OPENCODE_EXPERIMENTAL_HTTPAPI` flag
|
||||
- [x] verify OTEL spans and HTTP logs flow to motel
|
||||
- [x] bridge question, permission, and provider auth routes
|
||||
- [ ] port remaining provider endpoints (`GET /provider`, OAuth mutations)
|
||||
- [ ] port `config` read endpoints
|
||||
- [ ] decide when to remove the flag and make Effect routes the default
|
||||
|
||||
## Rule of thumb
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user