Compare commits

..

11 Commits

Author SHA1 Message Date
Kit Langton ce8e9b1d84 fix(session): persist interrupted streamed text 2026-03-13 23:36:31 -04:00
Kit Langton 2d6d788db5 test(session): add interrupted text persistence repro 2026-03-13 23:23:54 -04:00
Kit Langton dd68b85f58 refactor(provider): effectify ProviderAuthService (#17227) 2026-03-13 02:08:37 +00:00
Brendan Allan 84df96eaef desktop: multi-window support in electron (#17155) 2026-03-13 09:18:27 +08:00
Kit Langton d9dd33aeeb feat(cli): add console account subcommands (#17265) 2026-03-13 00:56:40 +00:00
Kit Langton 0a281c7390 refactor(auth): effectify AuthService (#17212) 2026-03-12 20:43:24 -04:00
Aiden Cline 3016efba47 tweak: rm openrouter warning (#17259) 2026-03-12 19:42:31 -05:00
Luke Parker 3998df8112 fix(app): increase CI e2e workers (#17263) 2026-03-13 10:15:34 +10:00
Kit Langton 7066e2a25e reorder provider list in providers login (#17262) 2026-03-13 00:09:30 +00:00
Adam c173988aaa feat(app): interruption state 2026-03-12 19:07:23 -05:00
Luke Parker 268855dc5a fix(ci): keep test runs on dev (#17260) 2026-03-13 09:54:34 +10:00
32 changed files with 953 additions and 242 deletions
+3 -1
View File
@@ -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:
+2
View File
@@ -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
View File
@@ -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
+5 -2
View File
@@ -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>
),
+4 -5
View File
@@ -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" },
],
+12 -2
View File
@@ -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>
+3 -4
View File
@@ -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
+12 -28
View File
@@ -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))
}
}
+101
View File
@@ -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
}
+25
View File
@@ -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() {},
})
+3 -3
View File
@@ -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,
}
-14
View File
@@ -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)
})
+3 -2
View File
@@ -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))
+2 -5
View File
@@ -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)
+4 -2
View File
@@ -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))
}
+23 -115
View File
@@ -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
}
+10 -1
View File
@@ -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;
+12 -22
View File
@@ -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()
+9 -4
View File
@@ -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}>
+12 -4
View File
@@ -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 = () => {