Compare commits

...

11 Commits

Author SHA1 Message Date
Brendan Allan
f4cda5198a Merge branch 'dev' into brendan/rework-default-server 2026-03-11 14:50:00 +08:00
Brendan Allan
0214485fec remove log 2026-03-11 14:22:21 +08:00
opencode-agent[bot]
7ec398d855 chore: generate 2026-03-11 03:34:02 +00:00
Luke Parker
4ab35d2c5c fix(electron): hide Windows background consoles (#16842)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-03-11 13:33:06 +10:00
SOUMITRA-SAHA
b4ae030fc2 fix: add GOOGLE_VERTEX_LOCATION env var support for Vertex AI (#16922)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-10 22:32:39 -05:00
Brendan Allan
5869d746a1 refactor(desktop): rework default server initialization and connection handling 2026-03-11 11:12:25 +08:00
Jack
0843964eb3 feat(web): use Feishu for Chinese community links (#16908)
Co-authored-by: Frank <frank@anoma.ly>
2026-03-11 11:07:13 +08:00
Kit Langton
a1b06d63c9 fix(account): resilient orgs fetch (#16944) 2026-03-11 00:39:07 +00:00
Dax Raad
1b6820bab5 sync 2026-03-10 20:13:56 -04:00
Adam
89bf199c07 chore(app): fix tests 2026-03-10 19:03:44 -05:00
Aiden Cline
5acfdd1c5d chore: kill old copilot 403 message that was used for old plugin migration (#16904) 2026-03-10 16:20:41 -05:00
39 changed files with 448 additions and 642 deletions

View File

@@ -1,3 +1,4 @@
plans/
bun.lock
package.json
package-lock.json

View File

@@ -137,4 +137,4 @@ OpenCode 内置两种 Agent可用 `Tab` 键快速切换:
---
**加入我们的社区** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)

View File

@@ -137,4 +137,4 @@ OpenCode 內建了兩種 Agent您可以使用 `Tab` 鍵快速切換。
---
**加入我們的社群** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)

View File

@@ -46,6 +46,7 @@
"@solidjs/router": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"effect": "4.0.0-beta.29",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#main",
"luxon": "catalog:",
@@ -226,6 +227,7 @@
"@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "0.15.4",
"effect": "4.0.0-beta.29",
"electron-log": "^5",
"electron-store": "^10",
"electron-updater": "^6",

View File

@@ -9,14 +9,12 @@ test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
await expect(terminal).not.toBeVisible()
await prompt.click()
await page.keyboard.type("/terminal")
await prompt.fill("/terminal")
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect(terminal).toBeVisible()
await prompt.click()
await page.keyboard.type("/terminal")
await prompt.fill("/terminal")
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect(terminal).not.toBeVisible()

View File

@@ -1,5 +1,6 @@
export const promptSelector = '[data-component="prompt-input"]'
export const terminalSelector = '[data-component="terminal"]'
export const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]'
export const terminalSelector = `${terminalPanelSelector} [data-component="terminal"]`
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'

View File

@@ -45,8 +45,8 @@
"@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3",
"@solid-primitives/audio": "1.4.2",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3",
@@ -56,6 +56,7 @@
"@solidjs/router": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"effect": "4.0.0-beta.29",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#main",
"luxon": "catalog:",

View File

@@ -1,14 +1,29 @@
import "@/index.css"
import { File } from "@opencode-ai/ui/file"
import { I18nProvider } from "@opencode-ai/ui/context"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { File } from "@opencode-ai/ui/file"
import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme"
import { MetaProvider } from "@solidjs/meta"
import { BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { Component, ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { type Duration, Effect } from "effect"
import {
type Component,
createResource,
createSignal,
ErrorBoundary,
For,
type JSX,
lazy,
onCleanup,
type ParentProps,
Show,
Suspense,
} from "solid-js"
import { Dynamic } from "solid-js/web"
import { CommandProvider } from "@/context/command"
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
@@ -22,13 +37,13 @@ import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { PromptProvider } from "@/context/prompt"
import { type ServerConnection, ServerProvider, useServer } from "@/context/server"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
import { SettingsProvider } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal"
import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
import { Dynamic } from "solid-js/web"
import { useCheckServerHealth } from "./utils/server-health"
const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
@@ -132,15 +147,108 @@ export function AppBaseProviders(props: ParentProps) {
)
}
function ServerKey(props: ParentProps) {
const effectMinDuration =
(duration: Duration.Input) =>
<A, E, R>(e: Effect.Effect<A, E, R>) =>
Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0]))
function ConnectionGate(props: ParentProps) {
const server = useServer()
const checkServerHealth = useCheckServerHealth()
const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking")
// 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
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 (
<Show when={server.key} keyed>
{props.children}
<Show
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
fallback={
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
}
>
<Show
when={startupHealthCheck()}
fallback={
<ConnectionError
onRetry={() => {
if (checkMode() === "background") healthCheckActions.refetch()
}}
onServerSelected={(key) => {
setCheckMode("blocking")
server.setActive(key)
healthCheckActions.refetch()
}}
/>
}
>
{props.children}
</Show>
</Show>
)
}
function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) {
const server = useServer()
const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key)
const timer = setInterval(() => props.onRetry?.(), 1000)
onCleanup(() => clearInterval(timer))
return (
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base gap-6 p-6">
<div class="flex flex-col items-center max-w-md text-center">
<Splash class="w-12 h-15 mb-4" />
<p class="text-14-regular text-text-base">
Could not reach <span class="text-text-strong font-medium">{server.name || server.key}</span>
</p>
<p class="mt-1 text-12-regular text-text-weak">Retrying automatically...</p>
</div>
<Show when={others().length > 0}>
<div class="flex flex-col gap-2 w-full max-w-sm">
<span class="text-12-regular text-text-base text-center">Other servers</span>
<div class="flex flex-col gap-1 bg-surface-base rounded-lg p-2">
<For each={others()}>
{(conn) => {
const key = ServerConnection.key(conn)
return (
<button
type="button"
class="flex items-center gap-3 w-full px-3 py-2 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => props.onServerSelected?.(key)}
>
<span class="text-14-regular text-text-strong truncate">{serverName(conn)}</span>
</button>
)
}}
</For>
</div>
</div>
</Show>
</div>
)
}
export function AppInterface(props: {
children?: JSX.Element
defaultServer: ServerConnection.Key
@@ -149,7 +257,7 @@ export function AppInterface(props: {
}) {
return (
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
<ServerKey>
<ConnectionGate>
<GlobalSDKProvider>
<GlobalSyncProvider>
<Dynamic
@@ -164,7 +272,7 @@ export function AppInterface(props: {
</Dynamic>
</GlobalSyncProvider>
</GlobalSDKProvider>
</ServerKey>
</ConnectionGate>
</ServerProvider>
)
}

View File

@@ -14,7 +14,7 @@ import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
interface ServerFormProps {
value: string
@@ -41,13 +41,15 @@ function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown
})
}
function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: ReturnType<typeof useLanguage>) {
const [defaultUrl, defaultUrlActions] = createResource(
function useDefaultServer() {
const language = useLanguage()
const platform = usePlatform()
const [defaultKey, defaultUrlActions] = createResource(
async () => {
try {
const url = await platform.getDefaultServerUrl?.()
if (!url) return null
return normalizeServerUrl(url) ?? null
const key = await platform.getDefaultServer?.()
if (!key) return null
return key
} catch (err) {
showRequestError(language, err)
return null
@@ -56,20 +58,22 @@ function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: Re
{ initialValue: null },
)
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
const setDefault = async (url: string | null) => {
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
const setDefault = async (key: ServerConnection.Key | null) => {
try {
await platform.setDefaultServerUrl?.(url)
defaultUrlActions.mutate(url)
await platform.setDefaultServer?.(key)
defaultUrlActions.mutate(key)
} catch (err) {
showRequestError(language, err)
}
}
return { defaultUrl, canDefault, setDefault }
return { defaultKey, canDefault, setDefault }
}
function useServerPreview(fetcher: typeof fetch) {
function useServerPreview() {
const checkServerHealth = useCheckServerHealth()
const looksComplete = (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) return false
@@ -92,7 +96,7 @@ function useServerPreview(fetcher: typeof fetch) {
const http: ServerConnection.HttpBase = { url: normalized }
if (username) http.username = username
if (password) http.password = password
const result = await checkServerHealth(http, fetcher)
const result = await checkServerHealth(http)
setStatus(result.healthy)
}
@@ -170,9 +174,9 @@ export function DialogSelectServer() {
const server = useServer()
const platform = usePlatform()
const language = useLanguage()
const fetcher = platform.fetch ?? globalThis.fetch
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
const { previewStatus } = useServerPreview(fetcher)
const { defaultKey, canDefault, setDefault } = useDefaultServer()
const { previewStatus } = useServerPreview()
const checkServerHealth = useCheckServerHealth()
const [store, setStore] = createStore({
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
addServer: {
@@ -264,7 +268,7 @@ export function DialogSelectServer() {
const results: Record<ServerConnection.Key, ServerHealth> = {}
await Promise.all(
items().map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
}),
)
setStore("status", reconcile(results))
@@ -364,7 +368,7 @@ export function DialogSelectServer() {
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
if (store.addServer.username) conn.http.username = store.addServer.username
if (store.addServer.password) conn.http.password = store.addServer.password
const result = await checkServerHealth(conn.http, fetcher)
const result = await checkServerHealth(conn.http)
setStore("addServer", { adding: false })
if (!result.healthy) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
@@ -404,7 +408,7 @@ export function DialogSelectServer() {
displayName: name,
http: { url: normalized, username, password },
}
const result = await checkServerHealth(conn.http, fetcher)
const result = await checkServerHealth(conn.http)
setStore("editServer", { busy: false })
if (!result.healthy) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
@@ -494,8 +498,8 @@ export function DialogSelectServer() {
async function handleRemove(url: ServerConnection.Key) {
server.remove(url)
if ((await platform.getDefaultServerUrl?.()) === url) {
platform.setDefaultServerUrl?.(null)
if ((await platform.getDefaultServer?.()) === url) {
platform.setDefaultServer?.(null)
}
}
@@ -551,7 +555,7 @@ export function DialogSelectServer() {
status={store.status[key]}
class="flex items-center gap-3 min-w-0 flex-1"
badge={
<Show when={defaultUrl() === i.http.url}>
<Show when={defaultKey() === i.http.url}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
@@ -584,14 +588,14 @@ export function DialogSelectServer() {
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={canDefault() && defaultUrl() !== i.http.url}>
<DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
<Show when={canDefault() && defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => setDefault(key)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canDefault() && defaultUrl() === i.http.url}>
<Show when={canDefault() && defaultKey() === key}>
<DropdownMenu.Item onSelect={() => setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}

View File

@@ -14,7 +14,7 @@ import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
import { DialogSelectServer } from "./dialog-select-server"
const pollMs = 10_000
@@ -53,7 +53,8 @@ const listServersByHealth = (
})
}
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typeof fetch) => {
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => {
const checkServerHealth = useCheckServerHealth()
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
createEffect(() => {
@@ -64,7 +65,7 @@ const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typ
const results: Record<string, ServerHealth> = {}
await Promise.all(
list.map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
}),
)
if (dead) return
@@ -168,7 +169,6 @@ export function StatusPopover() {
const language = useLanguage()
const navigate = useNavigate()
const fetcher = platform.fetch ?? globalThis.fetch
const servers = createMemo(() => {
const current = server.current
const list = server.list
@@ -176,10 +176,10 @@ export function StatusPopover() {
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
})
const health = useServerHealth(servers, fetcher)
const health = useServerHealth(servers)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
const mcp = useMcpToggle({ sync, sdk, language })
const defaultServer = useDefaultServerKey(platform.getDefaultServerUrl)
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)

View File

@@ -1,6 +1,7 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
import type { Accessor } from "solid-js"
import { ServerConnection } from "./server"
type PickerPaths = string | string[] | null
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
@@ -58,10 +59,10 @@ export type Platform = {
fetch?: typeof fetch
/** Get the configured default server URL (platform-specific) */
getDefaultServerUrl?(): Promise<string | null>
getDefaultServer?(): Promise<ServerConnection.Key | null>
/** Set the default server URL to use on app startup (platform-specific) */
setDefaultServerUrl?(url: string | null): Promise<void> | void
setDefaultServer?(url: ServerConnection.Key | null): Promise<void> | void
/** Get the configured WSL integration (desktop only) */
getWslEnabled?(): Promise<boolean>

View File

@@ -1,9 +1,8 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform"
import { Persist, persisted } from "@/utils/persist"
import { checkServerHealth } from "@/utils/server-health"
import { useCheckServerHealth } from "@/utils/server-health"
type StoredProject = { worktree: string; expanded: boolean }
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
@@ -96,7 +95,7 @@ export namespace ServerConnection {
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
name: "Server",
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
const platform = usePlatform()
const checkServerHealth = useCheckServerHealth()
const [store, setStore, _, ready] = persisted(
Persist.global("server", ["server.v3"]),
@@ -197,8 +196,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const isReady = createMemo(() => ready() && !!state.active)
const fetcher = platform.fetch ?? globalThis.fetch
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http, fetcher).then((x) => x.healthy)
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http).then((x) => x.healthy)
createEffect(() => {
const current_ = current()

View File

@@ -98,18 +98,6 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) {
throw new Error(getRootNotFoundError())
}
const platform: Platform = {
platform: "web",
version: pkg.version,
openLink,
back,
forward,
restart,
notify,
getDefaultServerUrl: async () => readDefaultServerUrl(),
setDefaultServerUrl: writeDefaultServerUrl,
}
const defaultUrl = iife(() => {
const lsDefault = readDefaultServerUrl()
if (lsDefault) return lsDefault
@@ -119,6 +107,18 @@ const defaultUrl = iife(() => {
return location.origin
})
const platform: Platform = {
platform: "web",
version: pkg.version,
openLink,
back,
forward,
restart,
notify,
getDefaultServer: async () => ServerConnection.Key.make(defaultUrl),
setDefaultServer: writeDefaultServerUrl,
}
if (root instanceof HTMLElement) {
const server: ServerConnection.Http = { type: "http", http: { url: defaultUrl } }
render(

View File

@@ -1,3 +1,4 @@
import { usePlatform } from "@/context/platform"
import type { ServerConnection } from "@/context/server"
import { createSdkForServer } from "./server"
@@ -81,3 +82,10 @@ export async function checkServerHealth(
.catch((error) => next(count, error))
return attempt(0).finally(() => timeout?.clear?.())
}
export function useCheckServerHealth() {
const platform = usePlatform()
const fetcher = platform.fetch ?? globalThis.fetch
return (http: ServerConnection.HttpBase) => checkServerHealth(http, fetcher)
}

View File

@@ -8,6 +8,12 @@ import { useI18n } from "~/context/i18n"
export function Footer() {
const language = useLanguage()
const i18n = useI18n()
const community = createMemo(() => {
const locale = language.locale()
return locale === "zh" || locale === "zht"
? ({ key: "footer.feishu", link: language.route("/feishu") } as const)
: ({ key: "footer.discord", link: language.route("/discord") } as const)
})
const githubData = createAsync(() => github())
const starCount = createMemo(() =>
githubData()?.stars
@@ -32,7 +38,7 @@ export function Footer() {
<a href={language.route("/changelog")}>{i18n.t("footer.changelog")}</a>
</div>
<div data-slot="cell">
<a href={language.route("/discord")}>{i18n.t("footer.discord")}</a>
<a href={community().link}>{i18n.t(community().key)}</a>
</div>
<div data-slot="cell">
<a href={config.social.twitter}>{i18n.t("footer.x")}</a>

View File

@@ -21,6 +21,7 @@ export const dict = {
"footer.github": "GitHub",
"footer.docs": "Docs",
"footer.changelog": "Changelog",
"footer.feishu": "Feishu",
"footer.discord": "Discord",
"footer.x": "X",

View File

@@ -24,6 +24,7 @@ export const dict = {
"footer.github": "GitHub",
"footer.docs": "文档",
"footer.changelog": "更新日志",
"footer.feishu": "飞书",
"footer.discord": "Discord",
"footer.x": "X",

View File

@@ -24,6 +24,7 @@ export const dict = {
"footer.github": "GitHub",
"footer.docs": "文件",
"footer.changelog": "更新日誌",
"footer.feishu": "飞书",
"footer.discord": "Discord",
"footer.x": "X",

View File

@@ -0,0 +1,7 @@
import { redirect } from "@solidjs/router"
export async function GET() {
return redirect(
"https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true",
)
}

View File

@@ -30,6 +30,7 @@
"@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "0.15.4",
"effect": "4.0.0-beta.29",
"electron-log": "^5",
"electron-store": "^10",
"electron-updater": "^6",

View File

@@ -107,7 +107,7 @@ export function syncCli() {
let version = ""
try {
version = execFileSync(installPath, ["--version"]).toString().trim()
version = execFileSync(installPath, ["--version"], { windowsHide: true }).toString().trim()
} catch {
return
}
@@ -147,7 +147,7 @@ export function spawnCommand(args: string, extraEnv: Record<string, string>) {
console.log(`[cli] Executing: ${cmd} ${cmdArgs.join(" ")}`)
const child = spawn(cmd, cmdArgs, {
env: envs,
detached: true,
detached: process.platform !== "win32",
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
})

View File

@@ -31,35 +31,13 @@ import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigratio
import { initLogging } from "./logging"
import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu"
import {
checkHealth,
checkHealthOrAskRetry,
getDefaultServerUrl,
getSavedServerUrl,
getWslConfig,
setDefaultServerUrl,
setWslConfig,
spawnLocalServer,
} from "./server"
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
import { createLoadingWindow, createMainWindow, setDockIcon } from "./windows"
type ServerConnection =
| { variant: "existing"; url: string }
| {
variant: "cli"
url: string
password: null | string
health: {
wait: Promise<void>
}
events: any
}
const initEmitter = new EventEmitter()
let initStep: InitStep = { phase: "server_waiting" }
let mainWindow: BrowserWindow | null = null
const loadingWindow: BrowserWindow | null = null
let sidecar: CommandChild | null = null
const loadingComplete = defer<void>()
@@ -131,77 +109,48 @@ function setInitStep(step: InitStep) {
initEmitter.emit("step", step)
}
async function setupServerConnection(): Promise<ServerConnection> {
const customUrl = await getSavedServerUrl()
if (customUrl && (await checkHealthOrAskRetry(customUrl))) {
serverReady.resolve({ url: customUrl, password: null })
return { variant: "existing", url: customUrl }
}
const port = await getSidecarPort()
const hostname = "127.0.0.1"
const localUrl = `http://${hostname}:${port}`
if (await checkHealth(localUrl)) {
serverReady.resolve({ url: localUrl, password: null })
return { variant: "existing", url: localUrl }
}
const password = randomUUID()
const { child, health, events } = spawnLocalServer(hostname, port, password)
sidecar = child
return {
variant: "cli",
url: localUrl,
password,
health,
events,
}
}
async function initialize() {
const needsMigration = !sqliteFileExists()
const sqliteDone = needsMigration ? defer<void>() : undefined
let overlay: BrowserWindow | null = null
const port = await getSidecarPort()
const hostname = "127.0.0.1"
const url = `http://${hostname}:${port}`
const password = randomUUID()
logger.log("spawning sidecar", { url })
const { child, health, events } = spawnLocalServer(hostname, port, password)
sidecar = child
serverReady.resolve({
url,
username: "opencode",
password,
})
const loadingTask = (async () => {
logger.log("setting up server connection")
const serverConnection = await setupServerConnection()
logger.log("server connection ready", {
variant: serverConnection.variant,
url: serverConnection.url,
logger.log("sidecar connection started", { url })
events.on("sqlite", (progress: SqliteMigrationProgress) => {
setInitStep({ phase: "sqlite_waiting" })
if (overlay) sendSqliteMigrationProgress(overlay, progress)
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
if (progress.type === "Done") sqliteDone?.resolve()
})
const cliHealthCheck = (() => {
if (serverConnection.variant == "cli") {
return async () => {
const { events, health } = serverConnection
events.on("sqlite", (progress: SqliteMigrationProgress) => {
setInitStep({ phase: "sqlite_waiting" })
if (loadingWindow) sendSqliteMigrationProgress(loadingWindow, progress)
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
if (progress.type === "Done") sqliteDone?.resolve()
})
await health.wait
serverReady.resolve({
url: serverConnection.url,
password: serverConnection.password,
})
}
} else {
serverReady.resolve({ url: serverConnection.url, password: null })
return null
}
})()
logger.log("server connection started")
if (cliHealthCheck) {
if (needsMigration) await sqliteDone?.promise
cliHealthCheck?.()
if (needsMigration) {
await sqliteDone?.promise
}
await Promise.race([
health.wait,
delay(30_000).then(() => {
throw new Error("Sidecar health check timed out")
}),
]).catch((error) => {
logger.error("sidecar health check failed", error)
})
logger.log("loading task finished")
})()
@@ -211,32 +160,25 @@ async function initialize() {
deepLinks: pendingDeepLinks,
}
const loadingWindow = await (async () => {
if (needsMigration /** TOOD: 1 second timeout */) {
// showLoading = await Promise.race([init.then(() => false).catch(() => false), delay(1000).then(() => true)])
const loadingWindow = createLoadingWindow(globals)
await delay(1000)
return loadingWindow
} else {
logger.log("showing main window without loading window")
mainWindow = createMainWindow(globals)
wireMenu()
mainWindow = createMainWindow(globals)
wireMenu()
if (needsMigration) {
const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)])
if (show) {
overlay = createLoadingWindow(globals)
await delay(1_000)
}
})()
}
await loadingTask
setInitStep({ phase: "done" })
if (loadingWindow) {
if (overlay) {
await loadingComplete.promise
}
if (!mainWindow) {
mainWindow = createMainWindow(globals)
wireMenu()
}
loadingWindow?.close()
overlay?.close()
}
function wireMenu() {

View File

@@ -1,6 +1,4 @@
import { dialog } from "electron"
import { getConfig, serve, type CommandChild, type Config } from "./cli"
import { serve, type CommandChild } from "./cli"
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
import { store } from "./store"
@@ -31,15 +29,6 @@ export function setWslConfig(config: WslConfig) {
store.set(WSL_ENABLED_KEY, config.enabled)
}
export async function getSavedServerUrl(): Promise<string | null> {
const direct = getDefaultServerUrl()
if (direct) return direct
const config = await getConfig().catch(() => null)
if (!config) return null
return getServerUrlFromConfig(config)
}
export function spawnLocalServer(hostname: string, port: number, password: string) {
const { child, exit, events } = serve(hostname, port, password)
@@ -94,36 +83,4 @@ export async function checkHealth(url: string, password?: string | null): Promis
}
}
export async function checkHealthOrAskRetry(url: string): Promise<boolean> {
while (true) {
if (await checkHealth(url)) return true
const result = await dialog.showMessageBox({
type: "warning",
message: `Could not connect to configured server:\n${url}\n\nWould you like to retry or start a local server instead?`,
title: "Connection Failed",
buttons: ["Retry", "Start Local"],
defaultId: 0,
cancelId: 1,
})
if (result.response === 0) continue
return false
}
}
export function normalizeHostnameForUrl(hostname: string) {
if (hostname === "0.0.0.0") return "127.0.0.1"
if (hostname === "::") return "[::1]"
if (hostname.includes(":") && !hostname.startsWith("[")) return `[${hostname}]`
return hostname
}
export function getServerUrlFromConfig(config: Config) {
const server = config.server
if (!server?.port) return null
const host = server.hostname ? normalizeHostnameForUrl(server.hostname) : "127.0.0.1"
return `http://${host}:${server.port}`
}
export type { CommandChild }

View File

@@ -2,6 +2,7 @@ export type InitStep = { phase: "server_waiting" } | { phase: "sqlite_waiting" }
export type ServerReadyData = {
url: string
username: string | null
password: string | null
}

View File

@@ -9,9 +9,8 @@ import {
ServerConnection,
useCommand,
} from "@opencode-ai/app"
import { Splash } from "@opencode-ai/ui/logo"
import type { AsyncStorage } from "@solid-primitives/storage"
import { type Accessor, createResource, type JSX, onCleanup, onMount, Show } from "solid-js"
import { createResource, onCleanup, onMount, Show } from "solid-js"
import { render } from "solid-js/web"
import { MemoryRouter } from "@solidjs/router"
import pkg from "../../package.json"
@@ -19,7 +18,6 @@ import { initI18n, t } from "./i18n"
import { UPDATER_ENABLED } from "./updater"
import { webviewZoom } from "./webview-zoom"
import "./styles.css"
import type { ServerReadyData } from "../preload/types"
const root = document.getElementById("root")
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
@@ -198,11 +196,13 @@ const createPlatform = (): Platform => {
await window.api.setWslConfig({ enabled })
},
getDefaultServerUrl: async () => {
return window.api.getDefaultServerUrl().catch(() => null)
getDefaultServer: async () => {
const url = await window.api.getDefaultServerUrl().catch(() => null)
if (!url) return null
return ServerConnection.Key.make(url)
},
setDefaultServerUrl: async (url: string | null) => {
setDefaultServer: async (url: string | null) => {
await window.api.setDefaultServerUrl(url)
},
@@ -240,6 +240,31 @@ listenForDeepLinks()
render(() => {
const platform = createPlatform()
// Fetch sidecar credentials (available immediately, before health check)
const [sidecar] = createResource(() => window.api.awaitInitialization(() => undefined))
const [defaultServer] = createResource(() =>
platform.getDefaultServer?.().then((url) => {
if (url) return ServerConnection.key({ type: "http", http: { url } })
}),
)
const servers = () => {
const data = sidecar()
if (!data) return []
const server: ServerConnection.Sidecar = {
displayName: "Local Server",
type: "sidecar",
variant: "base",
http: {
url: data.url,
username: data.username ?? undefined,
password: data.password ?? undefined,
},
}
return [server] as ServerConnection.Any[]
}
function handleClick(e: MouseEvent) {
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
if (link?.href) {
@@ -248,6 +273,12 @@ render(() => {
}
}
function Inner() {
const cmd = useCommand()
menuTrigger = (id) => cmd.trigger(id)
return null
}
onMount(() => {
document.addEventListener("click", handleClick)
onCleanup(() => {
@@ -258,55 +289,20 @@ render(() => {
return (
<PlatformProvider value={platform}>
<AppBaseProviders>
<ServerGate>
{(data) => {
const server: ServerConnection.Sidecar = {
displayName: "Local Server",
type: "sidecar",
variant: "base",
http: {
url: data().url,
username: "opencode",
password: data().password ?? undefined,
},
}
function Inner() {
const cmd = useCommand()
menuTrigger = (id) => cmd.trigger(id)
return null
}
<Show when={!defaultServer.loading && !sidecar.loading}>
{(_) => {
return (
<AppInterface defaultServer={ServerConnection.key(server)} servers={[server]} router={MemoryRouter}>
<AppInterface
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
servers={servers()}
router={MemoryRouter}
>
<Inner />
</AppInterface>
)
}}
</ServerGate>
</Show>
</AppBaseProviders>
</PlatformProvider>
)
}, root!)
// Gate component that waits for the server to be ready
function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.Element }) {
const [serverData] = createResource(() => window.api.awaitInitialization(() => undefined))
console.log({ serverData })
if (serverData.state === "errored") throw serverData.error
return (
<Show
when={serverData.state !== "pending" && serverData()}
fallback={
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
}
>
{(data) => props.children(data)}
</Show>
)
}

View File

@@ -12,12 +12,10 @@ mod window_customizer;
mod windows;
use crate::cli::CommandChild;
use futures::{
FutureExt, TryFutureExt,
future::{self, Shared},
};
use futures::{FutureExt, TryFutureExt};
use std::{
env,
future::Future,
net::TcpListener,
path::PathBuf,
process::Command,
@@ -35,7 +33,6 @@ use tokio::{
use crate::cli::{sqlite_migration::SqliteMigrationProgress, sync_cli};
use crate::constants::*;
use crate::server::get_saved_server_url;
use crate::windows::{LoadingWindow, MainWindow};
#[derive(Clone, serde::Serialize, specta::Type, Debug)]
@@ -43,7 +40,6 @@ struct ServerReadyData {
url: String,
username: Option<String>,
password: Option<String>,
is_sidecar: bool,
}
#[derive(Clone, Copy, serde::Serialize, specta::Type, Debug)]
@@ -65,27 +61,12 @@ struct InitState {
current: watch::Receiver<InitStep>,
}
#[derive(Clone)]
struct ServerState {
child: Arc<Mutex<Option<CommandChild>>>,
status: future::Shared<oneshot::Receiver<Result<ServerReadyData, String>>>,
}
impl ServerState {
pub fn new(
child: Option<CommandChild>,
status: Shared<oneshot::Receiver<Result<ServerReadyData, String>>>,
) -> Self {
Self {
child: Arc::new(Mutex::new(child)),
status,
}
}
pub fn set_child(&self, child: Option<CommandChild>) {
*self.child.lock().unwrap() = child;
}
}
/// Resolves with sidecar credentials as soon as the sidecar is spawned (before health check).
struct SidecarReady(futures::future::Shared<oneshot::Receiver<ServerReadyData>>);
#[tauri::command]
#[specta::specta]
@@ -110,26 +91,21 @@ fn kill_sidecar(app: AppHandle) {
tracing::info!("Killed server");
}
fn get_logs() -> String {
logging::tail()
}
#[tauri::command]
#[specta::specta]
async fn await_initialization(
state: State<'_, ServerState>,
state: State<'_, SidecarReady>,
init_state: State<'_, InitState>,
events: Channel<InitStep>,
) -> Result<ServerReadyData, String> {
let mut rx = init_state.current.clone();
let events = async {
let stream = async {
let e = *rx.borrow();
let _ = events.send(e);
while rx.changed().await.is_ok() {
let step = *rx.borrow_and_update();
let _ = events.send(step);
if matches!(step, InitStep::Done) {
@@ -138,10 +114,18 @@ async fn await_initialization(
}
};
future::join(state.status.clone(), events)
.await
.0
.map_err(|_| "Failed to get server status".to_string())?
// Wait for sidecar credentials (available immediately after spawn, before health check)
let data = async {
state
.inner()
.0
.clone()
.await
.map_err(|_| "Failed to get sidecar data".to_string())
};
let (result, _) = futures::future::join(data, stream).await;
result
}
#[tauri::command]
@@ -439,22 +423,35 @@ async fn initialize(app: AppHandle) {
setup_app(&app, init_rx);
spawn_cli_sync_task(app.clone());
let (server_ready_tx, server_ready_rx) = oneshot::channel();
let server_ready_rx = server_ready_rx.shared();
app.manage(ServerState::new(None, server_ready_rx.clone()));
// Spawn sidecar immediately - credentials are known before health check
let port = get_sidecar_port();
let hostname = "127.0.0.1";
let url = format!("http://{hostname}:{port}");
let password = uuid::Uuid::new_v4().to_string();
tracing::info!("Spawning sidecar on {url}");
let (child, health_check) =
server::spawn_local_server(app.clone(), hostname.to_string(), port, password.clone());
// Make sidecar credentials available immediately (before health check completes)
let (ready_tx, ready_rx) = oneshot::channel();
let _ = ready_tx.send(ServerReadyData {
url: url.clone(),
username: Some("opencode".to_string()),
password: Some(password),
});
app.manage(SidecarReady(ready_rx.shared()));
app.manage(ServerState {
child: Arc::new(Mutex::new(Some(child))),
});
let loading_window_complete = event_once_fut::<LoadingWindowComplete>(&app);
tracing::info!("Main and loading windows created");
// SQLite migration handling:
// We only do this if the sqlite db doesn't exist, and we're expecting the sidecar to create it
// First, we spawn a task that listens for SqliteMigrationProgress events that can
// come from any invocation of the sidecar CLI. The progress is captured by a stdout stream interceptor.
// Then in the loading task, we wait for sqlite migration to complete before
// starting our health check against the server, otherwise long migrations could result in a timeout.
let needs_sqlite_migration = !sqlite_file_exists();
let sqlite_done = needs_sqlite_migration.then(|| {
// We only do this if the sqlite db doesn't exist, and we're expecting the sidecar to create it.
// A separate loading window is shown for long migrations.
let needs_migration = !sqlite_file_exists();
let sqlite_done = needs_migration.then(|| {
tracing::info!(
path = %opencode_db_path().expect("failed to get db path").display(),
"Sqlite file not found, waiting for it to be generated"
@@ -480,80 +477,22 @@ async fn initialize(app: AppHandle) {
}))
});
// The loading task waits for SQLite migration (if needed) then for the sidecar health check.
// This is only used to drive the loading window progress - the main window is shown immediately.
let loading_task = tokio::spawn({
let app = app.clone();
async move {
tracing::info!("Setting up server connection");
let server_connection = setup_server_connection(app.clone()).await;
tracing::info!("Server connection setup");
// we delay spawning this future so that the timeout is created lazily
let cli_health_check = match server_connection {
ServerConnection::CLI {
child,
health_check,
url,
username,
password,
} => {
let app = app.clone();
Some(
async move {
let res = timeout(Duration::from_secs(30), health_check.0).await;
let err = match res {
Ok(Ok(Ok(()))) => None,
Ok(Ok(Err(e))) => Some(e),
Ok(Err(e)) => Some(format!("Health check task failed: {e}")),
Err(_) => Some("Health check timed out".to_string()),
};
if let Some(err) = err {
let _ = child.kill();
return Err(format!(
"Failed to spawn OpenCode Server ({err}). Logs:\n{}",
get_logs()
));
}
tracing::info!("CLI health check OK");
app.state::<ServerState>().set_child(Some(child));
Ok(ServerReadyData {
url,
username,
password,
is_sidecar: true,
})
}
.map(move |res| {
let _ = server_ready_tx.send(res);
}),
)
}
ServerConnection::Existing { url } => {
let _ = server_ready_tx.send(Ok(ServerReadyData {
url: url.to_string(),
username: None,
password: None,
is_sidecar: false,
}));
None
}
};
tracing::info!("server connection started");
if let Some(cli_health_check) = cli_health_check {
if let Some(sqlite_done_rx) = sqlite_done {
let _ = sqlite_done_rx.await;
}
tokio::spawn(cli_health_check);
if let Some(sqlite_done_rx) = sqlite_done {
let _ = sqlite_done_rx.await;
}
let _ = server_ready_rx.await;
// Wait for sidecar to become healthy (for loading window progress)
let res = timeout(Duration::from_secs(30), health_check.0).await;
match res {
Ok(Ok(Ok(()))) => tracing::info!("Sidecar health check OK"),
Ok(Ok(Err(e))) => tracing::error!("Sidecar health check failed: {e}"),
Ok(Err(e)) => tracing::error!("Sidecar health check task failed: {e}"),
Err(_) => tracing::error!("Sidecar health check timed out"),
}
tracing::info!("Loading task finished");
}
@@ -561,7 +500,8 @@ async fn initialize(app: AppHandle) {
.map_err(|_| ())
.shared();
let loading_window = if needs_sqlite_migration
// Show loading window for SQLite migrations if they take >1s
let loading_window = if needs_migration
&& timeout(Duration::from_secs(1), loading_task.clone())
.await
.is_err()
@@ -571,12 +511,12 @@ async fn initialize(app: AppHandle) {
sleep(Duration::from_secs(1)).await;
Some(loading_window)
} else {
tracing::debug!("Showing main window without loading window");
MainWindow::create(&app).expect("Failed to create main window");
None
};
// Create main window immediately - the web app handles its own loading/health gate
MainWindow::create(&app).expect("Failed to create main window");
let _ = loading_task.await;
tracing::info!("Loading done, completing initialisation");
@@ -584,12 +524,9 @@ async fn initialize(app: AppHandle) {
if loading_window.is_some() {
loading_window_complete.await;
tracing::info!("Loading window completed");
}
MainWindow::create(&app).expect("Failed to create main window");
if let Some(loading_window) = loading_window {
let _ = loading_window.close();
}
@@ -610,59 +547,6 @@ fn spawn_cli_sync_task(app: AppHandle) {
});
}
enum ServerConnection {
Existing {
url: String,
},
CLI {
url: String,
username: Option<String>,
password: Option<String>,
child: CommandChild,
health_check: server::HealthCheck,
},
}
async fn setup_server_connection(app: AppHandle) -> ServerConnection {
let custom_url = get_saved_server_url(&app).await;
tracing::info!(?custom_url, "Attempting server connection");
if let Some(url) = &custom_url
&& server::check_health_or_ask_retry(&app, url).await
{
tracing::info!(%url, "Connected to custom server");
// If the default server is already local, no need to also spawn a sidecar
if server::is_localhost_url(url) {
return ServerConnection::Existing { url: url.clone() };
}
// Remote default server: fall through and also spawn a local sidecar
}
let local_port = get_sidecar_port();
let hostname = "127.0.0.1";
let local_url = format!("http://{hostname}:{local_port}");
tracing::debug!(url = %local_url, "Checking health of local server");
if server::check_health(&local_url, None).await {
tracing::info!(url = %local_url, "Health check OK, using existing server");
return ServerConnection::Existing { url: local_url };
}
let password = uuid::Uuid::new_v4().to_string();
tracing::info!("Spawning new local server");
let (child, health_check) =
server::spawn_local_server(app, hostname.to_string(), local_port, password.clone());
ServerConnection::CLI {
url: local_url,
username: Some("opencode".to_string()),
password: Some(password),
child,
health_check,
}
}
fn get_sidecar_port() -> u32 {
option_env!("OPENCODE_PORT")

View File

@@ -1,7 +1,6 @@
use std::time::{Duration, Instant};
use tauri::AppHandle;
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
use tauri_plugin_store::StoreExt;
use tokio::task::JoinHandle;
@@ -85,22 +84,6 @@ pub fn set_wsl_config(app: AppHandle, config: WslConfig) -> Result<(), String> {
Ok(())
}
pub async fn get_saved_server_url(app: &tauri::AppHandle) -> Option<String> {
if let Some(url) = get_default_server_url(app.clone()).ok().flatten() {
tracing::info!(%url, "Using desktop-specific custom URL");
return Some(url);
}
if let Some(cli_config) = cli::get_config(app).await
&& let Some(url) = get_server_url_from_config(&cli_config)
{
tracing::info!(%url, "Using custom server URL from config");
return Some(url);
}
None
}
pub fn spawn_local_server(
app: AppHandle,
hostname: String,
@@ -145,19 +128,27 @@ pub fn spawn_local_server(
pub struct HealthCheck(pub JoinHandle<Result<(), String>>);
pub async fn check_health(url: &str, password: Option<&str>) -> bool {
async fn check_health(url: &str, password: Option<&str>) -> bool {
let Ok(url) = reqwest::Url::parse(url) else {
return false;
};
let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(7));
if url_is_localhost(&url) {
if url
.host_str()
.is_some_and(|host| {
host.eq_ignore_ascii_case("localhost")
|| host
.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback())
})
{
// Some environments set proxy variables (HTTP_PROXY/HTTPS_PROXY/ALL_PROXY) without
// excluding loopback. reqwest respects these by default, which can prevent the desktop
// app from reaching its own local sidecar server.
builder = builder.no_proxy();
};
}
let Ok(client) = builder.build() else {
return false;
@@ -177,77 +168,3 @@ pub async fn check_health(url: &str, password: Option<&str>) -> bool {
.map(|r| r.status().is_success())
.unwrap_or(false)
}
pub fn is_localhost_url(url: &str) -> bool {
reqwest::Url::parse(url).is_ok_and(|u| url_is_localhost(&u))
}
fn url_is_localhost(url: &reqwest::Url) -> bool {
url.host_str().is_some_and(|host| {
host.eq_ignore_ascii_case("localhost")
|| host
.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback())
})
}
/// Converts a bind address hostname to a valid URL hostname for connection.
/// - `0.0.0.0` and `::` are wildcard bind addresses, not valid connect targets
/// - IPv6 addresses need brackets in URLs (e.g., `::1` -> `[::1]`)
fn normalize_hostname_for_url(hostname: &str) -> String {
// Wildcard bind addresses -> localhost equivalents
if hostname == "0.0.0.0" {
return "127.0.0.1".to_string();
}
if hostname == "::" {
return "[::1]".to_string();
}
// IPv6 addresses need brackets in URLs
if hostname.contains(':') && !hostname.starts_with('[') {
return format!("[{}]", hostname);
}
hostname.to_string()
}
fn get_server_url_from_config(config: &cli::Config) -> Option<String> {
let server = config.server.as_ref()?;
let port = server.port?;
tracing::debug!(port, "server.port found in OC config");
let hostname = server
.hostname
.as_ref()
.map(|v| normalize_hostname_for_url(v))
.unwrap_or_else(|| "127.0.0.1".to_string());
Some(format!("http://{}:{}", hostname, port))
}
pub async fn check_health_or_ask_retry(app: &AppHandle, url: &str) -> bool {
tracing::debug!(%url, "Checking health");
loop {
if check_health(url, None).await {
return true;
}
const RETRY: &str = "Retry";
let res = app.dialog()
.message(format!("Could not connect to configured server:\n{}\n\nWould you like to retry or start a local server instead?", url))
.title("Connection Failed")
.buttons(MessageDialogButtons::OkCancelCustom(RETRY.to_string(), "Start Local".to_string()))
.blocking_show_with_result();
match res {
MessageDialogResult::Custom(name) if name == RETRY => {
continue;
}
_ => {
break;
}
}
}
false
}

View File

@@ -38,7 +38,6 @@ export type ServerReadyData = {
url: string,
username: string | null,
password: string | null,
is_sidecar: boolean,
};
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" };

View File

@@ -9,7 +9,6 @@ import {
ServerConnection,
useCommand,
} from "@opencode-ai/app"
import { Splash } from "@opencode-ai/ui/logo"
import type { AsyncStorage } from "@solid-primitives/storage"
import { getCurrentWindow } from "@tauri-apps/api/window"
import { readImage } from "@tauri-apps/plugin-clipboard-manager"
@@ -22,7 +21,7 @@ import { relaunch } from "@tauri-apps/plugin-process"
import { open as shellOpen } from "@tauri-apps/plugin-shell"
import { Store } from "@tauri-apps/plugin-store"
import { check, type Update } from "@tauri-apps/plugin-updater"
import { createResource, type JSX, onCleanup, onMount, Show } from "solid-js"
import { createResource, onCleanup, onMount, Show } from "solid-js"
import { render } from "solid-js/web"
import pkg from "../package.json"
import { initI18n, t } from "./i18n"
@@ -30,7 +29,7 @@ import { UPDATER_ENABLED } from "./updater"
import { webviewZoom } from "./webview-zoom"
import "./styles.css"
import { Channel } from "@tauri-apps/api/core"
import { commands, ServerReadyData, type InitStep } from "./bindings"
import { commands, type InitStep } from "./bindings"
import { createMenu } from "./menu"
const root = document.getElementById("root")
@@ -348,12 +347,13 @@ const createPlatform = (): Platform => {
await commands.setWslConfig({ enabled })
},
getDefaultServerUrl: async () => {
const result = await commands.getDefaultServerUrl().catch(() => null)
return result
getDefaultServer: async () => {
const url = await commands.getDefaultServerUrl().catch(() => null)
if (!url) return null
return ServerConnection.Key.make(url)
},
setDefaultServerUrl: async (url: string | null) => {
setDefaultServer: async (url: string | null) => {
await commands.setDefaultServerUrl(url)
},
@@ -412,12 +412,33 @@ void listenForDeepLinks()
render(() => {
const platform = createPlatform()
// Fetch sidecar credentials from Rust (available immediately, before health check)
const [sidecar] = createResource(() => commands.awaitInitialization(new Channel<InitStep>() as any))
const [defaultServer] = createResource(() =>
platform.getDefaultServerUrl?.().then((url) => {
platform.getDefaultServer?.().then((url) => {
if (url) return ServerConnection.key({ type: "http", http: { url } })
}),
)
// Build the sidecar server connection once credentials arrive
const servers = () => {
const data = sidecar()
if (!data) return []
const http = {
url: data.url,
username: data.username ?? undefined,
password: data.password ?? undefined,
}
const server: ServerConnection.Sidecar = {
displayName: t("desktop.server.local"),
type: "sidecar",
variant: "base",
http,
}
return [server] as ServerConnection.Any[]
}
function handleClick(e: MouseEvent) {
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
if (link?.href) {
@@ -426,6 +447,12 @@ render(() => {
}
}
function Inner() {
const cmd = useCommand()
menuTrigger = (id) => cmd.trigger(id)
return null
}
onMount(() => {
document.addEventListener("click", handleClick)
onCleanup(() => {
@@ -436,60 +463,19 @@ render(() => {
return (
<PlatformProvider value={platform}>
<AppBaseProviders>
<ServerGate>
{(data) => {
const http = {
url: data.url,
username: data.username ?? undefined,
password: data.password ?? undefined,
}
const server: ServerConnection.Any = data.is_sidecar
? {
displayName: t("desktop.server.local"),
type: "sidecar",
variant: "base",
http,
}
: { type: "http", http }
function Inner() {
const cmd = useCommand()
menuTrigger = (id) => cmd.trigger(id)
return null
}
<Show when={!defaultServer.loading && !sidecar.loading}>
{(_) => {
return (
<Show when={!defaultServer.loading}>
<AppInterface defaultServer={defaultServer.latest ?? ServerConnection.key(server)} servers={[server]}>
<Inner />
</AppInterface>
</Show>
<AppInterface
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
servers={servers()}
>
<Inner />
</AppInterface>
)
}}
</ServerGate>
</Show>
</AppBaseProviders>
</PlatformProvider>
)
}, root!)
// Gate component that waits for the server to be ready
function ServerGate(props: { children: (data: ServerReadyData) => JSX.Element }) {
const [serverData] = createResource(() => commands.awaitInitialization(new Channel<InitStep>() as any))
if (serverData.state === "errored") throw serverData.error
return (
<Show
when={serverData.state !== "pending" && serverData()}
fallback={
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
<div data-tauri-decorum-tb class="flex flex-row absolute top-0 right-0 z-10 h-10" />
</div>
}
>
{(data) => props.children(data())}
</Show>
)
}

View File

@@ -192,11 +192,17 @@ export class AccountService extends ServiceMap.Service<
const orgsByAccount = Effect.fn("AccountService.orgsByAccount")(function* () {
const accounts = yield* repo.list()
return yield* Effect.forEach(
const [errors, results] = yield* Effect.partition(
accounts,
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
{ concurrency: 3 },
)
for (const error of errors) {
yield* Effect.logWarning("failed to fetch orgs for account").pipe(
Effect.annotateLogs({ error: String(error) }),
)
}
return results
})
const orgs = Effect.fn("AccountService.orgs")(function* (accountID: AccountID) {

View File

@@ -114,6 +114,7 @@ export namespace LSP {
return {
process: spawn(item.command[0], item.command.slice(1), {
cwd: root,
windowsHide: true,
env: {
...process.env,
...item.env,

View File

@@ -1,4 +1,4 @@
import { spawn, type ChildProcessWithoutNullStreams } from "child_process"
import { spawn as launch, type ChildProcessWithoutNullStreams } from "child_process"
import path from "path"
import os from "os"
import { Global } from "../global"
@@ -14,6 +14,11 @@ import { Process } from "../util/process"
import { which } from "../util/which"
import { Module } from "@opencode-ai/util/module"
const spawn = ((cmd, args, opts) => {
if (Array.isArray(args)) return launch(cmd, [...args], { ...(opts ?? {}), windowsHide: true })
return launch(cmd, { ...(args ?? {}), windowsHide: true })
}) as typeof launch
export namespace LSPServer {
const log = Log.create({ service: "lsp.server" })
const pathExists = async (p: string) =>

View File

@@ -40,14 +40,6 @@ export namespace ProviderError {
return /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message)
}
function error(providerID: string, error: APICallError) {
if (providerID.includes("github-copilot") && error.statusCode === 403) {
return "Please reauthenticate with the copilot provider to ensure your credentials work properly with OpenCode."
}
return error.message
}
function message(providerID: string, e: APICallError) {
return iife(() => {
const msg = e.message
@@ -60,10 +52,6 @@ export namespace ProviderError {
return "Unknown error"
}
const transformed = error(providerID, e)
if (transformed !== msg) {
return transformed
}
if (!e.responseBody || (e.statusCode && msg !== STATUS_CODES[e.statusCode])) {
return msg
}

View File

@@ -67,7 +67,11 @@ export namespace Provider {
const project =
options["project"] ?? Env.get("GOOGLE_CLOUD_PROJECT") ?? Env.get("GCP_PROJECT") ?? Env.get("GCLOUD_PROJECT")
const location =
options["location"] ?? Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "us-central1"
options["location"] ??
Env.get("GOOGLE_VERTEX_LOCATION") ??
Env.get("GOOGLE_CLOUD_LOCATION") ??
Env.get("VERTEX_LOCATION") ??
"us-central1"
const endpoint = location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`
return {
@@ -437,7 +441,11 @@ export namespace Provider {
Env.get("GCLOUD_PROJECT")
const location =
provider.options?.location ?? Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "us-central1"
provider.options?.location ??
Env.get("GOOGLE_VERTEX_LOCATION") ??
Env.get("GOOGLE_CLOUD_LOCATION") ??
Env.get("VERTEX_LOCATION") ??
"us-central1"
const autoload = Boolean(project)
if (!autoload) return { autoload: false }

View File

@@ -1629,6 +1629,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const proc = spawn(shell, args, {
cwd,
detached: process.platform !== "win32",
windowsHide: process.platform === "win32",
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,

View File

@@ -15,7 +15,10 @@ export namespace Shell {
if (process.platform === "win32") {
await new Promise<void>((resolve) => {
const killer = spawn("taskkill", ["/pid", String(pid), "/f", "/t"], { stdio: "ignore" })
const killer = spawn("taskkill", ["/pid", String(pid), "/f", "/t"], {
stdio: "ignore",
windowsHide: true,
})
killer.once("exit", () => resolve())
killer.once("error", () => resolve())
})

View File

@@ -173,6 +173,7 @@ export const BashTool = Tool.define("bash", async () => {
},
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
windowsHide: process.platform === "win32",
})
let output = ""

View File

@@ -60,6 +60,7 @@ export namespace Process {
cwd: opts.cwd,
env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined,
stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"],
windowsHide: process.platform === "win32",
})
let closed = false

View File

@@ -842,35 +842,6 @@ describe("session.message-v2.fromError", () => {
})
})
test("maps github-copilot 403 to reauth guidance", () => {
const error = new APICallError({
message: "forbidden",
url: "https://api.githubcopilot.com/v1/chat/completions",
requestBodyValues: {},
statusCode: 403,
responseHeaders: { "content-type": "application/json" },
responseBody: '{"error":"forbidden"}',
isRetryable: false,
})
const result = MessageV2.fromError(error, { providerID: "github-copilot" })
expect(result).toStrictEqual({
name: "APIError",
data: {
message:
"Please reauthenticate with the copilot provider to ensure your credentials work properly with OpenCode.",
statusCode: 403,
isRetryable: false,
responseHeaders: { "content-type": "application/json" },
responseBody: '{"error":"forbidden"}',
metadata: {
url: "https://api.githubcopilot.com/v1/chat/completions",
},
},
})
})
test("detects context overflow from APICallError provider messages", () => {
const cases = [
"prompt is too long: 213462 tokens > 200000 maximum",