Merge remote-tracking branch 'upstream/dev' into desktop-wsl-onboarding

This commit is contained in:
LukeParkerDev
2026-04-27 16:15:37 +10:00
655 changed files with 37554 additions and 9667 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { showToast } from "@opencode-ai/ui/toast"
import { useParams } from "@solidjs/router"
import { getFilename } from "@opencode-ai/shared/util/path"
import { getFilename } from "@opencode-ai/core/util/path"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
import { useLanguage } from "@/context/language"
+30 -51
View File
@@ -8,11 +8,10 @@ import type {
Todo,
} from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/shared/util/path"
import { createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
import { getFilename } from "@opencode-ai/core/util/path"
import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { Persist, persisted } from "@/utils/persist"
import type { InitError } from "../pages/error"
import { useGlobalSDK } from "./global-sdk"
import { useServer } from "./server"
@@ -25,7 +24,6 @@ import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global
import { trimSessions } from "./global-sync/session-trim"
import type { ProjectMeta } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
import { sanitizeProject } from "./global-sync/utils"
import { formatServerError } from "@/utils/server-errors"
import { queryOptions, skipToken, useQueryClient } from "@tanstack/solid-query"
@@ -58,15 +56,10 @@ function createGlobalSync() {
const sessionLoads = new Map<string, Promise<void>>()
const sessionMeta = new Map<string, { limit: number }>()
const [projectCache, setProjectCache, projectInit] = persisted(
Persist.global("globalSync.project", ["globalSync.project.v1"]),
createStore({ value: [] as Project[] }),
)
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
ready: false,
path: { state: "", config: "", worktree: "", directory: "", home: "" },
project: projectCache.value,
project: [],
session_todo: {},
provider: { all: [], connected: [], default: {} },
provider_auth: {},
@@ -75,37 +68,18 @@ function createGlobalSync() {
})
const queryClient = useQueryClient()
let active = true
let projectWritten = false
let bootedAt = 0
let bootingRoot = false
let eventFrame: number | undefined
let eventTimer: ReturnType<typeof setTimeout> | undefined
onCleanup(() => {
active = false
})
onCleanup(() => {
if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
if (eventTimer !== undefined) clearTimeout(eventTimer)
})
const cacheProjects = () => {
setProjectCache(
"value",
untrack(() => globalStore.project.map(sanitizeProject)),
)
}
const setProjects = (next: Project[] | ((draft: Project[]) => void)) => {
projectWritten = true
if (typeof next === "function") {
setGlobalStore("project", produce(next))
cacheProjects()
return
}
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
setGlobalStore("project", next)
cacheProjects()
}
const setBootStore = ((...input: unknown[]) => {
@@ -118,22 +92,12 @@ function createGlobalSync() {
const set = ((...input: unknown[]) => {
if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) {
setProjects(input[1] as Project[] | ((draft: Project[]) => void))
setProjects(input[1] as Project[] | ((draft: Project[]) => Project[]))
return input[1]
}
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
}) as typeof setGlobalStore
if (projectInit instanceof Promise) {
void projectInit.then(() => {
if (!active) return
if (projectWritten) return
const cached = projectCache.value
if (cached.length === 0) return
setGlobalStore("project", cached)
})
}
const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
if (!sessionID) return
if (!todos) {
@@ -225,16 +189,18 @@ function createGlobalSync() {
limit,
permission: store.permission,
})
setStore(
"sessionTotal",
estimateRootSessionTotal({
count: nonArchived.length,
limit: x.limit,
limited: x.limited,
}),
)
setStore("session", reconcile(sessions, { key: "id" }))
cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
batch(() => {
setStore(
"sessionTotal",
estimateRootSessionTotal({
count: nonArchived.length,
limit: x.limit,
limited: x.limited,
}),
)
setStore("session", reconcile(sessions, { key: "id" }))
cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
})
sessionMeta.set(directory, { limit })
})
.catch((err) => {
@@ -301,6 +267,19 @@ function createGlobalSync() {
const event = e.details
const recent = bootingRoot || Date.now() - bootedAt < 1500
if (event.type === "session.error") {
const error = event.properties.error
if (error?.name !== "MessageAbortedError") {
console.error("[global-sync] session error", {
scope: directory === "global" ? "global" : "workspace",
directory: directory === "global" ? undefined : directory,
project: directory === "global" ? undefined : getFilename(directory),
sessionID: event.properties.sessionID,
error,
})
}
}
if (directory === "global") {
applyGlobalEvent({
event,
@@ -11,15 +11,14 @@ import type {
Todo,
} from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/shared/util/path"
import { retry } from "@opencode-ai/shared/util/retry"
import { getFilename } from "@opencode-ai/core/util/path"
import { retry } from "@opencode-ai/core/util/retry"
import { batch } from "solid-js"
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { State, VcsCache } from "./types"
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions, skipToken } from "@tanstack/solid-query"
import { loadSessionsQuery } from "../global-sync"
type GlobalStore = {
ready: boolean
@@ -80,9 +79,12 @@ export async function bootstrapGlobal(input: {
() =>
retry(() =>
input.globalSDK.global.config.get().then((x) => {
input.setGlobalStore("config", x.data!)
input.setGlobalStore("config", reconcile(x.data!, { merge: false }))
}),
),
]
const slow = [
() =>
input.queryClient.fetchQuery({
...loadProvidersQuery(null, input.serverKey),
@@ -94,9 +96,6 @@ export async function bootstrapGlobal(input: {
}),
),
}),
]
const slow = [
() =>
retry(() =>
input.globalSDK.path.get().then((x) => {
@@ -184,8 +183,44 @@ function warmSessions(input: {
export const loadProvidersQuery = (directory: string | null, serverKey: string | undefined) =>
queryOptions<null>({ queryKey: [serverKey, directory, "providers"], queryFn: skipToken })
export const loadAgentsQuery = (directory: string | null, serverKey: string | undefined) =>
queryOptions<null>({ queryKey: [serverKey, directory, "agents"], queryFn: skipToken })
export const loadAgentsQuery = (
directory: string | null,
serverKey: string | undefined,
sdk?: OpencodeClient,
transform?: (x: Awaited<ReturnType<OpencodeClient["app"]["agents"]>>) => void,
) =>
queryOptions<null>({
queryKey: [serverKey, directory, "agents"],
queryFn:
sdk && transform
? () =>
retry(() =>
sdk.app
.agents()
.then(transform)
.then(() => null),
)
: skipToken,
})
export const loadPathQuery = (
directory: string | null,
sdk?: OpencodeClient,
transform?: (x: Awaited<ReturnType<OpencodeClient["path"]["get"]>>) => void,
) =>
queryOptions<Path>({
queryKey: [directory, "path"],
queryFn:
sdk && transform
? () =>
retry(() =>
sdk.path.get().then(async (x) => {
transform(x)
return x.data!
}),
)
: skipToken,
})
export async function bootstrapDirectory(input: {
directory: string
@@ -213,7 +248,7 @@ export async function bootstrapDirectory(input: {
input.setStore("provider", input.global.provider)
}
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
input.setStore("config", input.global.config)
input.setStore("config", reconcile(input.global.config, { merge: false }))
}
if (loading || input.store.provider.all.length === 0) {
input.setStore("provider_ready", false)
@@ -224,45 +259,30 @@ export async function bootstrapDirectory(input: {
input.setStore("lsp", [])
if (loading) input.setStore("status", "partial")
const fast = [() => Promise.resolve(input.loadSessions(input.directory))]
const errs = errors(await runAll(fast))
if (errs.length > 0) {
console.error("Failed to bootstrap instance", errs[0])
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(errs[0], input.translate),
})
}
const rev = (providerRev.get(input.directory) ?? 0) + 1
providerRev.set(input.directory, rev)
;(async () => {
const slow = [
() => Promise.resolve(input.loadSessions(input.directory)),
() =>
input.queryClient.ensureQueryData({
...loadAgentsQuery(input.directory, input.serverKey),
queryFn: () =>
retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))).then(
() => null,
),
}),
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
input.queryClient.ensureQueryData(
loadAgentsQuery(input.directory, input.serverKey, input.sdk, (x) =>
input.setStore("agent", normalizeAgentList(x.data)),
),
),
() =>
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
() =>
seededProject
? Promise.resolve()
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
() =>
seededPath
? Promise.resolve()
: retry(() =>
input.sdk.path.get().then((x) => {
input.setStore("path", x.data!)
const next = projectID(x.data?.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next)
}),
),
!seededProject &&
(() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
!seededPath &&
(() =>
input.queryClient.ensureQueryData(
loadPathQuery(input.directory, input.sdk, (x) => {
const next = projectID(x.data?.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next)
}),
)),
() =>
retry(() =>
input.sdk.vcs.get().then((x) => {
@@ -332,7 +352,28 @@ export async function bootstrapDirectory(input: {
input.setStore("mcp_ready", true)
}),
),
]
() =>
input.queryClient.ensureQueryData({
...loadProvidersQuery(input.directory, input.serverKey),
queryFn: () =>
retry(() => input.sdk.provider.list())
.then((x) => {
if (providerRev.get(input.directory) !== rev) return
input.setStore("provider", normalizeProviderList(x.data!))
input.setStore("provider_ready", true)
})
.catch((err) => {
if (providerRev.get(input.directory) !== rev) console.error("Failed to refresh provider list", err)
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(err, input.translate),
})
})
.then(() => null),
}),
].filter(Boolean) as (() => Promise<any>)[]
await waitForPaint()
const slowErrs = errors(await runAll(slow))
@@ -346,29 +387,6 @@ export async function bootstrapDirectory(input: {
})
}
if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete")
const rev = (providerRev.get(input.directory) ?? 0) + 1
providerRev.set(input.directory, rev)
void input.queryClient.ensureQueryData({
...loadProvidersQuery(input.directory, input.serverKey),
queryFn: () =>
retry(() => input.sdk.provider.list())
.then((x) => {
if (providerRev.get(input.directory) !== rev) return
input.setStore("provider", normalizeProviderList(x.data!))
input.setStore("provider_ready", true)
})
.catch((err) => {
if (providerRev.get(input.directory) !== rev) console.error("Failed to refresh provider list", err)
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(err, input.translate),
})
})
.then(() => null),
})
if (loading && slowErrs.length === 0) input.setStore("status", "complete")
})()
}
@@ -14,6 +14,8 @@ import {
type VcsCache,
} from "./types"
import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
import { useQuery } from "@tanstack/solid-query"
import { loadPathQuery } from "./bootstrap"
export function createChildStoreManager(input: {
owner: Owner
@@ -161,16 +163,21 @@ export function createChildStoreManager(input: {
const init = () =>
createRoot((dispose) => {
const initialMeta = meta[0].value
const initialIcon = icon[0].value
const pathQuery = useQuery(() => loadPathQuery(directory))
const child = createStore<State>({
project: "",
projectMeta: initialMeta,
projectMeta: undefined,
icon: initialIcon,
provider_ready: false,
provider: { all: [], connected: [], default: {} },
config: {},
path: { state: "", config: "", worktree: "", directory: "", home: "" },
get path() {
if (pathQuery.isLoading || !pathQuery.data)
return { state: "", config: "", worktree: "", directory: "", home: "" }
return pathQuery.data
},
status: "loading" as const,
agent: [],
command: [],
@@ -207,11 +214,6 @@ export function createChildStoreManager(input: {
child[1]("vcs", (value) => value ?? cached)
})
onPersistedInit(meta[2], () => {
if (child[0].projectMeta !== initialMeta) return
child[1]("projectMeta", meta[0].value)
})
onPersistedInit(icon[2], () => {
if (child[0].icon !== initialIcon) return
child[1]("icon", icon[0].value)
@@ -1,4 +1,4 @@
import { Binary } from "@opencode-ai/shared/util/binary"
import { Binary } from "@opencode-ai/core/util/binary"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type {
Message,
@@ -21,7 +21,7 @@ const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
export function applyGlobalEvent(input: {
event: { type: string; properties?: unknown }
project: Project[]
setGlobalProject: (next: Project[] | ((draft: Project[]) => void)) => void
setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void
refresh: () => void
}) {
if (input.event.type === "global.disposed" || input.event.type === "server.connected") {
@@ -33,14 +33,18 @@ export function applyGlobalEvent(input: {
const properties = input.event.properties as Project
const result = Binary.search(input.project, properties.id, (s) => s.id)
if (result.found) {
input.setGlobalProject((draft) => {
draft[result.index] = { ...draft[result.index], ...properties }
})
input.setGlobalProject(
produce((draft) => {
draft[result.index] = { ...draft[result.index], ...properties }
}),
)
return
}
input.setGlobalProject((draft) => {
draft.splice(result.index, 0, properties)
})
input.setGlobalProject(
produce((draft) => {
draft.splice(result.index, 0, properties)
}),
)
}
function cleanupSessionCaches(
+2 -32
View File
@@ -391,37 +391,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
? globalSync.data.project.find((x) => x.id === projectID)
: globalSync.data.project.find((x) => x.worktree === project.worktree)
const local = childStore.projectMeta
const localOverride =
local?.name !== undefined ||
local?.commands?.start !== undefined ||
local?.icon?.override !== undefined ||
local?.icon?.color !== undefined
const base = {
...metadata,
...project,
icon: {
url: metadata?.icon?.url,
override: metadata?.icon?.override ?? childStore.icon,
color: metadata?.icon?.color,
},
}
const isGlobal = projectID === "global" || (metadata?.id === undefined && localOverride)
if (!isGlobal) return base
return {
...base,
id: base.id ?? "global",
name: local?.name,
commands: local?.commands,
icon: {
url: base.icon?.url,
override: local?.icon?.override,
color: local?.icon?.color,
},
}
return { ...metadata, ...project }
}
const roots = createMemo(() => {
@@ -516,7 +486,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
for (const project of projects) {
if (project.icon?.color) continue
if (project.icon?.color || project.icon?.override || project.icon?.url) continue
const worktree = project.worktree
const existing = colors[worktree]
const color = existing ?? pickAvailableColor(used)
+1 -1
View File
@@ -1,5 +1,5 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { base64Encode } from "@opencode-ai/shared/util/encode"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
+2 -2
View File
@@ -7,8 +7,8 @@ import { useGlobalSync } from "./global-sync"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { Binary } from "@opencode-ai/shared/util/binary"
import { base64Encode } from "@opencode-ai/shared/util/encode"
import { Binary } from "@opencode-ai/core/util/binary"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { decode64 } from "@/utils/base64"
import { EventSessionError } from "@opencode-ai/sdk/v2"
import { Persist, persisted } from "@/utils/persist"
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { base64Encode } from "@opencode-ai/shared/util/encode"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond"
const session = (input: { id: string; parentID?: string }) =>
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/shared/util/encode"
import { base64Encode } from "@opencode-ai/core/util/encode"
export function acceptKey(sessionID: string, directory?: string) {
if (!directory) return sessionID
+3 -3
View File
@@ -154,11 +154,11 @@ export type Platform = {
/** Storage mechanism, defaults to localStorage */
storage?: (name?: string) => SyncStorage | AsyncStorage
/** Check for updates (Tauri only) */
/** Check for a downloadable desktop update */
checkUpdate?(): Promise<UpdateInfo>
/** Install updates (Tauri only) */
update?(): Promise<void>
/** Install the downloaded update using the platform restart flow */
updateAndRestart?(): Promise<void>
/** Fetch override */
fetch?: typeof fetch
+4 -4
View File
@@ -1,5 +1,5 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { checksum } from "@opencode-ai/shared/util/encode"
import { checksum } from "@opencode-ai/core/util/encode"
import { useParams } from "@solidjs/router"
import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
@@ -185,9 +185,9 @@ function createPromptSession(dir: string, id: string | undefined) {
return {
ready,
current: createMemo(() => store.prompt),
current: () => store.prompt,
cursor: createMemo(() => store.cursor),
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
context: {
items: createMemo(() => store.context.items),
add(item: ContextItem) {
@@ -280,7 +280,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const pick = (scope?: Scope) => (scope ? load(scope.dir, scope.id) : session())
return {
ready: () => session().ready(),
ready: () => session().ready,
current: () => session().current(),
cursor: () => session().cursor(),
dirty: () => session().dirty(),
+11 -1
View File
@@ -31,6 +31,7 @@ export interface Settings {
showReasoningSummaries: boolean
shellToolPartsExpanded: boolean
editToolPartsExpanded: boolean
showSessionProgressBar: boolean
}
updates: {
startup: boolean
@@ -56,7 +57,8 @@ export const terminalDefault = "JetBrainsMono Nerd Font Mono"
const monoFallback =
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
const terminalFallback = '"JetBrainsMono Nerd Font Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
const terminalFallback =
'"JetBrainsMono Nerd Font Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
const monoBase = monoFallback
const sansBase = sansFallback
@@ -114,6 +116,7 @@ const defaultSettings: Settings = {
showReasoningSummaries: false,
shellToolPartsExpanded: false,
editToolPartsExpanded: false,
showSessionProgressBar: true,
},
updates: {
startup: true,
@@ -226,6 +229,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setEditToolPartsExpanded(value: boolean) {
setStore("general", "editToolPartsExpanded", value)
},
showSessionProgressBar: withFallback(
() => store.general?.showSessionProgressBar,
defaultSettings.general.showSessionProgressBar,
),
setShowSessionProgressBar(value: boolean) {
setStore("general", "showSessionProgressBar", value)
},
},
updates: {
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
+2 -2
View File
@@ -1,7 +1,7 @@
import { batch, createMemo } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { Binary } from "@opencode-ai/shared/util/binary"
import { retry } from "@opencode-ai/shared/util/retry"
import { Binary } from "@opencode-ai/core/util/binary"
import { retry } from "@opencode-ai/core/util/retry"
import { createSimpleContext } from "@opencode-ai/ui/context"
import {
clearSessionPrefetch,