mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-04 09:16:20 -04:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce8e9b1d84 | |||
| 2d6d788db5 | |||
| dd68b85f58 | |||
| 84df96eaef | |||
| d9dd33aeeb | |||
| 0a281c7390 | |||
| 3016efba47 | |||
| 3998df8112 | |||
| 7066e2a25e | |||
| c173988aaa | |||
| 268855dc5a |
@@ -8,7 +8,9 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
# Keep every run on dev so cancelled checks do not pollute the default branch
|
||||
# commit history. PRs and other branches still share a group and cancel stale runs.
|
||||
group: ${{ case(github.ref == 'refs/heads/dev', format('{0}-{1}', github.workflow, github.run_id), format('{0}-{1}', github.workflow, github.event.pull_request.number || github.ref)) }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -6,6 +6,7 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
|
||||
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
|
||||
const reuse = !process.env.CI
|
||||
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
@@ -17,6 +18,7 @@ export default defineConfig({
|
||||
fullyParallel: process.env.PLAYWRIGHT_FULLY_PARALLEL === "1",
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers,
|
||||
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
|
||||
webServer: {
|
||||
command,
|
||||
|
||||
+19
-16
@@ -159,7 +159,7 @@ const effectMinDuration =
|
||||
<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) {
|
||||
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
|
||||
const server = useServer()
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
|
||||
@@ -168,21 +168,23 @@ function ConnectionGate(props: ParentProps) {
|
||||
// performs repeated health check with a grace period for
|
||||
// non-http connections, otherwise fails instantly
|
||||
const [startupHealthCheck, healthCheckActions] = createResource(() =>
|
||||
Effect.gen(function* () {
|
||||
if (!server.current) return true
|
||||
const { http, type } = server.current
|
||||
props.disableHealthCheck
|
||||
? true
|
||||
: Effect.gen(function* () {
|
||||
if (!server.current) return true
|
||||
const { http, type } = server.current
|
||||
|
||||
while (true) {
|
||||
const res = yield* Effect.promise(() => checkServerHealth(http))
|
||||
if (res.healthy) return true
|
||||
if (checkMode() === "background" || type === "http") return false
|
||||
}
|
||||
}).pipe(
|
||||
effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
|
||||
Effect.timeoutOrElse({ duration: "10 seconds", onTimeout: () => Effect.succeed(false) }),
|
||||
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
|
||||
Effect.runPromise,
|
||||
),
|
||||
while (true) {
|
||||
const res = yield* Effect.promise(() => checkServerHealth(http))
|
||||
if (res.healthy) return true
|
||||
if (checkMode() === "background" || type === "http") return false
|
||||
}
|
||||
}).pipe(
|
||||
effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
|
||||
Effect.timeoutOrElse({ duration: "10 seconds", onTimeout: () => Effect.succeed(false) }),
|
||||
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
|
||||
Effect.runPromise,
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -261,10 +263,11 @@ export function AppInterface(props: {
|
||||
defaultServer: ServerConnection.Key
|
||||
servers?: Array<ServerConnection.Any>
|
||||
router?: Component<BaseRouterProps>
|
||||
disableHealthCheck?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
|
||||
<ConnectionGate>
|
||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
||||
<GlobalSDKProvider>
|
||||
<GlobalSyncProvider>
|
||||
<Dynamic
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// @refresh reload
|
||||
|
||||
import { iife } from "@opencode-ai/util/iife"
|
||||
import { render } from "solid-js/web"
|
||||
import { AppBaseProviders, AppInterface } from "@/app"
|
||||
import { type Platform, PlatformProvider } from "@/context/platform"
|
||||
@@ -132,7 +131,11 @@ if (root instanceof HTMLElement) {
|
||||
() => (
|
||||
<PlatformProvider value={platform}>
|
||||
<AppBaseProviders>
|
||||
<AppInterface defaultServer={ServerConnection.Key.make(getDefaultUrl())} servers={[server]} />
|
||||
<AppInterface
|
||||
defaultServer={ServerConnection.Key.make(getDefaultUrl())}
|
||||
servers={[server]}
|
||||
disableHealthCheck
|
||||
/>
|
||||
</AppBaseProviders>
|
||||
</PlatformProvider>
|
||||
),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createServer } from "node:net"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { Event } from "electron"
|
||||
import { app, type BrowserWindow, dialog } from "electron"
|
||||
import { app, BrowserWindow, dialog } from "electron"
|
||||
import pkg from "electron-updater"
|
||||
|
||||
const APP_NAMES: Record<string, string> = {
|
||||
@@ -32,7 +32,7 @@ import { initLogging } from "./logging"
|
||||
import { parseMarkdown } from "./markdown"
|
||||
import { createMenu } from "./menu"
|
||||
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
|
||||
import { createLoadingWindow, createMainWindow, setDockIcon } from "./windows"
|
||||
import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows"
|
||||
|
||||
const initEmitter = new EventEmitter()
|
||||
let initStep: InitStep = { phase: "server_waiting" }
|
||||
@@ -156,12 +156,9 @@ async function initialize() {
|
||||
|
||||
const globals = {
|
||||
updaterEnabled: UPDATER_ENABLED,
|
||||
wsl: getWslConfig().enabled,
|
||||
deepLinks: pendingDeepLinks,
|
||||
}
|
||||
|
||||
wireMenu()
|
||||
|
||||
if (needsMigration) {
|
||||
const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)])
|
||||
if (show) {
|
||||
@@ -178,6 +175,7 @@ async function initialize() {
|
||||
}
|
||||
|
||||
mainWindow = createMainWindow(globals)
|
||||
wireMenu()
|
||||
|
||||
overlay?.close()
|
||||
}
|
||||
@@ -231,6 +229,7 @@ registerIpcHandlers({
|
||||
runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail),
|
||||
checkUpdate: async () => checkUpdate(),
|
||||
installUpdate: async () => installUpdate(),
|
||||
setBackgroundColor: (color) => setBackgroundColor(color),
|
||||
})
|
||||
|
||||
function killSidecar() {
|
||||
|
||||
@@ -24,6 +24,7 @@ type Deps = {
|
||||
runUpdater: (alertOnFail: boolean) => Promise<void> | void
|
||||
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
|
||||
installUpdate: () => Promise<void> | void
|
||||
setBackgroundColor: (color: string) => void
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(deps: Deps) {
|
||||
@@ -53,6 +54,7 @@ export function registerIpcHandlers(deps: Deps) {
|
||||
ipcMain.handle("run-updater", (_event: IpcMainInvokeEvent, alertOnFail: boolean) => deps.runUpdater(alertOnFail))
|
||||
ipcMain.handle("check-update", () => deps.checkUpdate())
|
||||
ipcMain.handle("install-update", () => deps.installUpdate())
|
||||
ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color))
|
||||
ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => {
|
||||
const store = getStore(name)
|
||||
const value = store.get(key)
|
||||
@@ -140,6 +142,8 @@ export function registerIpcHandlers(deps: Deps) {
|
||||
new Notification({ title, body }).show()
|
||||
})
|
||||
|
||||
ipcMain.handle("get-window-count", () => BrowserWindow.getAllWindows().length)
|
||||
|
||||
ipcMain.handle("get-window-focused", (event: IpcMainInvokeEvent) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
return win?.isFocused() ?? false
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BrowserWindow, Menu, shell } from "electron"
|
||||
|
||||
import { UPDATER_ENABLED } from "./constants"
|
||||
import { createMainWindow } from "./windows"
|
||||
|
||||
type Deps = {
|
||||
trigger: (id: string) => void
|
||||
@@ -48,6 +49,11 @@ export function createMenu(deps: Deps) {
|
||||
submenu: [
|
||||
{ label: "New Session", accelerator: "Shift+Cmd+S", click: () => deps.trigger("session.new") },
|
||||
{ label: "Open Project...", accelerator: "Cmd+O", click: () => deps.trigger("project.open") },
|
||||
{
|
||||
label: "New Window",
|
||||
accelerator: "Cmd+Shift+N",
|
||||
click: () => createMainWindow({ updaterEnabled: UPDATER_ENABLED }),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{ role: "close" },
|
||||
],
|
||||
|
||||
@@ -6,12 +6,21 @@ import type { TitlebarTheme } from "../preload/types"
|
||||
|
||||
type Globals = {
|
||||
updaterEnabled: boolean
|
||||
wsl: boolean
|
||||
deepLinks?: string[]
|
||||
}
|
||||
|
||||
const root = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
let backgroundColor: string | undefined
|
||||
|
||||
export function setBackgroundColor(color: string) {
|
||||
backgroundColor = color
|
||||
}
|
||||
|
||||
export function getBackgroundColor(): string | undefined {
|
||||
return backgroundColor
|
||||
}
|
||||
|
||||
function iconsDir() {
|
||||
return app.isPackaged ? join(process.resourcesPath, "icons") : join(root, "../../resources/icons")
|
||||
}
|
||||
@@ -59,6 +68,7 @@ export function createMainWindow(globals: Globals) {
|
||||
show: true,
|
||||
title: "OpenCode",
|
||||
icon: iconPath(),
|
||||
backgroundColor,
|
||||
...(process.platform === "darwin"
|
||||
? {
|
||||
titleBarStyle: "hidden" as const,
|
||||
@@ -95,6 +105,7 @@ export function createLoadingWindow(globals: Globals) {
|
||||
center: true,
|
||||
show: true,
|
||||
icon: iconPath(),
|
||||
backgroundColor,
|
||||
...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const } : {}),
|
||||
...(process.platform === "win32"
|
||||
? {
|
||||
@@ -131,7 +142,6 @@ function injectGlobals(win: BrowserWindow, globals: Globals) {
|
||||
const deepLinks = globals.deepLinks ?? []
|
||||
const data = {
|
||||
updaterEnabled: globals.updaterEnabled,
|
||||
wsl: globals.wsl,
|
||||
deepLinks: Array.isArray(deepLinks) ? deepLinks.splice(0) : deepLinks,
|
||||
}
|
||||
void win.webContents.executeJavaScript(
|
||||
|
||||
@@ -28,6 +28,7 @@ const api: ElectronAPI = {
|
||||
storeKeys: (name) => ipcRenderer.invoke("store-keys", name),
|
||||
storeLength: (name) => ipcRenderer.invoke("store-length", name),
|
||||
|
||||
getWindowCount: () => ipcRenderer.invoke("get-window-count"),
|
||||
onSqliteMigrationProgress: (cb) => {
|
||||
const handler = (_: unknown, progress: SqliteMigrationProgress) => cb(progress)
|
||||
ipcRenderer.on("sqlite-migration-progress", handler)
|
||||
@@ -62,6 +63,7 @@ const api: ElectronAPI = {
|
||||
runUpdater: (alertOnFail) => ipcRenderer.invoke("run-updater", alertOnFail),
|
||||
checkUpdate: () => ipcRenderer.invoke("check-update"),
|
||||
installUpdate: () => ipcRenderer.invoke("install-update"),
|
||||
setBackgroundColor: (color: string) => ipcRenderer.invoke("set-background-color", color),
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("api", api)
|
||||
|
||||
@@ -36,6 +36,7 @@ export type ElectronAPI = {
|
||||
storeKeys: (name: string) => Promise<string[]>
|
||||
storeLength: (name: string) => Promise<number>
|
||||
|
||||
getWindowCount: () => Promise<number>
|
||||
onSqliteMigrationProgress: (cb: (progress: SqliteMigrationProgress) => void) => () => void
|
||||
onMenuCommand: (cb: (id: string) => void) => () => void
|
||||
onDeepLink: (cb: (urls: string[]) => void) => () => void
|
||||
@@ -66,4 +67,5 @@ export type ElectronAPI = {
|
||||
runUpdater: (alertOnFail: boolean) => Promise<void>
|
||||
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
|
||||
installUpdate: () => Promise<void>
|
||||
setBackgroundColor: (color: string) => Promise<void>
|
||||
}
|
||||
|
||||
@@ -10,14 +10,15 @@ import {
|
||||
useCommand,
|
||||
} from "@opencode-ai/app"
|
||||
import type { AsyncStorage } from "@solid-primitives/storage"
|
||||
import { createResource, onCleanup, onMount, Show } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import { MemoryRouter } from "@solidjs/router"
|
||||
import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import pkg from "../../package.json"
|
||||
import { initI18n, t } from "./i18n"
|
||||
import { UPDATER_ENABLED } from "./updater"
|
||||
import { webviewZoom } from "./webview-zoom"
|
||||
import "./styles.css"
|
||||
import { useTheme } from "@opencode-ai/ui/theme"
|
||||
|
||||
const root = document.getElementById("root")
|
||||
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
||||
@@ -226,7 +227,9 @@ const createPlatform = (): Platform => {
|
||||
const image = await window.api.readClipboardImage().catch(() => null)
|
||||
if (!image) return null
|
||||
const blob = new Blob([image.buffer], { type: "image/png" })
|
||||
return new File([blob], `pasted-image-${Date.now()}.png`, { type: "image/png" })
|
||||
return new File([blob], `pasted-image-${Date.now()}.png`, {
|
||||
type: "image/png",
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -240,6 +243,8 @@ listenForDeepLinks()
|
||||
render(() => {
|
||||
const platform = createPlatform()
|
||||
|
||||
const [windowCount] = createResource(() => window.api.getWindowCount())
|
||||
|
||||
// Fetch sidecar credentials (available immediately, before health check)
|
||||
const [sidecar] = createResource(() => window.api.awaitInitialization(() => undefined))
|
||||
|
||||
@@ -276,6 +281,18 @@ render(() => {
|
||||
function Inner() {
|
||||
const cmd = useCommand()
|
||||
menuTrigger = (id) => cmd.trigger(id)
|
||||
|
||||
const theme = useTheme()
|
||||
|
||||
createEffect(() => {
|
||||
theme.themeId()
|
||||
theme.mode()
|
||||
const bg = getComputedStyle(document.documentElement).getPropertyValue("--background-base").trim()
|
||||
if (bg) {
|
||||
void window.api.setBackgroundColor(bg)
|
||||
}
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -289,13 +306,14 @@ render(() => {
|
||||
return (
|
||||
<PlatformProvider value={platform}>
|
||||
<AppBaseProviders>
|
||||
<Show when={!defaultServer.loading && !sidecar.loading}>
|
||||
<Show when={!defaultServer.loading && !sidecar.loading && !windowCount.loading}>
|
||||
{(_) => {
|
||||
return (
|
||||
<AppInterface
|
||||
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
|
||||
servers={servers()}
|
||||
router={MemoryRouter}
|
||||
disableHealthCheck={(windowCount() ?? 0) > 1}
|
||||
>
|
||||
<Inner />
|
||||
</AppInterface>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { Menu, MenuItem, PredefinedMenuItem, Submenu } from "@tauri-apps/api/menu"
|
||||
import { openUrl } from "@tauri-apps/plugin-opener"
|
||||
import { type as ostype } from "@tauri-apps/plugin-os"
|
||||
import { relaunch } from "@tauri-apps/plugin-process"
|
||||
import { openUrl } from "@tauri-apps/plugin-opener"
|
||||
|
||||
import { runUpdater, UPDATER_ENABLED } from "./updater"
|
||||
import { commands } from "./bindings"
|
||||
import { installCli } from "./cli"
|
||||
import { initI18n, t } from "./i18n"
|
||||
import { commands } from "./bindings"
|
||||
import { runUpdater, UPDATER_ENABLED } from "./updater"
|
||||
|
||||
export async function createMenu(trigger: (id: string) => void) {
|
||||
if (ostype() !== "macos") return
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import path from "path"
|
||||
import { Global } from "../global"
|
||||
import { Effect } from "effect"
|
||||
import z from "zod"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { runtime } from "@/effect/runtime"
|
||||
import * as S from "./service"
|
||||
|
||||
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
|
||||
export { OAUTH_DUMMY_KEY } from "./service"
|
||||
|
||||
function runPromise<A>(f: (service: S.AuthService.Service) => Effect.Effect<A, S.AuthServiceError>) {
|
||||
return runtime.runPromise(S.AuthService.use(f))
|
||||
}
|
||||
|
||||
export namespace Auth {
|
||||
export const Oauth = z
|
||||
@@ -35,39 +39,19 @@ export namespace Auth {
|
||||
export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).meta({ ref: "Auth" })
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
const filepath = path.join(Global.Path.data, "auth.json")
|
||||
|
||||
export async function get(providerID: string) {
|
||||
const auth = await all()
|
||||
return auth[providerID]
|
||||
return runPromise((service) => service.get(providerID))
|
||||
}
|
||||
|
||||
export async function all(): Promise<Record<string, Info>> {
|
||||
const data = await Filesystem.readJson<Record<string, unknown>>(filepath).catch(() => ({}))
|
||||
return Object.entries(data).reduce(
|
||||
(acc, [key, value]) => {
|
||||
const parsed = Info.safeParse(value)
|
||||
if (!parsed.success) return acc
|
||||
acc[key] = parsed.data
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, Info>,
|
||||
)
|
||||
return runPromise((service) => service.all())
|
||||
}
|
||||
|
||||
export async function set(key: string, info: Info) {
|
||||
const normalized = key.replace(/\/+$/, "")
|
||||
const data = await all()
|
||||
if (normalized !== key) delete data[key]
|
||||
delete data[normalized + "/"]
|
||||
await Filesystem.writeJson(filepath, { ...data, [normalized]: info }, 0o600)
|
||||
return runPromise((service) => service.set(key, info))
|
||||
}
|
||||
|
||||
export async function remove(key: string) {
|
||||
const normalized = key.replace(/\/+$/, "")
|
||||
const data = await all()
|
||||
delete data[key]
|
||||
delete data[normalized]
|
||||
await Filesystem.writeJson(filepath, data, 0o600)
|
||||
return runPromise((service) => service.remove(key))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import path from "path"
|
||||
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
|
||||
import { Global } from "../global"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
|
||||
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
|
||||
|
||||
export class Oauth extends Schema.Class<Oauth>("OAuth")({
|
||||
type: Schema.Literal("oauth"),
|
||||
refresh: Schema.String,
|
||||
access: Schema.String,
|
||||
expires: Schema.Number,
|
||||
accountId: Schema.optional(Schema.String),
|
||||
enterpriseUrl: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
||||
export class Api extends Schema.Class<Api>("ApiAuth")({
|
||||
type: Schema.Literal("api"),
|
||||
key: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
|
||||
type: Schema.Literal("wellknown"),
|
||||
key: Schema.String,
|
||||
token: Schema.String,
|
||||
}) {}
|
||||
|
||||
export const Info = Schema.Union([Oauth, Api, WellKnown])
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
export class AuthServiceError extends Schema.TaggedErrorClass<AuthServiceError>()("AuthServiceError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
const file = path.join(Global.Path.data, "auth.json")
|
||||
|
||||
const fail = (message: string) => (cause: unknown) => new AuthServiceError({ message, cause })
|
||||
|
||||
export namespace AuthService {
|
||||
export interface Service {
|
||||
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthServiceError>
|
||||
readonly all: () => Effect.Effect<Record<string, Info>, AuthServiceError>
|
||||
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthServiceError>
|
||||
readonly remove: (key: string) => Effect.Effect<void, AuthServiceError>
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthService extends ServiceMap.Service<AuthService, AuthService.Service>()("@opencode/Auth") {
|
||||
static readonly layer = Layer.effect(
|
||||
AuthService,
|
||||
Effect.gen(function* () {
|
||||
const decode = Schema.decodeUnknownOption(Info)
|
||||
|
||||
const all = Effect.fn("AuthService.all")(() =>
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
|
||||
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
|
||||
},
|
||||
catch: fail("Failed to read auth data"),
|
||||
}),
|
||||
)
|
||||
|
||||
const get = Effect.fn("AuthService.get")(function* (providerID: string) {
|
||||
return (yield* all())[providerID]
|
||||
})
|
||||
|
||||
const set = Effect.fn("AuthService.set")(function* (key: string, info: Info) {
|
||||
const norm = key.replace(/\/+$/, "")
|
||||
const data = yield* all()
|
||||
if (norm !== key) delete data[key]
|
||||
delete data[norm + "/"]
|
||||
yield* Effect.tryPromise({
|
||||
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
|
||||
catch: fail("Failed to write auth data"),
|
||||
})
|
||||
})
|
||||
|
||||
const remove = Effect.fn("AuthService.remove")(function* (key: string) {
|
||||
const norm = key.replace(/\/+$/, "")
|
||||
const data = yield* all()
|
||||
delete data[key]
|
||||
delete data[norm]
|
||||
yield* Effect.tryPromise({
|
||||
try: () => Filesystem.writeJson(file, data, 0o600),
|
||||
catch: fail("Failed to write auth data"),
|
||||
})
|
||||
})
|
||||
|
||||
return AuthService.of({
|
||||
get,
|
||||
all,
|
||||
set,
|
||||
remove,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
static readonly defaultLayer = AuthService.layer
|
||||
}
|
||||
@@ -192,3 +192,28 @@ export const OrgsCommand = cmd({
|
||||
await runtime.runPromise(orgsEffect())
|
||||
},
|
||||
})
|
||||
|
||||
export const ConsoleCommand = cmd({
|
||||
command: "console",
|
||||
describe: "manage console account",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.command({
|
||||
...LoginCommand,
|
||||
describe: "log in to console",
|
||||
})
|
||||
.command({
|
||||
...LogoutCommand,
|
||||
describe: "log out from console",
|
||||
})
|
||||
.command({
|
||||
...SwitchCommand,
|
||||
describe: "switch active org",
|
||||
})
|
||||
.command({
|
||||
...OrgsCommand,
|
||||
describe: "list orgs",
|
||||
})
|
||||
.demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
@@ -318,10 +318,10 @@ export const ProvidersLoginCommand = cmd({
|
||||
|
||||
const priority: Record<string, number> = {
|
||||
opencode: 0,
|
||||
anthropic: 1,
|
||||
openai: 1,
|
||||
"github-copilot": 2,
|
||||
openai: 3,
|
||||
google: 4,
|
||||
google: 3,
|
||||
anthropic: 4,
|
||||
openrouter: 5,
|
||||
vercel: 6,
|
||||
}
|
||||
|
||||
@@ -677,20 +677,6 @@ function App() {
|
||||
},
|
||||
])
|
||||
|
||||
createEffect(() => {
|
||||
const currentModel = local.model.current()
|
||||
if (!currentModel) return
|
||||
if (currentModel.providerID === "openrouter" && !kv.get("openrouter_warning", false)) {
|
||||
untrack(() => {
|
||||
DialogAlert.show(
|
||||
dialog,
|
||||
"Warning",
|
||||
"While openrouter is a convenient way to access LLMs your request will often be routed to subpar providers that do not work well in our testing.\n\nFor reliable access to models check out OpenCode Zen\nhttps://opencode.ai/zen",
|
||||
).then(() => kv.set("openrouter_warning", true))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
|
||||
command.trigger(evt.properties.command)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ManagedRuntime } from "effect"
|
||||
import { Layer, ManagedRuntime } from "effect"
|
||||
import { AccountService } from "@/account/service"
|
||||
import { AuthService } from "@/auth/service"
|
||||
|
||||
export const runtime = ManagedRuntime.make(AccountService.defaultLayer)
|
||||
export const runtime = ManagedRuntime.make(Layer.mergeAll(AccountService.defaultLayer, AuthService.defaultLayer))
|
||||
|
||||
@@ -3,7 +3,7 @@ import { hideBin } from "yargs/helpers"
|
||||
import { RunCommand } from "./cli/cmd/run"
|
||||
import { GenerateCommand } from "./cli/cmd/generate"
|
||||
import { Log } from "./util/log"
|
||||
import { LoginCommand, LogoutCommand, SwitchCommand, OrgsCommand } from "./cli/cmd/account"
|
||||
import { ConsoleCommand } from "./cli/cmd/account"
|
||||
import { ProvidersCommand } from "./cli/cmd/providers"
|
||||
import { AgentCommand } from "./cli/cmd/agent"
|
||||
import { UpgradeCommand } from "./cli/cmd/upgrade"
|
||||
@@ -135,10 +135,7 @@ let cli = yargs(hideBin(process.argv))
|
||||
.command(RunCommand)
|
||||
.command(GenerateCommand)
|
||||
.command(DebugCommand)
|
||||
.command(LoginCommand)
|
||||
.command(LogoutCommand)
|
||||
.command(SwitchCommand)
|
||||
.command(OrgsCommand)
|
||||
.command(ConsoleCommand)
|
||||
.command(ProvidersCommand)
|
||||
.command(AgentCommand)
|
||||
.command(UpgradeCommand)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Effect } from "effect"
|
||||
import { Log } from "@/util/log"
|
||||
import { Context } from "../util/context"
|
||||
import { Project } from "./project"
|
||||
@@ -5,6 +6,7 @@ import { State } from "./state"
|
||||
import { iife } from "@/util/iife"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { InstanceState } from "@/util/instance-state"
|
||||
|
||||
interface Context {
|
||||
directory: string
|
||||
@@ -106,7 +108,7 @@ export const Instance = {
|
||||
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
|
||||
const directory = Filesystem.resolve(input.directory)
|
||||
Log.Default.info("reloading instance", { directory })
|
||||
await State.dispose(directory)
|
||||
await Promise.all([State.dispose(directory), Effect.runPromise(InstanceState.dispose(directory))])
|
||||
cache.delete(directory)
|
||||
const next = track(directory, boot({ ...input, directory }))
|
||||
emit(directory)
|
||||
@@ -114,7 +116,7 @@ export const Instance = {
|
||||
},
|
||||
async dispose() {
|
||||
Log.Default.info("disposing instance", { directory: Instance.directory })
|
||||
await State.dispose(Instance.directory)
|
||||
await Promise.all([State.dispose(Instance.directory), Effect.runPromise(InstanceState.dispose(Instance.directory))])
|
||||
cache.delete(Instance.directory)
|
||||
emit(Instance.directory)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { Effect, Layer, Record, ServiceMap, Struct } from "effect"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Plugin } from "../plugin"
|
||||
import { filter, fromEntries, map, pipe } from "remeda"
|
||||
import type { AuthOuathResult } from "@opencode-ai/plugin"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import * as Auth from "@/auth/service"
|
||||
import { InstanceState } from "@/util/instance-state"
|
||||
import { ProviderID } from "./schema"
|
||||
import z from "zod"
|
||||
|
||||
export const Method = z
|
||||
.object({
|
||||
type: z.union([z.literal("oauth"), z.literal("api")]),
|
||||
label: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "ProviderAuthMethod",
|
||||
})
|
||||
export type Method = z.infer<typeof Method>
|
||||
|
||||
export const Authorization = z
|
||||
.object({
|
||||
url: z.string(),
|
||||
method: z.union([z.literal("auto"), z.literal("code")]),
|
||||
instructions: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "ProviderAuthAuthorization",
|
||||
})
|
||||
export type Authorization = z.infer<typeof Authorization>
|
||||
|
||||
export const OauthMissing = NamedError.create(
|
||||
"ProviderAuthOauthMissing",
|
||||
z.object({
|
||||
providerID: ProviderID.zod,
|
||||
}),
|
||||
)
|
||||
|
||||
export const OauthCodeMissing = NamedError.create(
|
||||
"ProviderAuthOauthCodeMissing",
|
||||
z.object({
|
||||
providerID: ProviderID.zod,
|
||||
}),
|
||||
)
|
||||
|
||||
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
|
||||
|
||||
export type ProviderAuthError =
|
||||
| Auth.AuthServiceError
|
||||
| InstanceType<typeof OauthMissing>
|
||||
| InstanceType<typeof OauthCodeMissing>
|
||||
| InstanceType<typeof OauthCallbackFailed>
|
||||
|
||||
export namespace ProviderAuthService {
|
||||
export interface Service {
|
||||
/** Get available auth methods for each provider (e.g. OAuth, API key). */
|
||||
readonly methods: () => Effect.Effect<Record<string, Method[]>>
|
||||
|
||||
/** Start an OAuth authorization flow for a provider. Returns the URL to redirect to. */
|
||||
readonly authorize: (input: { providerID: ProviderID; method: number }) => Effect.Effect<Authorization | undefined>
|
||||
|
||||
/** Complete an OAuth flow after the user has authorized. Exchanges the code/callback for credentials. */
|
||||
readonly callback: (input: {
|
||||
providerID: ProviderID
|
||||
method: number
|
||||
code?: string
|
||||
}) => Effect.Effect<void, ProviderAuthError>
|
||||
|
||||
/** Set an API key directly for a provider (no OAuth flow). */
|
||||
readonly api: (input: { providerID: ProviderID; key: string }) => Effect.Effect<void, Auth.AuthServiceError>
|
||||
}
|
||||
}
|
||||
|
||||
export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService, ProviderAuthService.Service>()(
|
||||
"@opencode/ProviderAuth",
|
||||
) {
|
||||
static readonly layer = Layer.effect(
|
||||
ProviderAuthService,
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.AuthService
|
||||
const state = yield* InstanceState.make({
|
||||
lookup: () =>
|
||||
Effect.promise(async () => {
|
||||
const methods = pipe(
|
||||
await Plugin.list(),
|
||||
filter((x) => x.auth?.provider !== undefined),
|
||||
map((x) => [x.auth!.provider, x.auth!] as const),
|
||||
fromEntries(),
|
||||
)
|
||||
return { methods, pending: new Map<ProviderID, AuthOuathResult>() }
|
||||
}),
|
||||
})
|
||||
|
||||
const methods = Effect.fn("ProviderAuthService.methods")(function* () {
|
||||
const x = yield* InstanceState.get(state)
|
||||
return Record.map(x.methods, (y) => y.methods.map((z): Method => Struct.pick(z, ["type", "label"])))
|
||||
})
|
||||
|
||||
const authorize = Effect.fn("ProviderAuthService.authorize")(function* (input: {
|
||||
providerID: ProviderID
|
||||
method: number
|
||||
}) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const method = s.methods[input.providerID].methods[input.method]
|
||||
if (method.type !== "oauth") return
|
||||
const result = yield* Effect.promise(() => method.authorize())
|
||||
s.pending.set(input.providerID, result)
|
||||
return {
|
||||
url: result.url,
|
||||
method: result.method,
|
||||
instructions: result.instructions,
|
||||
}
|
||||
})
|
||||
|
||||
const callback = Effect.fn("ProviderAuthService.callback")(function* (input: {
|
||||
providerID: ProviderID
|
||||
method: number
|
||||
code?: string
|
||||
}) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const match = s.pending.get(input.providerID)
|
||||
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
|
||||
|
||||
if (match.method === "code" && !input.code)
|
||||
return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
|
||||
|
||||
const result = yield* Effect.promise(() =>
|
||||
match.method === "code" ? match.callback(input.code!) : match.callback(),
|
||||
)
|
||||
|
||||
if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({}))
|
||||
|
||||
if ("key" in result) {
|
||||
yield* auth.set(input.providerID, {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
})
|
||||
}
|
||||
|
||||
if ("refresh" in result) {
|
||||
yield* auth.set(input.providerID, {
|
||||
type: "oauth",
|
||||
access: result.access,
|
||||
refresh: result.refresh,
|
||||
expires: result.expires,
|
||||
...(result.accountId ? { accountId: result.accountId } : {}),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const api = Effect.fn("ProviderAuthService.api")(function* (input: { providerID: ProviderID; key: string }) {
|
||||
yield* auth.set(input.providerID, {
|
||||
type: "api",
|
||||
key: input.key,
|
||||
})
|
||||
})
|
||||
|
||||
return ProviderAuthService.of({
|
||||
methods,
|
||||
authorize,
|
||||
callback,
|
||||
api,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
static readonly defaultLayer = ProviderAuthService.layer.pipe(Layer.provide(Auth.AuthService.defaultLayer))
|
||||
}
|
||||
@@ -1,75 +1,36 @@
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Plugin } from "../plugin"
|
||||
import { map, filter, pipe, fromEntries, mapValues } from "remeda"
|
||||
import { Effect, ManagedRuntime } from "effect"
|
||||
import z from "zod"
|
||||
|
||||
import { fn } from "@/util/fn"
|
||||
import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { Auth } from "@/auth"
|
||||
import * as S from "./auth-service"
|
||||
import { ProviderID } from "./schema"
|
||||
|
||||
export namespace ProviderAuth {
|
||||
const state = Instance.state(async () => {
|
||||
const methods = pipe(
|
||||
await Plugin.list(),
|
||||
filter((x) => x.auth?.provider !== undefined),
|
||||
map((x) => [x.auth!.provider, x.auth!] as const),
|
||||
fromEntries(),
|
||||
)
|
||||
return { methods, pending: {} as Record<string, AuthOuathResult> }
|
||||
})
|
||||
// Separate runtime: ProviderAuthService can't join the shared runtime because
|
||||
// runtime.ts → auth-service.ts → provider/auth.ts creates a circular import.
|
||||
// AuthService is stateless file I/O so the duplicate instance is harmless.
|
||||
const rt = ManagedRuntime.make(S.ProviderAuthService.defaultLayer)
|
||||
|
||||
export const Method = z
|
||||
.object({
|
||||
type: z.union([z.literal("oauth"), z.literal("api")]),
|
||||
label: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "ProviderAuthMethod",
|
||||
})
|
||||
export type Method = z.infer<typeof Method>
|
||||
function runPromise<A>(f: (service: S.ProviderAuthService.Service) => Effect.Effect<A, S.ProviderAuthError>) {
|
||||
return rt.runPromise(S.ProviderAuthService.use(f))
|
||||
}
|
||||
|
||||
export namespace ProviderAuth {
|
||||
export const Method = S.Method
|
||||
export type Method = S.Method
|
||||
|
||||
export async function methods() {
|
||||
const s = await state().then((x) => x.methods)
|
||||
return mapValues(s, (x) =>
|
||||
x.methods.map(
|
||||
(y): Method => ({
|
||||
type: y.type,
|
||||
label: y.label,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return runPromise((service) => service.methods())
|
||||
}
|
||||
|
||||
export const Authorization = z
|
||||
.object({
|
||||
url: z.string(),
|
||||
method: z.union([z.literal("auto"), z.literal("code")]),
|
||||
instructions: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "ProviderAuthAuthorization",
|
||||
})
|
||||
export type Authorization = z.infer<typeof Authorization>
|
||||
export const Authorization = S.Authorization
|
||||
export type Authorization = S.Authorization
|
||||
|
||||
export const authorize = fn(
|
||||
z.object({
|
||||
providerID: ProviderID.zod,
|
||||
method: z.number(),
|
||||
}),
|
||||
async (input): Promise<Authorization | undefined> => {
|
||||
const auth = await state().then((s) => s.methods[input.providerID])
|
||||
const method = auth.methods[input.method]
|
||||
if (method.type === "oauth") {
|
||||
const result = await method.authorize()
|
||||
await state().then((s) => (s.pending[input.providerID] = result))
|
||||
return {
|
||||
url: result.url,
|
||||
method: result.method,
|
||||
instructions: result.instructions,
|
||||
}
|
||||
}
|
||||
},
|
||||
async (input): Promise<Authorization | undefined> => runPromise((service) => service.authorize(input)),
|
||||
)
|
||||
|
||||
export const callback = fn(
|
||||
@@ -78,44 +39,7 @@ export namespace ProviderAuth {
|
||||
method: z.number(),
|
||||
code: z.string().optional(),
|
||||
}),
|
||||
async (input) => {
|
||||
const match = await state().then((s) => s.pending[input.providerID])
|
||||
if (!match) throw new OauthMissing({ providerID: input.providerID })
|
||||
let result
|
||||
|
||||
if (match.method === "code") {
|
||||
if (!input.code) throw new OauthCodeMissing({ providerID: input.providerID })
|
||||
result = await match.callback(input.code)
|
||||
}
|
||||
|
||||
if (match.method === "auto") {
|
||||
result = await match.callback()
|
||||
}
|
||||
|
||||
if (result?.type === "success") {
|
||||
if ("key" in result) {
|
||||
await Auth.set(input.providerID, {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
})
|
||||
}
|
||||
if ("refresh" in result) {
|
||||
const info: Auth.Info = {
|
||||
type: "oauth",
|
||||
access: result.access,
|
||||
refresh: result.refresh,
|
||||
expires: result.expires,
|
||||
}
|
||||
if (result.accountId) {
|
||||
info.accountId = result.accountId
|
||||
}
|
||||
await Auth.set(input.providerID, info)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
throw new OauthCallbackFailed({})
|
||||
},
|
||||
async (input) => runPromise((service) => service.callback(input)),
|
||||
)
|
||||
|
||||
export const api = fn(
|
||||
@@ -123,26 +47,10 @@ export namespace ProviderAuth {
|
||||
providerID: ProviderID.zod,
|
||||
key: z.string(),
|
||||
}),
|
||||
async (input) => {
|
||||
await Auth.set(input.providerID, {
|
||||
type: "api",
|
||||
key: input.key,
|
||||
})
|
||||
},
|
||||
async (input) => runPromise((service) => service.api(input)),
|
||||
)
|
||||
|
||||
export const OauthMissing = NamedError.create(
|
||||
"ProviderAuthOauthMissing",
|
||||
z.object({
|
||||
providerID: ProviderID.zod,
|
||||
}),
|
||||
)
|
||||
export const OauthCodeMissing = NamedError.create(
|
||||
"ProviderAuthOauthCodeMissing",
|
||||
z.object({
|
||||
providerID: ProviderID.zod,
|
||||
}),
|
||||
)
|
||||
|
||||
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
|
||||
export import OauthMissing = S.OauthMissing
|
||||
export import OauthCodeMissing = S.OauthCodeMissing
|
||||
export import OauthCallbackFailed = S.OauthCallbackFailed
|
||||
}
|
||||
|
||||
@@ -48,8 +48,8 @@ export namespace SessionProcessor {
|
||||
needsCompaction = false
|
||||
const shouldBreak = (await Config.get()).experimental?.continue_loop_on_deny !== true
|
||||
while (true) {
|
||||
let currentText: MessageV2.TextPart | undefined
|
||||
try {
|
||||
let currentText: MessageV2.TextPart | undefined
|
||||
let reasoningMap: Record<string, MessageV2.ReasoningPart> = {}
|
||||
const stream = await LLM.stream(streamInput)
|
||||
|
||||
@@ -385,6 +385,15 @@ export namespace SessionProcessor {
|
||||
SessionStatus.set(input.sessionID, { type: "idle" })
|
||||
}
|
||||
}
|
||||
if (currentText) {
|
||||
currentText.text = currentText.text.trimEnd()
|
||||
currentText.time = {
|
||||
start: currentText.time?.start ?? Date.now(),
|
||||
end: Date.now(),
|
||||
}
|
||||
await Session.updatePart(currentText)
|
||||
currentText = undefined
|
||||
}
|
||||
if (snapshot) {
|
||||
const patch = await Snapshot.patch(snapshot)
|
||||
if (patch.files.length) {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Effect, ScopedCache, Scope } from "effect"
|
||||
|
||||
import { Instance } from "@/project/instance"
|
||||
|
||||
const TypeId = Symbol.for("@opencode/InstanceState")
|
||||
|
||||
type Task = (key: string) => Effect.Effect<void>
|
||||
|
||||
const tasks = new Set<Task>()
|
||||
|
||||
export namespace InstanceState {
|
||||
export interface State<A, E = never, R = never> {
|
||||
readonly [TypeId]: typeof TypeId
|
||||
readonly cache: ScopedCache.ScopedCache<string, A, E, R>
|
||||
}
|
||||
|
||||
export const make = <A, E = never, R = never>(input: {
|
||||
lookup: (key: string) => Effect.Effect<A, E, R>
|
||||
release?: (value: A, key: string) => Effect.Effect<void>
|
||||
}): Effect.Effect<State<A, E, R>, never, R | Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* ScopedCache.make<string, A, E, R>({
|
||||
capacity: Number.POSITIVE_INFINITY,
|
||||
lookup: (key) =>
|
||||
Effect.acquireRelease(input.lookup(key), (value) => (input.release ? input.release(value, key) : Effect.void)),
|
||||
})
|
||||
|
||||
const task: Task = (key) => ScopedCache.invalidate(cache, key)
|
||||
tasks.add(task)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => void tasks.delete(task)))
|
||||
|
||||
return {
|
||||
[TypeId]: TypeId,
|
||||
cache,
|
||||
}
|
||||
})
|
||||
|
||||
export const get = <A, E, R>(self: State<A, E, R>) => ScopedCache.get(self.cache, Instance.directory)
|
||||
|
||||
export const has = <A, E, R>(self: State<A, E, R>) => ScopedCache.has(self.cache, Instance.directory)
|
||||
|
||||
export const invalidate = <A, E, R>(self: State<A, E, R>) =>
|
||||
ScopedCache.invalidate(self.cache, Instance.directory)
|
||||
|
||||
export const dispose = (key: string) =>
|
||||
Effect.all(
|
||||
[...tasks].map((task) => task(key)),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
test("Instance.state caches values for the same instance", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
let n = 0
|
||||
const state = Instance.state(() => ({ n: ++n }))
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const a = state()
|
||||
const b = state()
|
||||
expect(a).toBe(b)
|
||||
expect(n).toBe(1)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("Instance.state isolates values by directory", async () => {
|
||||
await using a = await tmpdir()
|
||||
await using b = await tmpdir()
|
||||
let n = 0
|
||||
const state = Instance.state(() => ({ n: ++n }))
|
||||
|
||||
const x = await Instance.provide({
|
||||
directory: a.path,
|
||||
fn: async () => state(),
|
||||
})
|
||||
const y = await Instance.provide({
|
||||
directory: b.path,
|
||||
fn: async () => state(),
|
||||
})
|
||||
const z = await Instance.provide({
|
||||
directory: a.path,
|
||||
fn: async () => state(),
|
||||
})
|
||||
|
||||
expect(x).toBe(z)
|
||||
expect(x).not.toBe(y)
|
||||
expect(n).toBe(2)
|
||||
})
|
||||
|
||||
test("Instance.state is disposed on instance reload", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const seen: string[] = []
|
||||
let n = 0
|
||||
const state = Instance.state(
|
||||
() => ({ n: ++n }),
|
||||
async (value) => {
|
||||
seen.push(String(value.n))
|
||||
},
|
||||
)
|
||||
|
||||
const a = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => state(),
|
||||
})
|
||||
await Instance.reload({ directory: tmp.path })
|
||||
const b = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => state(),
|
||||
})
|
||||
|
||||
expect(a).not.toBe(b)
|
||||
expect(seen).toEqual(["1"])
|
||||
})
|
||||
|
||||
test("Instance.state is disposed on disposeAll", async () => {
|
||||
await using a = await tmpdir()
|
||||
await using b = await tmpdir()
|
||||
const seen: string[] = []
|
||||
const state = Instance.state(
|
||||
() => ({ dir: Instance.directory }),
|
||||
async (value) => {
|
||||
seen.push(value.dir)
|
||||
},
|
||||
)
|
||||
|
||||
await Instance.provide({
|
||||
directory: a.path,
|
||||
fn: async () => state(),
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: b.path,
|
||||
fn: async () => state(),
|
||||
})
|
||||
await Instance.disposeAll()
|
||||
|
||||
expect(seen.sort()).toEqual([a.path, b.path].sort())
|
||||
})
|
||||
|
||||
test("Instance.state dedupes concurrent promise initialization", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
let n = 0
|
||||
const state = Instance.state(async () => {
|
||||
n += 1
|
||||
await Bun.sleep(10)
|
||||
return { n }
|
||||
})
|
||||
|
||||
const [a, b] = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => Promise.all([state(), state()]),
|
||||
})
|
||||
|
||||
expect(a).toBe(b)
|
||||
expect(n).toBe(1)
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { ProviderAuth } from "../../src/provider/auth"
|
||||
import { ProviderID } from "../../src/provider/schema"
|
||||
|
||||
afterEach(async () => {
|
||||
await Auth.remove("test-provider-auth")
|
||||
})
|
||||
|
||||
test("ProviderAuth.api persists auth via AuthService", async () => {
|
||||
await ProviderAuth.api({
|
||||
providerID: ProviderID.make("test-provider-auth"),
|
||||
key: "sk-test",
|
||||
})
|
||||
|
||||
expect(await Auth.get("test-provider-auth")).toEqual({
|
||||
type: "api",
|
||||
key: "sk-test",
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, test, spyOn } from "bun:test"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import type { Provider } from "../../src/provider/provider"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { Session } from "../../src/session"
|
||||
import { LLM } from "../../src/session/llm"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { SessionProcessor } from "../../src/session/processor"
|
||||
import { MessageID } from "../../src/session/schema"
|
||||
import { Log } from "../../src/util/log"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
const model: Provider.Model = {
|
||||
id: ModelID.make("gpt-5.4"),
|
||||
providerID: ProviderID.make("openai"),
|
||||
api: {
|
||||
id: "openai",
|
||||
url: "https://api.openai.com/v1",
|
||||
npm: "@ai-sdk/openai",
|
||||
},
|
||||
name: "GPT-5.4",
|
||||
capabilities: {
|
||||
temperature: true,
|
||||
reasoning: true,
|
||||
attachment: false,
|
||||
toolcall: false,
|
||||
input: {
|
||||
text: true,
|
||||
audio: false,
|
||||
image: false,
|
||||
video: false,
|
||||
pdf: false,
|
||||
},
|
||||
output: {
|
||||
text: true,
|
||||
audio: false,
|
||||
image: false,
|
||||
video: false,
|
||||
pdf: false,
|
||||
},
|
||||
interleaved: false,
|
||||
},
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
limit: {
|
||||
context: 128_000,
|
||||
output: 8_000,
|
||||
},
|
||||
status: "active",
|
||||
options: {},
|
||||
headers: {},
|
||||
release_date: "2026-01-01",
|
||||
}
|
||||
|
||||
describe("interrupted streamed text reproducers", () => {
|
||||
test("persists streamed assistant text on abort so reconnect sees the same partial reply", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({ title: "manual" })
|
||||
const user = await Session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID: session.id,
|
||||
agent: "build",
|
||||
model: {
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
},
|
||||
time: {
|
||||
created: Date.now(),
|
||||
},
|
||||
})
|
||||
const assistant: MessageV2.Assistant = {
|
||||
id: MessageID.ascending(),
|
||||
role: "assistant",
|
||||
sessionID: session.id,
|
||||
parentID: user.id,
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: {
|
||||
cwd: tmp.path,
|
||||
root: tmp.path,
|
||||
},
|
||||
cost: 0,
|
||||
tokens: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
time: {
|
||||
created: Date.now(),
|
||||
},
|
||||
}
|
||||
await Session.updateMessage(assistant)
|
||||
|
||||
const seen = Promise.withResolvers<void>()
|
||||
let live = ""
|
||||
const off = Bus.subscribe(MessageV2.Event.PartDelta, (evt) => {
|
||||
if (evt.properties.messageID !== assistant.id) return
|
||||
if (evt.properties.field !== "text") return
|
||||
live += evt.properties.delta
|
||||
seen.resolve()
|
||||
})
|
||||
|
||||
const mock = spyOn(LLM, "stream").mockImplementation(async (input) => {
|
||||
return {
|
||||
fullStream: (async function* () {
|
||||
yield { type: "start" }
|
||||
yield { type: "text-start" }
|
||||
yield { type: "text-delta", text: "Here is a long one:" }
|
||||
await new Promise((resolve) => input.abort.addEventListener("abort", resolve, { once: true }))
|
||||
throw new DOMException("Aborted", "AbortError")
|
||||
})(),
|
||||
} as never
|
||||
})
|
||||
|
||||
const abort = new AbortController()
|
||||
const proc = SessionProcessor.create({
|
||||
assistantMessage: assistant,
|
||||
sessionID: session.id,
|
||||
model,
|
||||
abort: abort.signal,
|
||||
})
|
||||
const run = proc.process({} as never)
|
||||
|
||||
await seen.promise
|
||||
abort.abort()
|
||||
|
||||
expect(await run).toBe("stop")
|
||||
|
||||
const msg = await MessageV2.get({
|
||||
sessionID: session.id,
|
||||
messageID: assistant.id,
|
||||
})
|
||||
const text = msg.parts.find((part) => part.type === "text")
|
||||
|
||||
expect(live).toBe("Here is a long one:")
|
||||
expect(text?.type).toBe("text")
|
||||
expect(text && text.type === "text" ? text.text : undefined).toBe(live)
|
||||
expect(msg.info.role === "assistant" ? msg.info.time.completed : undefined).toBeDefined()
|
||||
|
||||
off()
|
||||
mock.mockRestore()
|
||||
await Session.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { InstanceState } from "../../src/util/instance-state"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
async function access<A, E>(state: InstanceState.State<A, E>, dir: string) {
|
||||
return Instance.provide({
|
||||
directory: dir,
|
||||
fn: () => Effect.runPromise(InstanceState.get(state)),
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
test("InstanceState caches values for the same instance", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
let n = 0
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make({
|
||||
lookup: () => Effect.sync(() => ({ n: ++n })),
|
||||
})
|
||||
|
||||
const a = yield* Effect.promise(() => access(state, tmp.path))
|
||||
const b = yield* Effect.promise(() => access(state, tmp.path))
|
||||
|
||||
expect(a).toBe(b)
|
||||
expect(n).toBe(1)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("InstanceState isolates values by directory", async () => {
|
||||
await using a = await tmpdir()
|
||||
await using b = await tmpdir()
|
||||
let n = 0
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make({
|
||||
lookup: (dir) => Effect.sync(() => ({ dir, n: ++n })),
|
||||
})
|
||||
|
||||
const x = yield* Effect.promise(() => access(state, a.path))
|
||||
const y = yield* Effect.promise(() => access(state, b.path))
|
||||
const z = yield* Effect.promise(() => access(state, a.path))
|
||||
|
||||
expect(x).toBe(z)
|
||||
expect(x).not.toBe(y)
|
||||
expect(n).toBe(2)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("InstanceState is disposed on instance reload", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const seen: string[] = []
|
||||
let n = 0
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make({
|
||||
lookup: () => Effect.sync(() => ({ n: ++n })),
|
||||
release: (value) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(String(value.n))
|
||||
}),
|
||||
})
|
||||
|
||||
const a = yield* Effect.promise(() => access(state, tmp.path))
|
||||
yield* Effect.promise(() => Instance.reload({ directory: tmp.path }))
|
||||
const b = yield* Effect.promise(() => access(state, tmp.path))
|
||||
|
||||
expect(a).not.toBe(b)
|
||||
expect(seen).toEqual(["1"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("InstanceState is disposed on disposeAll", async () => {
|
||||
await using a = await tmpdir()
|
||||
await using b = await tmpdir()
|
||||
const seen: string[] = []
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make({
|
||||
lookup: (dir) => Effect.sync(() => ({ dir })),
|
||||
release: (value) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(value.dir)
|
||||
}),
|
||||
})
|
||||
|
||||
yield* Effect.promise(() => access(state, a.path))
|
||||
yield* Effect.promise(() => access(state, b.path))
|
||||
yield* Effect.promise(() => Instance.disposeAll())
|
||||
|
||||
expect(seen.sort()).toEqual([a.path, b.path].sort())
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("InstanceState dedupes concurrent lookups for the same directory", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
let n = 0
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make({
|
||||
lookup: () =>
|
||||
Effect.promise(async () => {
|
||||
n += 1
|
||||
await Bun.sleep(10)
|
||||
return { n }
|
||||
}),
|
||||
})
|
||||
|
||||
const [a, b] = yield* Effect.promise(() => Promise.all([access(state, tmp.path), access(state, tmp.path)]))
|
||||
expect(a).toBe(b)
|
||||
expect(n).toBe(1)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -23,10 +23,6 @@
|
||||
max-width: 100%;
|
||||
gap: 0;
|
||||
|
||||
&[data-interrupted] {
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
[data-slot="user-message-attachments"] {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -165,10 +161,6 @@
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
[data-slot="user-message-copy-wrapper"][data-interrupted] {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&:hover [data-slot="user-message-copy-wrapper"],
|
||||
&:focus-within [data-slot="user-message-copy-wrapper"] {
|
||||
opacity: 1;
|
||||
|
||||
@@ -131,7 +131,6 @@ export interface MessageProps {
|
||||
parts: PartType[]
|
||||
actions?: UserActions
|
||||
showAssistantCopyPartID?: string | null
|
||||
interrupted?: boolean
|
||||
showReasoningSummaries?: boolean
|
||||
}
|
||||
|
||||
@@ -691,12 +690,7 @@ export function Message(props: MessageProps) {
|
||||
<Switch>
|
||||
<Match when={props.message.role === "user" && props.message}>
|
||||
{(userMessage) => (
|
||||
<UserMessageDisplay
|
||||
message={userMessage() as UserMessage}
|
||||
parts={props.parts}
|
||||
actions={props.actions}
|
||||
interrupted={props.interrupted}
|
||||
/>
|
||||
<UserMessageDisplay message={userMessage() as UserMessage} parts={props.parts} actions={props.actions} />
|
||||
)}
|
||||
</Match>
|
||||
<Match when={props.message.role === "assistant" && props.message}>
|
||||
@@ -887,12 +881,7 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function UserMessageDisplay(props: {
|
||||
message: UserMessage
|
||||
parts: PartType[]
|
||||
actions?: UserActions
|
||||
interrupted?: boolean
|
||||
}) {
|
||||
export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[]; actions?: UserActions }) {
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
const i18n = useI18n()
|
||||
@@ -947,10 +936,7 @@ export function UserMessageDisplay(props: {
|
||||
return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0")
|
||||
})
|
||||
|
||||
const metaTail = createMemo(() => {
|
||||
const items = [stamp(), props.interrupted ? i18n.t("ui.message.interrupted") : ""]
|
||||
return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0")
|
||||
})
|
||||
const metaTail = stamp
|
||||
|
||||
const openImagePreview = (url: string, alt?: string) => {
|
||||
dialog.show(() => <ImagePreview src={url} alt={alt} />)
|
||||
@@ -981,7 +967,7 @@ export function UserMessageDisplay(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-component="user-message" data-interrupted={props.interrupted ? "" : undefined}>
|
||||
<div data-component="user-message">
|
||||
<Show when={attachments().length > 0}>
|
||||
<div data-slot="user-message-attachments">
|
||||
<For each={attachments()}>
|
||||
@@ -1021,7 +1007,7 @@ export function UserMessageDisplay(props: {
|
||||
<HighlightedText text={text()} references={inlineFiles()} agents={agents()} />
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="user-message-copy-wrapper" data-interrupted={props.interrupted ? "" : undefined}>
|
||||
<div data-slot="user-message-copy-wrapper">
|
||||
<Show when={metaHead() || metaTail()}>
|
||||
<span data-slot="user-message-meta-wrap">
|
||||
<Show when={metaHead()}>
|
||||
@@ -1305,14 +1291,13 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||
)
|
||||
}
|
||||
|
||||
PART_MAPPING["compaction"] = function CompactionPartDisplay() {
|
||||
const i18n = useI18n()
|
||||
export function MessageDivider(props: { label: string }) {
|
||||
return (
|
||||
<div data-component="compaction-part">
|
||||
<div data-slot="compaction-part-divider">
|
||||
<span data-slot="compaction-part-line" />
|
||||
<span data-slot="compaction-part-label" class="text-12-regular text-text-weak">
|
||||
{i18n.t("ui.messagePart.compaction")}
|
||||
{props.label}
|
||||
</span>
|
||||
<span data-slot="compaction-part-line" />
|
||||
</div>
|
||||
@@ -1320,6 +1305,11 @@ PART_MAPPING["compaction"] = function CompactionPartDisplay() {
|
||||
)
|
||||
}
|
||||
|
||||
PART_MAPPING["compaction"] = function CompactionPartDisplay() {
|
||||
const i18n = useI18n()
|
||||
return <MessageDivider label={i18n.t("ui.messagePart.compaction")} />
|
||||
}
|
||||
|
||||
PART_MAPPING["text"] = function TextPartDisplay(props) {
|
||||
const data = useData()
|
||||
const i18n = useI18n()
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Binary } from "@opencode-ai/util/binary"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { AssistantParts, Message, Part, PART_MAPPING, type UserActions } from "./message-part"
|
||||
import { AssistantParts, Message, MessageDivider, PART_MAPPING, type UserActions } from "./message-part"
|
||||
import { Card } from "./card"
|
||||
import { Accordion } from "./accordion"
|
||||
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
||||
@@ -276,6 +276,11 @@ export function SessionTurn(
|
||||
)
|
||||
|
||||
const interrupted = createMemo(() => assistantMessages().some((m) => m.error?.name === "MessageAbortedError"))
|
||||
const divider = createMemo(() => {
|
||||
if (compaction()) return i18n.t("ui.messagePart.compaction")
|
||||
if (interrupted()) return i18n.t("ui.message.interrupted")
|
||||
return ""
|
||||
})
|
||||
const error = createMemo(
|
||||
() => assistantMessages().find((m) => m.error && m.error.name !== "MessageAbortedError")?.error,
|
||||
)
|
||||
@@ -384,11 +389,11 @@ export function SessionTurn(
|
||||
class={props.classes?.container}
|
||||
>
|
||||
<div data-slot="session-turn-message-content" aria-live="off">
|
||||
<Message message={message()!} parts={parts()} actions={props.actions} interrupted={interrupted()} />
|
||||
<Message message={message()!} parts={parts()} actions={props.actions} />
|
||||
</div>
|
||||
<Show when={compaction()}>
|
||||
<Show when={divider()}>
|
||||
<div data-slot="session-turn-compaction">
|
||||
<Part part={compaction()!} message={message()!} hideDetails />
|
||||
<MessageDivider label={divider()} />
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={assistantMessages().length > 0}>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { onMount, onCleanup, createEffect } from "solid-js"
|
||||
import { createEffect, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { DesktopTheme } from "./types"
|
||||
import { resolveThemeVariant, themeToCss } from "./resolve"
|
||||
import { DEFAULT_THEMES } from "./default-themes"
|
||||
import { createSimpleContext } from "../context/helper"
|
||||
import { DEFAULT_THEMES } from "./default-themes"
|
||||
import { resolveThemeVariant, themeToCss } from "./resolve"
|
||||
import type { DesktopTheme } from "./types"
|
||||
|
||||
export type ColorScheme = "light" | "dark" | "system"
|
||||
|
||||
@@ -87,6 +87,14 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
previewScheme: null as ColorScheme | null,
|
||||
})
|
||||
|
||||
window.addEventListener("storage", (e) => {
|
||||
if (e.key === STORAGE_KEYS.THEME_ID && e.newValue) setStore("themeId", e.newValue)
|
||||
if (e.key === STORAGE_KEYS.COLOR_SCHEME && e.newValue) {
|
||||
setStore("colorScheme", e.newValue as ColorScheme)
|
||||
setStore("mode", e.newValue === "system" ? getSystemMode() : (e.newValue as any))
|
||||
}
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
const handler = () => {
|
||||
|
||||
Reference in New Issue
Block a user