From 34f43fff89d14c72aada06e0dc1d9228ec4d667d Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 24 Mar 2026 01:00:17 -0400 Subject: [PATCH 01/48] sync --- packages/console/app/src/routes/zen/util/handler.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 9dbadf1eef..ab075a24d8 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -340,6 +340,13 @@ export async function handler( "error.message": error.message, "error.cause": error.cause?.toString(), }) + if (error.message.startsWith("Failed query")) { + try { + logger.metric({ + "error.cause2": JSON.stringify(error.cause), + }) + } catch (e) {} + } // Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message. if ( From d3debc191f116f8ed005bf0c3ddc8776546d53cd Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 24 Mar 2026 10:00:19 +0100 Subject: [PATCH 02/48] manually lock/unlock theme mode (#18905) --- packages/opencode/src/cli/cmd/tui/app.tsx | 14 ++++++- .../src/cli/cmd/tui/context/theme.tsx | 39 ++++++++++++++++--- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index dc052c4d2e..549c7c34a7 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -212,7 +212,7 @@ function App() { const command = useCommandDialog() const sdk = useSDK() const toast = useToast() - const { theme, mode, setMode } = useTheme() + const { theme, mode, setMode, locked, lock, unlock } = useTheme() const sync = useSync() const exit = useExit() const promptRef = usePromptRef() @@ -557,7 +557,7 @@ function App() { category: "System", }, { - title: "Toggle appearance", + title: "Toggle Theme Mode", value: "theme.switch_mode", onSelect: (dialog) => { setMode(mode() === "dark" ? "light" : "dark") @@ -565,6 +565,16 @@ function App() { }, category: "System", }, + { + title: locked() ? "Unlock Theme Mode" : "Lock Theme Mode", + value: "theme.mode.lock", + onSelect: (dialog) => { + if (locked()) unlock() + else lock() + dialog.clear() + }, + category: "System", + }, { title: "Help", value: "help.show", diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index d786e0b491..a3d268afd3 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -283,9 +283,15 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ const renderer = useRenderer() const config = useTuiConfig() const kv = useKV() + const pick = (value: unknown) => { + if (value === "dark" || value === "light") return value + return + } + const lock = pick(kv.get("theme_mode_lock")) const [store, setStore] = createStore({ themes: DEFAULT_THEMES, - mode: kv.get("theme_mode", props.mode), + mode: lock ?? pick(kv.get("theme_mode", props.mode)) ?? props.mode, + lock, active: (config.theme ?? kv.get("theme", "opencode")) as string, ready: false, }) @@ -345,16 +351,30 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ }) } - function update(mode: "dark" | "light") { + function apply(mode: "dark" | "light") { + kv.set("theme_mode", mode) if (store.mode === mode) return setStore("mode", mode) - kv.set("theme_mode", mode) renderer.clearPaletteCache() resolveSystemTheme(mode) } + function pin(mode: "dark" | "light" = store.mode) { + setStore("lock", mode) + kv.set("theme_mode_lock", mode) + apply(mode) + } + + function free() { + setStore("lock", undefined) + kv.set("theme_mode_lock", undefined) + const mode = renderer.themeMode + if (mode) apply(mode) + } + const handle = (mode: "dark" | "light") => { - update(mode) + if (store.lock) return + apply(mode) } renderer.on(CliRenderEvents.THEME_MODE, handle) onCleanup(() => { @@ -390,8 +410,17 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ mode() { return store.mode }, + locked() { + return store.lock !== undefined + }, + lock() { + pin(store.mode) + }, + unlock() { + free() + }, setMode(mode: "dark" | "light") { - update(mode) + pin(mode) }, set(theme: string) { setStore("active", theme) From fde201c286a83ff32dda9b41d61d734a4449fe70 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Tue, 24 Mar 2026 16:46:16 +0530 Subject: [PATCH 03/48] fix(app): stop terminal autofocus on shortcuts (#18931) --- packages/app/src/pages/session.tsx | 10 +++++++-- .../app/src/pages/session/helpers.test.ts | 21 +++++++++++++++++++ packages/app/src/pages/session/helpers.ts | 7 +++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 428826f6ad..19dcba58ee 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -41,7 +41,13 @@ import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit" import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer" -import { createOpenReviewFile, createSessionTabs, createSizing, focusTerminalById } from "@/pages/session/helpers" +import { + createOpenReviewFile, + createSessionTabs, + createSizing, + focusTerminalById, + shouldFocusTerminalOnKeyDown, +} from "@/pages/session/helpers" import { MessageTimeline } from "@/pages/session/message-timeline" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" import { useSessionLayout } from "@/pages/session/session-layout" @@ -850,7 +856,7 @@ export default function Page() { // Prefer the open terminal over the composer when it can take focus if (view().terminal.opened()) { const id = terminal.active() - if (id && focusTerminalById(id)) return + if (id && shouldFocusTerminalOnKeyDown(event) && focusTerminalById(id)) return } // Only treat explicit scroll keys as potential "user scroll" gestures. diff --git a/packages/app/src/pages/session/helpers.test.ts b/packages/app/src/pages/session/helpers.test.ts index 047946fc1e..95f7cd384d 100644 --- a/packages/app/src/pages/session/helpers.test.ts +++ b/packages/app/src/pages/session/helpers.test.ts @@ -7,6 +7,7 @@ import { createSessionTabs, focusTerminalById, getTabReorderIndex, + shouldFocusTerminalOnKeyDown, } from "./helpers" describe("createOpenReviewFile", () => { @@ -86,6 +87,26 @@ describe("focusTerminalById", () => { }) }) +describe("shouldFocusTerminalOnKeyDown", () => { + test("skips pure modifier keys", () => { + expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Meta", metaKey: true }))).toBe(false) + expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Control", ctrlKey: true }))).toBe(false) + expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Alt", altKey: true }))).toBe(false) + expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Shift", shiftKey: true }))).toBe(false) + }) + + test("skips shortcut key combos", () => { + expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "c", metaKey: true }))).toBe(false) + expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "c", ctrlKey: true }))).toBe(false) + expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true }))).toBe(false) + }) + + test("keeps plain typing focused on terminal", () => { + expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "a" }))).toBe(true) + expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "A", shiftKey: true }))).toBe(true) + }) +}) + describe("getTabReorderIndex", () => { test("returns target index for valid drag reorder", () => { expect(getTabReorderIndex(["a", "b", "c"], "a", "c")).toBe(2) diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index c3571f3ffc..7e2c1ccf7b 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -93,6 +93,13 @@ export const focusTerminalById = (id: string) => { return true } +const skip = new Set(["Alt", "Control", "Meta", "Shift"]) + +export const shouldFocusTerminalOnKeyDown = (event: Pick) => { + if (skip.has(event.key)) return false + return !(event.ctrlKey || event.metaKey || event.altKey) +} + export const createOpenReviewFile = (input: { showAllFiles: () => void tabForPath: (path: string) => string From 431e0586add85c108ceadc0366a08ee09b862ecc Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 24 Mar 2026 14:01:25 +0000 Subject: [PATCH 04/48] fix(app): filter non-renderable part types from browser store (#18926) --- packages/app/src/context/global-sync/event-reducer.ts | 3 +++ packages/app/src/context/sync.tsx | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index b8eda0573f..5d8b7c4e3d 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -15,6 +15,8 @@ import type { State, VcsCache } from "./types" import { trimSessions } from "./session-trim" import { dropSessionCaches } from "./session-cache" +const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) + export function applyGlobalEvent(input: { event: { type: string; properties?: unknown } project: Project[] @@ -211,6 +213,7 @@ export function applyDirectoryEvent(input: { } case "message.part.updated": { const part = (event.properties as { part: Part }).part + if (SKIP_PARTS.has(part.type)) break const parts = input.store.part[part.messageID] if (!parts) { input.setStore("part", part.messageID, [part]) diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 0f20087234..66b889e2ad 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -14,6 +14,8 @@ import { useSDK } from "./sdk" import type { Message, Part } from "@opencode-ai/sdk/v2/client" import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache" +const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) + function sortParts(parts: Part[]) { return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)) } @@ -336,7 +338,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ batch(() => { input.setStore("message", input.sessionID, reconcile(message, { key: "id" })) for (const p of next.part) { - input.setStore("part", p.id, p.part) + const filtered = p.part.filter((x) => !SKIP_PARTS.has(x.type)) + if (filtered.length) input.setStore("part", p.id, filtered) } setMeta("limit", key, message.length) setMeta("cursor", key, next.cursor) From 3f1a4abe6dc72b4d24b916436d3dd95393aeb650 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 24 Mar 2026 14:01:58 +0000 Subject: [PATCH 05/48] fix(app): use optional chaining for model.current() in ProviderIcon (#18927) --- packages/app/src/components/prompt-input.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 34f83b13e2..f523671ec9 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1497,7 +1497,7 @@ export const PromptInput: Component = (props) => { > @@ -1529,7 +1529,7 @@ export const PromptInput: Component = (props) => { > From c9c93eac00bda356f4cf2b03e011d0b19e535952 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 24 Mar 2026 14:02:22 +0000 Subject: [PATCH 06/48] fix(ui): eliminate N+1 reactive subscriptions in SessionTurn (#18924) --- .../src/pages/session/message-timeline.tsx | 11 ++++- packages/ui/src/components/session-turn.tsx | 49 +++++++++---------- 2 files changed, 34 insertions(+), 26 deletions(-) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index fe61f16854..5fef41a550 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -923,7 +923,15 @@ export function MessageTimeline(props: { {(messageID) => { const active = createMemo(() => activeMessageID() === messageID) const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], { - equals: (a, b) => JSON.stringify(a) === JSON.stringify(b), + equals: (a, b) => + a.length === b.length && + a.every( + (c, i) => + c.path === b[i].path && + c.comment === b[i].comment && + c.selection?.startLine === b[i].selection?.startLine && + c.selection?.endLine === b[i].selection?.endLine, + ), }) const commentCount = createMemo(() => comments().length) return ( @@ -979,6 +987,7 @@ export function MessageTimeline(props: { list(data.store.message?.[props.sessionID], emptyMessages)) + const allMessages = createMemo(() => props.messages ?? list(data.store.message?.[props.sessionID], emptyMessages)) const messageIndex = createMemo(() => { const messages = allMessages() ?? emptyMessages @@ -340,30 +341,28 @@ export function SessionTurn( if (end < start) return undefined return end - start }) - const assistantVisible = createMemo(() => - assistantMessages().reduce((count, message) => { - const parts = list(data.store.part?.[message.id], emptyParts) - return count + parts.filter((part) => partState(part, showReasoningSummaries()) === "visible").length - }, 0), - ) - const assistantTailVisible = createMemo(() => - assistantMessages() - .flatMap((message) => list(data.store.part?.[message.id], emptyParts)) - .flatMap((part) => { - if (partState(part, showReasoningSummaries()) !== "visible") return [] - if (part.type === "text") return ["text" as const] - return ["other" as const] - }) - .at(-1), - ) - const reasoningHeading = createMemo(() => - assistantMessages() - .flatMap((message) => list(data.store.part?.[message.id], emptyParts)) - .filter((part): part is PartType & { type: "reasoning"; text: string } => part.type === "reasoning") - .map((part) => heading(part.text)) - .filter((text): text is string => !!text) - .at(-1), - ) + const assistantDerived = createMemo(() => { + let visible = 0 + let tail: "text" | "other" | undefined + let reason: string | undefined + const show = showReasoningSummaries() + for (const message of assistantMessages()) { + for (const part of list(data.store.part?.[message.id], emptyParts)) { + if (partState(part, show) === "visible") { + visible++ + tail = part.type === "text" ? "text" : "other" + } + if (part.type === "reasoning" && part.text) { + const h = heading(part.text) + if (h) reason = h + } + } + } + return { visible, tail, reason } + }) + const assistantVisible = createMemo(() => assistantDerived().visible) + const assistantTailVisible = createMemo(() => assistantDerived().tail) + const reasoningHeading = createMemo(() => assistantDerived().reason) const showThinking = createMemo(() => { if (!working() || !!error()) return false if (status().type === "retry") return false From 546748a461539ca63e188ee07ab2b143c5ac2c83 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 24 Mar 2026 09:10:24 -0500 Subject: [PATCH 07/48] fix(app): startup efficiency (#18854) --- packages/app/src/app.tsx | 8 +- .../components/dialog-connect-provider.tsx | 50 ++- .../app/src/components/settings-general.tsx | 50 ++- .../app/src/components/status-popover.tsx | 23 +- packages/app/src/components/terminal.tsx | 5 +- packages/app/src/components/titlebar.tsx | 2 +- packages/app/src/context/global-sync.tsx | 58 ++-- .../app/src/context/global-sync/bootstrap.ts | 189 +++++------ packages/app/src/context/language.tsx | 144 ++++----- packages/app/src/context/notification.tsx | 6 +- packages/app/src/context/settings.tsx | 8 + packages/app/src/context/terminal-title.ts | 51 +-- packages/app/src/entry.tsx | 11 +- packages/app/src/hooks/use-providers.ts | 2 +- packages/app/src/index.ts | 1 + packages/app/src/pages/directory-layout.tsx | 70 ++--- packages/app/src/pages/layout.tsx | 62 ++-- packages/app/src/utils/server-health.ts | 24 +- packages/app/src/utils/sound.ts | 177 +++++------ .../desktop-electron/src/renderer/index.tsx | 20 +- packages/desktop/src/index.tsx | 19 +- packages/ui/package.json | 1 + .../icons/provider/alibaba-coding-plan-cn.svg | 3 + .../icons/provider/alibaba-coding-plan.svg | 3 + .../ui/src/assets/icons/provider/clarifai.svg | 24 ++ .../src/assets/icons/provider/dinference.svg | 1 + .../ui/src/assets/icons/provider/drun.svg | 8 + .../icons/provider/perplexity-agent.svg | 3 + .../icons/provider/tencent-coding-plan.svg | 5 + .../ui/src/assets/icons/provider/zenmux.svg | 5 +- packages/ui/src/components/font.tsx | 119 +------ packages/ui/src/font-loader.ts | 133 ++++++++ packages/ui/src/theme/context.tsx | 294 +++++++++++++----- 33 files changed, 943 insertions(+), 636 deletions(-) create mode 100644 packages/ui/src/assets/icons/provider/alibaba-coding-plan-cn.svg create mode 100644 packages/ui/src/assets/icons/provider/alibaba-coding-plan.svg create mode 100644 packages/ui/src/assets/icons/provider/clarifai.svg create mode 100644 packages/ui/src/assets/icons/provider/dinference.svg create mode 100644 packages/ui/src/assets/icons/provider/drun.svg create mode 100644 packages/ui/src/assets/icons/provider/perplexity-agent.svg create mode 100644 packages/ui/src/assets/icons/provider/tencent-coding-plan.svg create mode 100644 packages/ui/src/font-loader.ts diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 5247c951d3..0eb5b4e9e0 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -6,7 +6,7 @@ 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 { ThemeProvider } from "@opencode-ai/ui/theme/context" import { MetaProvider } from "@solidjs/meta" import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" @@ -32,7 +32,7 @@ import { FileProvider } from "@/context/file" import { GlobalSDKProvider } from "@/context/global-sdk" import { GlobalSyncProvider } from "@/context/global-sync" import { HighlightsProvider } from "@/context/highlights" -import { LanguageProvider, useLanguage } from "@/context/language" +import { LanguageProvider, type Locale, useLanguage } from "@/context/language" import { LayoutProvider } from "@/context/layout" import { ModelsProvider } from "@/context/models" import { NotificationProvider } from "@/context/notification" @@ -130,7 +130,7 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { ) } -export function AppBaseProviders(props: ParentProps) { +export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { return ( @@ -139,7 +139,7 @@ export function AppBaseProviders(props: ParentProps) { void window.api?.setTitlebar?.({ mode }) }} > - + }> diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 734958dd58..e7eaa1fb29 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -1,4 +1,4 @@ -import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client" +import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client" import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" @@ -9,7 +9,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Spinner } from "@opencode-ai/ui/spinner" import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@opencode-ai/ui/toast" -import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js" +import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js" import { createStore, produce } from "solid-js/store" import { Link } from "@/components/link" import { useGlobalSDK } from "@/context/global-sdk" @@ -34,15 +34,25 @@ export function DialogConnectProvider(props: { provider: string }) { }) const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!) - const methods = createMemo( - () => - globalSync.data.provider_auth[props.provider] ?? [ - { - type: "api", - label: language.t("provider.connect.method.apiKey"), - }, - ], + const fallback = createMemo(() => [ + { + type: "api" as const, + label: language.t("provider.connect.method.apiKey"), + }, + ]) + const [auth] = createResource( + () => props.provider, + async () => { + const cached = globalSync.data.provider_auth[props.provider] + if (cached) return cached + const res = await globalSDK.client.provider.auth() + if (!alive.value) return fallback() + globalSync.set("provider_auth", res.data ?? {}) + return res.data?.[props.provider] ?? fallback() + }, ) + const loading = createMemo(() => auth.loading && !globalSync.data.provider_auth[props.provider]) + const methods = createMemo(() => auth.latest ?? globalSync.data.provider_auth[props.provider] ?? fallback()) const [store, setStore] = createStore({ methodIndex: undefined as undefined | number, authorization: undefined as undefined | ProviderAuthAuthorization, @@ -177,7 +187,11 @@ export function DialogConnectProvider(props: { provider: string }) { index: 0, }) - const prompts = createMemo(() => method()?.prompts ?? []) + const prompts = createMemo>(() => { + const value = method() + if (value?.type !== "oauth") return [] + return value.prompts ?? [] + }) const matches = (prompt: NonNullable[number]>, value: Record) => { if (!prompt.when) return true const actual = value[prompt.when.key] @@ -296,8 +310,12 @@ export function DialogConnectProvider(props: { provider: string }) { listRef?.onKeyDown(e) } - onMount(() => { + let auto = false + createEffect(() => { + if (auto) return + if (loading()) return if (methods().length === 1) { + auto = true selectMethod(0) } }) @@ -573,6 +591,14 @@ export function DialogConnectProvider(props: { provider: string }) {
+ +
+
+ + {language.t("provider.connect.status.inProgress")} +
+
+
diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index b768bafcca..f4b8198e7e 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -1,27 +1,41 @@ -import { Component, Show, createMemo, createResource, type JSX } from "solid-js" +import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { Select } from "@opencode-ai/ui/select" import { Switch } from "@opencode-ai/ui/switch" import { Tooltip } from "@opencode-ai/ui/tooltip" -import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" +import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { showToast } from "@opencode-ai/ui/toast" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSettings, monoFontFamily } from "@/context/settings" -import { playSound, SOUND_OPTIONS } from "@/utils/sound" +import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" import { Link } from "./link" import { SettingsList } from "./settings-list" let demoSoundState = { cleanup: undefined as (() => void) | undefined, timeout: undefined as NodeJS.Timeout | undefined, + run: 0, +} + +type ThemeOption = { + id: string + name: string +} + +let font: Promise | undefined + +function loadFont() { + font ??= import("@opencode-ai/ui/font-loader") + return font } // To prevent audio from overlapping/playing very quickly when navigating the settings menus, // delay the playback by 100ms during quick selection changes and pause existing sounds. const stopDemoSound = () => { + demoSoundState.run += 1 if (demoSoundState.cleanup) { demoSoundState.cleanup() } @@ -29,12 +43,19 @@ const stopDemoSound = () => { demoSoundState.cleanup = undefined } -const playDemoSound = (src: string | undefined) => { +const playDemoSound = (id: string | undefined) => { stopDemoSound() - if (!src) return + if (!id) return + const run = ++demoSoundState.run demoSoundState.timeout = setTimeout(() => { - demoSoundState.cleanup = playSound(src) + void playSoundById(id).then((cleanup) => { + if (demoSoundState.run !== run) { + cleanup?.() + return + } + demoSoundState.cleanup = cleanup + }) }, 100) } @@ -44,6 +65,10 @@ export const SettingsGeneral: Component = () => { const platform = usePlatform() const settings = useSettings() + onMount(() => { + void theme.loadThemes() + }) + const [store, setStore] = createStore({ checking: false, }) @@ -104,9 +129,7 @@ export const SettingsGeneral: Component = () => { .finally(() => setStore("checking", false)) } - const themeOptions = createMemo(() => - Object.entries(theme.themes()).map(([id, def]) => ({ id, name: def.name ?? id })), - ) + const themeOptions = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [ { value: "system", label: language.t("theme.scheme.system") }, @@ -143,7 +166,7 @@ export const SettingsGeneral: Component = () => { ] as const const fontOptionsList = [...fontOptions] - const noneSound = { id: "none", label: "sound.option.none", src: undefined } as const + const noneSound = { id: "none", label: "sound.option.none" } as const const soundOptions = [noneSound, ...SOUND_OPTIONS] const soundSelectProps = ( @@ -158,7 +181,7 @@ export const SettingsGeneral: Component = () => { label: (o: (typeof soundOptions)[number]) => language.t(o.label), onHighlight: (option: (typeof soundOptions)[number] | undefined) => { if (!option) return - playDemoSound(option.src) + playDemoSound(option.id === "none" ? undefined : option.id) }, onSelect: (option: (typeof soundOptions)[number] | undefined) => { if (!option) return @@ -169,7 +192,7 @@ export const SettingsGeneral: Component = () => { } setEnabled(true) set(option.id) - playDemoSound(option.src) + playDemoSound(option.id) }, variant: "secondary" as const, size: "small" as const, @@ -321,6 +344,9 @@ export const SettingsGeneral: Component = () => { current={fontOptionsList.find((o) => o.value === settings.appearance.font())} value={(o) => o.value} label={(o) => language.t(o.label)} + onHighlight={(option) => { + void loadFont().then((x) => x.ensureMonoFont(option?.value)) + }} onSelect={(option) => option && settings.appearance.setFont(option.value)} variant="secondary" size="small" diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 464522443f..8d5ecac39a 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -16,7 +16,6 @@ import { useSDK } from "@/context/sdk" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { useSync } from "@/context/sync" import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health" -import { DialogSelectServer } from "./dialog-select-server" const pollMs = 10_000 @@ -54,11 +53,15 @@ const listServersByHealth = ( }) } -const useServerHealth = (servers: Accessor) => { +const useServerHealth = (servers: Accessor, enabled: Accessor) => { const checkServerHealth = useCheckServerHealth() const [status, setStatus] = createStore({} as Record) createEffect(() => { + if (!enabled()) { + setStatus(reconcile({})) + return + } const list = servers() let dead = false @@ -162,6 +165,12 @@ export function StatusPopover() { const navigate = useNavigate() const [shown, setShown] = createSignal(false) + let dialogRun = 0 + let dialogDead = false + onCleanup(() => { + dialogDead = true + dialogRun += 1 + }) const servers = createMemo(() => { const current = server.current const list = server.list @@ -169,7 +178,7 @@ 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) + const health = useServerHealth(servers, shown) const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health)) const toggleMcp = useMcpToggleMutation() const defaultServer = useDefaultServerKey(platform.getDefaultServer) @@ -300,7 +309,13 @@ export function StatusPopover() { diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index aed46f1262..0a5a7d2d3e 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -1,4 +1,7 @@ -import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme" +import { withAlpha } from "@opencode-ai/ui/theme/color" +import { useTheme } from "@opencode-ai/ui/theme/context" +import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve" +import type { HexColor } from "@opencode-ai/ui/theme/types" import { showToast } from "@opencode-ai/ui/toast" import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web" import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js" diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 77de1a73ce..0a41f31196 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -5,7 +5,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Icon } from "@opencode-ai/ui/icon" import { Button } from "@opencode-ai/ui/button" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" -import { useTheme } from "@opencode-ai/ui/theme" +import { useTheme } from "@opencode-ai/ui/theme/context" import { useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 2d1e501353..cbd08e99f5 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -9,17 +9,7 @@ import type { } from "@opencode-ai/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" import { getFilename } from "@opencode-ai/util/path" -import { - createContext, - getOwner, - Match, - onCleanup, - onMount, - type ParentProps, - Switch, - untrack, - useContext, -} from "solid-js" +import { 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" @@ -80,6 +70,8 @@ function createGlobalSync() { let active = true let projectWritten = false + let bootedAt = 0 + let bootingRoot = false onCleanup(() => { active = false @@ -258,6 +250,11 @@ function createGlobalSync() { const sdk = sdkFor(directory) await bootstrapDirectory({ directory, + global: { + config: globalStore.config, + project: globalStore.project, + provider: globalStore.provider, + }, sdk, store: child[0], setStore: child[1], @@ -278,15 +275,20 @@ function createGlobalSync() { const unsub = globalSDK.event.listen((e) => { const directory = e.name const event = e.details + const recent = bootingRoot || Date.now() - bootedAt < 1500 if (directory === "global") { applyGlobalEvent({ event, project: globalStore.project, - refresh: queue.refresh, + refresh: () => { + if (recent) return + queue.refresh() + }, setGlobalProject: setProjects, }) if (event.type === "server.connected" || event.type === "global.disposed") { + if (recent) return for (const directory of Object.keys(children.children)) { queue.push(directory) } @@ -325,17 +327,19 @@ function createGlobalSync() { }) async function bootstrap() { - await bootstrapGlobal({ - globalSDK: globalSDK.client, - connectErrorTitle: language.t("dialog.server.add.error"), - connectErrorDescription: language.t("error.globalSync.connectFailed", { - url: globalSDK.url, - }), - requestFailedTitle: language.t("common.requestFailed"), - translate: language.t, - formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), - setGlobalStore: setBootStore, - }) + bootingRoot = true + try { + await bootstrapGlobal({ + globalSDK: globalSDK.client, + requestFailedTitle: language.t("common.requestFailed"), + translate: language.t, + formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), + setGlobalStore: setBootStore, + }) + bootedAt = Date.now() + } finally { + bootingRoot = false + } } onMount(() => { @@ -392,13 +396,7 @@ const GlobalSyncContext = createContext>() export function GlobalSyncProvider(props: ParentProps) { const value = createGlobalSync() - return ( - - - {props.children} - - - ) + return {props.children} } export function useGlobalSync() { diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 13494b7ade..c795ab471c 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -33,27 +33,11 @@ type GlobalStore = { export async function bootstrapGlobal(input: { globalSDK: OpencodeClient - connectErrorTitle: string - connectErrorDescription: string requestFailedTitle: string translate: (key: string, vars?: Record) => string formatMoreCount: (count: number) => string setGlobalStore: SetStoreFunction }) { - const health = await input.globalSDK.global - .health() - .then((x) => x.data) - .catch(() => undefined) - if (!health?.healthy) { - showToast({ - variant: "error", - title: input.connectErrorTitle, - description: input.connectErrorDescription, - }) - input.setGlobalStore("ready", true) - return - } - const tasks = [ retry(() => input.globalSDK.path.get().then((x) => { @@ -80,11 +64,6 @@ export async function bootstrapGlobal(input: { input.setGlobalStore("provider", normalizeProviderList(x.data!)) }), ), - retry(() => - input.globalSDK.provider.auth().then((x) => { - input.setGlobalStore("provider_auth", x.data ?? {}) - }), - ), ] const results = await Promise.allSettled(tasks) @@ -111,6 +90,10 @@ function groupBySession(input: T[]) }, {}) } +function projectID(directory: string, projects: Project[]) { + return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id +} + export async function bootstrapDirectory(input: { directory: string sdk: OpencodeClient @@ -119,88 +102,112 @@ export async function bootstrapDirectory(input: { vcsCache: VcsCache loadSessions: (directory: string) => Promise | void translate: (key: string, vars?: Record) => string + global: { + config: Config + project: Project[] + provider: ProviderListResponse + } }) { - if (input.store.status !== "complete") input.setStore("status", "loading") + const loading = input.store.status !== "complete" + const seededProject = projectID(input.directory, input.global.project) + if (seededProject) input.setStore("project", seededProject) + if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) { + 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) + } + if (loading) input.setStore("status", "partial") - const blockingRequests = { - project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)), - provider: () => + const results = await Promise.allSettled([ + seededProject + ? Promise.resolve() + : retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)), + retry(() => input.sdk.provider.list().then((x) => { input.setStore("provider", normalizeProviderList(x.data!)) }), - agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])), - config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)), - } + ), + retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? []))), + retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))), + retry(() => + input.sdk.path.get().then((x) => { + input.setStore("path", x.data!) + const next = projectID(x.data?.directory ?? input.directory, input.global.project) + if (next) input.setStore("project", next) + }), + ), + retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))), + retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))), + input.loadSessions(input.directory), + retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))), + retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))), + retry(() => + input.sdk.vcs.get().then((x) => { + const next = x.data ?? input.store.vcs + input.setStore("vcs", next) + if (next?.branch) input.vcsCache.setStore("value", next) + }), + ), + retry(() => + input.sdk.permission.list().then((x) => { + const grouped = groupBySession( + (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), + ) + batch(() => { + for (const sessionID of Object.keys(input.store.permission)) { + if (grouped[sessionID]) continue + input.setStore("permission", sessionID, []) + } + for (const [sessionID, permissions] of Object.entries(grouped)) { + input.setStore( + "permission", + sessionID, + reconcile( + permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ), + ) + } + }) + }), + ), + retry(() => + input.sdk.question.list().then((x) => { + const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) + batch(() => { + for (const sessionID of Object.keys(input.store.question)) { + if (grouped[sessionID]) continue + input.setStore("question", sessionID, []) + } + for (const [sessionID, questions] of Object.entries(grouped)) { + input.setStore( + "question", + sessionID, + reconcile( + questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ), + ) + } + }) + }), + ), + ]) - try { - await Promise.all(Object.values(blockingRequests).map((p) => retry(p))) - } catch (err) { - console.error("Failed to bootstrap instance", err) + const errors = results + .filter((item): item is PromiseRejectedResult => item.status === "rejected") + .map((item) => item.reason) + if (errors.length > 0) { + console.error("Failed to bootstrap instance", errors[0]) const project = getFilename(input.directory) showToast({ variant: "error", title: input.translate("toast.project.reloadFailed.title", { project }), - description: formatServerError(err, input.translate), + description: formatServerError(errors[0], input.translate), }) - input.setStore("status", "partial") return } - if (input.store.status !== "complete") input.setStore("status", "partial") - - Promise.all([ - input.sdk.path.get().then((x) => input.setStore("path", x.data!)), - input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])), - input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)), - input.loadSessions(input.directory), - input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)), - input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)), - input.sdk.vcs.get().then((x) => { - const next = x.data ?? input.store.vcs - input.setStore("vcs", next) - if (next?.branch) input.vcsCache.setStore("value", next) - }), - input.sdk.permission.list().then((x) => { - const grouped = groupBySession( - (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), - ) - batch(() => { - for (const sessionID of Object.keys(input.store.permission)) { - if (grouped[sessionID]) continue - input.setStore("permission", sessionID, []) - } - for (const [sessionID, permissions] of Object.entries(grouped)) { - input.setStore( - "permission", - sessionID, - reconcile( - permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ), - ) - } - }) - }), - input.sdk.question.list().then((x) => { - const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) - batch(() => { - for (const sessionID of Object.keys(input.store.question)) { - if (grouped[sessionID]) continue - input.setStore("question", sessionID, []) - } - for (const [sessionID, questions] of Object.entries(grouped)) { - input.setStore( - "question", - sessionID, - reconcile( - questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ), - ) - } - }) - }), - ]).then(() => { - input.setStore("status", "complete") - }) + if (loading) input.setStore("status", "complete") } diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx index b1edd541c3..51dc09cd7d 100644 --- a/packages/app/src/context/language.tsx +++ b/packages/app/src/context/language.tsx @@ -1,42 +1,10 @@ import * as i18n from "@solid-primitives/i18n" -import { createEffect, createMemo } from "solid-js" +import { createEffect, createMemo, createResource } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { Persist, persisted } from "@/utils/persist" import { dict as en } from "@/i18n/en" -import { dict as zh } from "@/i18n/zh" -import { dict as zht } from "@/i18n/zht" -import { dict as ko } from "@/i18n/ko" -import { dict as de } from "@/i18n/de" -import { dict as es } from "@/i18n/es" -import { dict as fr } from "@/i18n/fr" -import { dict as da } from "@/i18n/da" -import { dict as ja } from "@/i18n/ja" -import { dict as pl } from "@/i18n/pl" -import { dict as ru } from "@/i18n/ru" -import { dict as ar } from "@/i18n/ar" -import { dict as no } from "@/i18n/no" -import { dict as br } from "@/i18n/br" -import { dict as th } from "@/i18n/th" -import { dict as bs } from "@/i18n/bs" -import { dict as tr } from "@/i18n/tr" import { dict as uiEn } from "@opencode-ai/ui/i18n/en" -import { dict as uiZh } from "@opencode-ai/ui/i18n/zh" -import { dict as uiZht } from "@opencode-ai/ui/i18n/zht" -import { dict as uiKo } from "@opencode-ai/ui/i18n/ko" -import { dict as uiDe } from "@opencode-ai/ui/i18n/de" -import { dict as uiEs } from "@opencode-ai/ui/i18n/es" -import { dict as uiFr } from "@opencode-ai/ui/i18n/fr" -import { dict as uiDa } from "@opencode-ai/ui/i18n/da" -import { dict as uiJa } from "@opencode-ai/ui/i18n/ja" -import { dict as uiPl } from "@opencode-ai/ui/i18n/pl" -import { dict as uiRu } from "@opencode-ai/ui/i18n/ru" -import { dict as uiAr } from "@opencode-ai/ui/i18n/ar" -import { dict as uiNo } from "@opencode-ai/ui/i18n/no" -import { dict as uiBr } from "@opencode-ai/ui/i18n/br" -import { dict as uiTh } from "@opencode-ai/ui/i18n/th" -import { dict as uiBs } from "@opencode-ai/ui/i18n/bs" -import { dict as uiTr } from "@opencode-ai/ui/i18n/tr" export type Locale = | "en" @@ -59,6 +27,7 @@ export type Locale = type RawDictionary = typeof en & typeof uiEn type Dictionary = i18n.Flatten +type Source = { dict: Record } function cookie(locale: Locale) { return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax` @@ -125,24 +94,43 @@ const LABEL_KEY: Record = { } const base = i18n.flatten({ ...en, ...uiEn }) -const DICT: Record = { - en: base, - zh: { ...base, ...i18n.flatten({ ...zh, ...uiZh }) }, - zht: { ...base, ...i18n.flatten({ ...zht, ...uiZht }) }, - ko: { ...base, ...i18n.flatten({ ...ko, ...uiKo }) }, - de: { ...base, ...i18n.flatten({ ...de, ...uiDe }) }, - es: { ...base, ...i18n.flatten({ ...es, ...uiEs }) }, - fr: { ...base, ...i18n.flatten({ ...fr, ...uiFr }) }, - da: { ...base, ...i18n.flatten({ ...da, ...uiDa }) }, - ja: { ...base, ...i18n.flatten({ ...ja, ...uiJa }) }, - pl: { ...base, ...i18n.flatten({ ...pl, ...uiPl }) }, - ru: { ...base, ...i18n.flatten({ ...ru, ...uiRu }) }, - ar: { ...base, ...i18n.flatten({ ...ar, ...uiAr }) }, - no: { ...base, ...i18n.flatten({ ...no, ...uiNo }) }, - br: { ...base, ...i18n.flatten({ ...br, ...uiBr }) }, - th: { ...base, ...i18n.flatten({ ...th, ...uiTh }) }, - bs: { ...base, ...i18n.flatten({ ...bs, ...uiBs }) }, - tr: { ...base, ...i18n.flatten({ ...tr, ...uiTr }) }, +const dicts = new Map([["en", base]]) + +const merge = (app: Promise, ui: Promise) => + Promise.all([app, ui]).then(([a, b]) => ({ ...base, ...i18n.flatten({ ...a.dict, ...b.dict }) }) as Dictionary) + +const loaders: Record, () => Promise> = { + zh: () => merge(import("@/i18n/zh"), import("@opencode-ai/ui/i18n/zh")), + zht: () => merge(import("@/i18n/zht"), import("@opencode-ai/ui/i18n/zht")), + ko: () => merge(import("@/i18n/ko"), import("@opencode-ai/ui/i18n/ko")), + de: () => merge(import("@/i18n/de"), import("@opencode-ai/ui/i18n/de")), + es: () => merge(import("@/i18n/es"), import("@opencode-ai/ui/i18n/es")), + fr: () => merge(import("@/i18n/fr"), import("@opencode-ai/ui/i18n/fr")), + da: () => merge(import("@/i18n/da"), import("@opencode-ai/ui/i18n/da")), + ja: () => merge(import("@/i18n/ja"), import("@opencode-ai/ui/i18n/ja")), + pl: () => merge(import("@/i18n/pl"), import("@opencode-ai/ui/i18n/pl")), + ru: () => merge(import("@/i18n/ru"), import("@opencode-ai/ui/i18n/ru")), + ar: () => merge(import("@/i18n/ar"), import("@opencode-ai/ui/i18n/ar")), + no: () => merge(import("@/i18n/no"), import("@opencode-ai/ui/i18n/no")), + br: () => merge(import("@/i18n/br"), import("@opencode-ai/ui/i18n/br")), + th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")), + bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")), + tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")), +} + +function loadDict(locale: Locale) { + const hit = dicts.get(locale) + if (hit) return Promise.resolve(hit) + if (locale === "en") return Promise.resolve(base) + const load = loaders[locale] + return load().then((next: Dictionary) => { + dicts.set(locale, next) + return next + }) +} + +export function loadLocaleDict(locale: Locale) { + return loadDict(locale).then(() => undefined) } const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [ @@ -168,27 +156,6 @@ const localeMatchers: Array<{ locale: Locale; match: (language: string) => boole { locale: "tr", match: (language) => language.startsWith("tr") }, ] -type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen" -const PARITY_CHECK: Record, Record> = { - zh, - zht, - ko, - de, - es, - fr, - da, - ja, - pl, - ru, - ar, - no, - br, - th, - bs, - tr, -} -void PARITY_CHECK - function detectLocale(): Locale { if (typeof navigator !== "object") return "en" @@ -203,27 +170,48 @@ function detectLocale(): Locale { return "en" } -function normalizeLocale(value: string): Locale { +export function normalizeLocale(value: string): Locale { return LOCALES.includes(value as Locale) ? (value as Locale) : "en" } +function readStoredLocale() { + if (typeof localStorage !== "object") return + try { + const raw = localStorage.getItem("opencode.global.dat:language") + if (!raw) return + const next = JSON.parse(raw) as { locale?: string } + if (typeof next?.locale !== "string") return + return normalizeLocale(next.locale) + } catch { + return + } +} + +const warm = readStoredLocale() ?? detectLocale() +if (warm !== "en") void loadDict(warm) + export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({ name: "Language", - init: () => { + init: (props: { locale?: Locale }) => { + const initial = props.locale ?? readStoredLocale() ?? detectLocale() const [store, setStore, _, ready] = persisted( Persist.global("language", ["language.v1"]), createStore({ - locale: detectLocale() as Locale, + locale: initial, }), ) const locale = createMemo(() => normalizeLocale(store.locale)) - console.log("locale", locale()) const intl = createMemo(() => INTL[locale()]) - const dict = createMemo(() => DICT[locale()]) + const [dict] = createResource(locale, loadDict, { + initialValue: dicts.get(initial) ?? base, + }) - const t = i18n.translator(dict, i18n.resolveTemplate) + const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as ( + key: keyof Dictionary, + params?: Record, + ) => string const label = (value: Locale) => t(LABEL_KEY[value]) diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 04bc2fdaaa..281a1ef33d 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -12,7 +12,7 @@ import { base64Encode } from "@opencode-ai/util/encode" import { decode64 } from "@/utils/base64" import { EventSessionError } from "@opencode-ai/sdk/v2" import { Persist, persisted } from "@/utils/persist" -import { playSound, soundSrc } from "@/utils/sound" +import { playSoundById } from "@/utils/sound" type NotificationBase = { directory?: string @@ -234,7 +234,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi if (session.parentID) return if (settings.sounds.agentEnabled()) { - playSound(soundSrc(settings.sounds.agent())) + void playSoundById(settings.sounds.agent()) } append({ @@ -263,7 +263,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi if (session?.parentID) return if (settings.sounds.errorsEnabled()) { - playSound(soundSrc(settings.sounds.errors())) + void playSoundById(settings.sounds.errors()) } const error = "error" in event.properties ? event.properties.error : undefined diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 48788fe8ec..247d36dd36 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -104,6 +104,13 @@ function withFallback(read: () => T | undefined, fallback: T) { return createMemo(() => read() ?? fallback) } +let font: Promise | undefined + +function loadFont() { + font ??= import("@opencode-ai/ui/font-loader") + return font +} + export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({ name: "Settings", init: () => { @@ -111,6 +118,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont createEffect(() => { if (typeof document === "undefined") return + void loadFont().then((x) => x.ensureMonoFont(store.appearance?.font)) document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font)) }) diff --git a/packages/app/src/context/terminal-title.ts b/packages/app/src/context/terminal-title.ts index 3e8fa9af25..c8b18f4211 100644 --- a/packages/app/src/context/terminal-title.ts +++ b/packages/app/src/context/terminal-title.ts @@ -1,45 +1,18 @@ -import { dict as ar } from "@/i18n/ar" -import { dict as br } from "@/i18n/br" -import { dict as bs } from "@/i18n/bs" -import { dict as da } from "@/i18n/da" -import { dict as de } from "@/i18n/de" -import { dict as en } from "@/i18n/en" -import { dict as es } from "@/i18n/es" -import { dict as fr } from "@/i18n/fr" -import { dict as ja } from "@/i18n/ja" -import { dict as ko } from "@/i18n/ko" -import { dict as no } from "@/i18n/no" -import { dict as pl } from "@/i18n/pl" -import { dict as ru } from "@/i18n/ru" -import { dict as th } from "@/i18n/th" -import { dict as tr } from "@/i18n/tr" -import { dict as zh } from "@/i18n/zh" -import { dict as zht } from "@/i18n/zht" +const template = "Terminal {{number}}" -const numbered = Array.from( - new Set([ - en["terminal.title.numbered"], - ar["terminal.title.numbered"], - br["terminal.title.numbered"], - bs["terminal.title.numbered"], - da["terminal.title.numbered"], - de["terminal.title.numbered"], - es["terminal.title.numbered"], - fr["terminal.title.numbered"], - ja["terminal.title.numbered"], - ko["terminal.title.numbered"], - no["terminal.title.numbered"], - pl["terminal.title.numbered"], - ru["terminal.title.numbered"], - th["terminal.title.numbered"], - tr["terminal.title.numbered"], - zh["terminal.title.numbered"], - zht["terminal.title.numbered"], - ]), -) +const numbered = [ + template, + "محطة طرفية {{number}}", + "Терминал {{number}}", + "ターミナル {{number}}", + "터미널 {{number}}", + "เทอร์มินัล {{number}}", + "终端 {{number}}", + "終端機 {{number}}", +] export function defaultTitle(number: number) { - return en["terminal.title.numbered"].replace("{{number}}", String(number)) + return template.replace("{{number}}", String(number)) } export function isDefaultTitle(title: string, number: number) { diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index b5cbed6e75..da22c55523 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -97,10 +97,15 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) { throw new Error(getRootNotFoundError()) } +const localUrl = () => + `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` + +const isLocalHost = () => ["localhost", "127.0.0.1", "0.0.0.0"].includes(location.hostname) + const getCurrentUrl = () => { - if (location.hostname.includes("opencode.ai")) return "http://localhost:4096" - if (import.meta.env.DEV) - return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` + if (location.hostname.includes("opencode.ai")) return localUrl() + if (import.meta.env.DEV) return localUrl() + if (isLocalHost()) return localUrl() return location.origin } diff --git a/packages/app/src/hooks/use-providers.ts b/packages/app/src/hooks/use-providers.ts index a25f8b4b25..a8f2360bbf 100644 --- a/packages/app/src/hooks/use-providers.ts +++ b/packages/app/src/hooks/use-providers.ts @@ -22,7 +22,7 @@ export function useProviders() { const providers = () => { if (dir()) { const [projectStore] = globalSync.child(dir()) - return projectStore.provider + if (projectStore.provider.all.length > 0) return projectStore.provider } return globalSync.data.provider } diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 53063f48f8..d80e9fffb0 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,6 +1,7 @@ export { AppBaseProviders, AppInterface } from "./app" export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker" export { useCommand } from "./context/command" +export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language" export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform" export { ServerConnection } from "./context/server" export { handleNotificationClick } from "./utils/notification-click" diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index cd5e079a69..6d3b04be9d 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -2,8 +2,7 @@ import { DataProvider } from "@opencode-ai/ui/context" import { showToast } from "@opencode-ai/ui/toast" import { base64Encode } from "@opencode-ai/util/encode" import { useLocation, useNavigate, useParams } from "@solidjs/router" -import { createMemo, createResource, type ParentProps, Show } from "solid-js" -import { useGlobalSDK } from "@/context/global-sdk" +import { createEffect, createMemo, type ParentProps, Show } from "solid-js" import { useLanguage } from "@/context/language" import { LocalProvider } from "@/context/local" import { SDKProvider } from "@/context/sdk" @@ -11,10 +10,18 @@ import { SyncProvider, useSync } from "@/context/sync" import { decode64 } from "@/utils/base64" function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { + const location = useLocation() const navigate = useNavigate() const sync = useSync() const slug = createMemo(() => base64Encode(props.directory)) + createEffect(() => { + const next = sync.data.path.directory + if (!next || next === props.directory) return + const path = location.pathname.slice(slug().length + 1) + navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) + }) + return ( ) { export default function Layout(props: ParentProps) { const params = useParams() - const location = useLocation() const language = useLanguage() - const globalSDK = useGlobalSDK() const navigate = useNavigate() let invalid = "" - const [resolved] = createResource( - () => { - if (params.dir) return [location.pathname, params.dir] as const - }, - async ([pathname, b64Dir]) => { - const directory = decode64(b64Dir) + const resolved = createMemo(() => { + if (!params.dir) return "" + return decode64(params.dir) ?? "" + }) - if (!directory) { - if (invalid === params.dir) return - invalid = b64Dir - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: language.t("directory.error.invalidUrl"), - }) - navigate("/", { replace: true }) - return - } - - return await globalSDK - .createClient({ - directory, - throwOnError: true, - }) - .path.get() - .then((x) => { - const next = x.data?.directory ?? directory - invalid = "" - if (next === directory) return next - const path = pathname.slice(b64Dir.length + 1) - navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) - }) - .catch(() => { - invalid = "" - return directory - }) - }, - ) + createEffect(() => { + const dir = params.dir + if (!dir) return + if (resolved()) { + invalid = "" + return + } + if (invalid === dir) return + invalid = dir + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: language.t("directory.error.invalidUrl"), + }) + navigate("/", { replace: true }) + }) return ( diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index d8b0732580..d01c7d3ceb 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -49,21 +49,16 @@ import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" import { Binary } from "@opencode-ai/util/binary" import { retry } from "@opencode-ai/util/retry" -import { playSound, soundSrc } from "@/utils/sound" +import { playSoundById } from "@/utils/sound" import { createAim } from "@/utils/aim" import { setNavigate } from "@/utils/notification-click" import { Worktree as WorktreeState } from "@/utils/worktree" import { setSessionHandoff } from "@/pages/session/handoff" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" -import { DialogSelectProvider } from "@/components/dialog-select-provider" -import { DialogSelectServer } from "@/components/dialog-select-server" -import { DialogSettings } from "@/components/dialog-settings" +import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { useCommand, type CommandOption } from "@/context/command" import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd" -import { DialogSelectDirectory } from "@/components/dialog-select-directory" -import { DialogEditProject } from "@/components/dialog-edit-project" import { DebugBar } from "@/components/debug-bar" import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" @@ -110,6 +105,8 @@ export default function Layout(props: ParentProps) { const pageReady = createMemo(() => ready()) let scrollContainerRef: HTMLDivElement | undefined + let dialogRun = 0 + let dialogDead = false const params = useParams() const globalSDK = useGlobalSDK() @@ -139,7 +136,7 @@ export default function Layout(props: ParentProps) { dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir, } }) - const availableThemeEntries = createMemo(() => Object.entries(theme.themes())) + const availableThemeEntries = createMemo(() => theme.ids().map((id) => [id, theme.themes()[id]] as const)) const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"] const colorSchemeKey: Record = { system: "theme.scheme.system", @@ -201,6 +198,8 @@ export default function Layout(props: ParentProps) { }) onCleanup(() => { + dialogDead = true + dialogRun += 1 if (navLeave.current !== undefined) clearTimeout(navLeave.current) clearTimeout(sortNowTimeout) if (sortNowInterval) clearInterval(sortNowInterval) @@ -336,10 +335,9 @@ export default function Layout(props: ParentProps) { const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length const nextThemeId = ids[nextIndex] theme.setTheme(nextThemeId) - const nextTheme = theme.themes()[nextThemeId] showToast({ title: language.t("toast.theme.title"), - description: nextTheme?.name ?? nextThemeId, + description: theme.name(nextThemeId), }) } @@ -494,7 +492,7 @@ export default function Layout(props: ParentProps) { if (e.details.type === "permission.asked") { if (settings.sounds.permissionsEnabled()) { - playSound(soundSrc(settings.sounds.permissions())) + void playSoundById(settings.sounds.permissions()) } if (settings.notifications.permissions()) { void platform.notify(title, description, href) @@ -1152,10 +1150,10 @@ export default function Layout(props: ParentProps) { }, ] - for (const [id, definition] of availableThemeEntries()) { + for (const [id] of availableThemeEntries()) { commands.push({ id: `theme.set.${id}`, - title: language.t("command.theme.set", { theme: definition.name ?? id }), + title: language.t("command.theme.set", { theme: theme.name(id) }), category: language.t("command.category.theme"), onSelect: () => theme.commitPreview(), onHighlight: () => { @@ -1206,15 +1204,27 @@ export default function Layout(props: ParentProps) { }) function connectProvider() { - dialog.show(() => ) + const run = ++dialogRun + void import("@/components/dialog-select-provider").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show(() => ) + }) } function openServer() { - dialog.show(() => ) + const run = ++dialogRun + void import("@/components/dialog-select-server").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show(() => ) + }) } function openSettings() { - dialog.show(() => ) + const run = ++dialogRun + void import("@/components/dialog-settings").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show(() => ) + }) } function projectRoot(directory: string) { @@ -1441,7 +1451,13 @@ export default function Layout(props: ParentProps) { layout.sidebar.toggleWorkspaces(project.worktree) } - const showEditProjectDialog = (project: LocalProject) => dialog.show(() => ) + const showEditProjectDialog = (project: LocalProject) => { + const run = ++dialogRun + void import("@/components/dialog-edit-project").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show(() => ) + }) + } async function chooseProject() { function resolve(result: string | string[] | null) { @@ -1462,10 +1478,14 @@ export default function Layout(props: ParentProps) { }) resolve(result) } else { - dialog.show( - () => , - () => resolve(null), - ) + const run = ++dialogRun + void import("@/components/dialog-select-directory").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show( + () => , + () => resolve(null), + ) + }) } } diff --git a/packages/app/src/utils/server-health.ts b/packages/app/src/utils/server-health.ts index 45a323c7be..a13fd34ef7 100644 --- a/packages/app/src/utils/server-health.ts +++ b/packages/app/src/utils/server-health.ts @@ -14,6 +14,15 @@ interface CheckServerHealthOptions { const defaultTimeoutMs = 3000 const defaultRetryCount = 2 const defaultRetryDelayMs = 100 +const cacheMs = 750 +const healthCache = new Map< + string, + { at: number; done: boolean; fetch: typeof globalThis.fetch; promise: Promise } +>() + +function cacheKey(server: ServerConnection.HttpBase) { + return `${server.url}\n${server.username ?? ""}\n${server.password ?? ""}` +} function timeoutSignal(timeoutMs: number) { const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout @@ -87,5 +96,18 @@ export function useCheckServerHealth() { const platform = usePlatform() const fetcher = platform.fetch ?? globalThis.fetch - return (http: ServerConnection.HttpBase) => checkServerHealth(http, fetcher) + return (http: ServerConnection.HttpBase) => { + const key = cacheKey(http) + const hit = healthCache.get(key) + const now = Date.now() + if (hit && hit.fetch === fetcher && (!hit.done || now - hit.at < cacheMs)) return hit.promise + const promise = checkServerHealth(http, fetcher).finally(() => { + const next = healthCache.get(key) + if (!next || next.promise !== promise) return + next.done = true + next.at = Date.now() + }) + healthCache.set(key, { at: now, done: false, fetch: fetcher, promise }) + return promise + } } diff --git a/packages/app/src/utils/sound.ts b/packages/app/src/utils/sound.ts index 6dea812ec8..78e5a0c565 100644 --- a/packages/app/src/utils/sound.ts +++ b/packages/app/src/utils/sound.ts @@ -1,106 +1,89 @@ -import alert01 from "@opencode-ai/ui/audio/alert-01.aac" -import alert02 from "@opencode-ai/ui/audio/alert-02.aac" -import alert03 from "@opencode-ai/ui/audio/alert-03.aac" -import alert04 from "@opencode-ai/ui/audio/alert-04.aac" -import alert05 from "@opencode-ai/ui/audio/alert-05.aac" -import alert06 from "@opencode-ai/ui/audio/alert-06.aac" -import alert07 from "@opencode-ai/ui/audio/alert-07.aac" -import alert08 from "@opencode-ai/ui/audio/alert-08.aac" -import alert09 from "@opencode-ai/ui/audio/alert-09.aac" -import alert10 from "@opencode-ai/ui/audio/alert-10.aac" -import bipbop01 from "@opencode-ai/ui/audio/bip-bop-01.aac" -import bipbop02 from "@opencode-ai/ui/audio/bip-bop-02.aac" -import bipbop03 from "@opencode-ai/ui/audio/bip-bop-03.aac" -import bipbop04 from "@opencode-ai/ui/audio/bip-bop-04.aac" -import bipbop05 from "@opencode-ai/ui/audio/bip-bop-05.aac" -import bipbop06 from "@opencode-ai/ui/audio/bip-bop-06.aac" -import bipbop07 from "@opencode-ai/ui/audio/bip-bop-07.aac" -import bipbop08 from "@opencode-ai/ui/audio/bip-bop-08.aac" -import bipbop09 from "@opencode-ai/ui/audio/bip-bop-09.aac" -import bipbop10 from "@opencode-ai/ui/audio/bip-bop-10.aac" -import nope01 from "@opencode-ai/ui/audio/nope-01.aac" -import nope02 from "@opencode-ai/ui/audio/nope-02.aac" -import nope03 from "@opencode-ai/ui/audio/nope-03.aac" -import nope04 from "@opencode-ai/ui/audio/nope-04.aac" -import nope05 from "@opencode-ai/ui/audio/nope-05.aac" -import nope06 from "@opencode-ai/ui/audio/nope-06.aac" -import nope07 from "@opencode-ai/ui/audio/nope-07.aac" -import nope08 from "@opencode-ai/ui/audio/nope-08.aac" -import nope09 from "@opencode-ai/ui/audio/nope-09.aac" -import nope10 from "@opencode-ai/ui/audio/nope-10.aac" -import nope11 from "@opencode-ai/ui/audio/nope-11.aac" -import nope12 from "@opencode-ai/ui/audio/nope-12.aac" -import staplebops01 from "@opencode-ai/ui/audio/staplebops-01.aac" -import staplebops02 from "@opencode-ai/ui/audio/staplebops-02.aac" -import staplebops03 from "@opencode-ai/ui/audio/staplebops-03.aac" -import staplebops04 from "@opencode-ai/ui/audio/staplebops-04.aac" -import staplebops05 from "@opencode-ai/ui/audio/staplebops-05.aac" -import staplebops06 from "@opencode-ai/ui/audio/staplebops-06.aac" -import staplebops07 from "@opencode-ai/ui/audio/staplebops-07.aac" -import yup01 from "@opencode-ai/ui/audio/yup-01.aac" -import yup02 from "@opencode-ai/ui/audio/yup-02.aac" -import yup03 from "@opencode-ai/ui/audio/yup-03.aac" -import yup04 from "@opencode-ai/ui/audio/yup-04.aac" -import yup05 from "@opencode-ai/ui/audio/yup-05.aac" -import yup06 from "@opencode-ai/ui/audio/yup-06.aac" +let files: Record Promise> | undefined +let loads: Record Promise> | undefined + +function getFiles() { + if (files) return files + files = import.meta.glob("../../../ui/src/assets/audio/*.aac", { import: "default" }) as Record< + string, + () => Promise + > + return files +} export const SOUND_OPTIONS = [ - { id: "alert-01", label: "sound.option.alert01", src: alert01 }, - { id: "alert-02", label: "sound.option.alert02", src: alert02 }, - { id: "alert-03", label: "sound.option.alert03", src: alert03 }, - { id: "alert-04", label: "sound.option.alert04", src: alert04 }, - { id: "alert-05", label: "sound.option.alert05", src: alert05 }, - { id: "alert-06", label: "sound.option.alert06", src: alert06 }, - { id: "alert-07", label: "sound.option.alert07", src: alert07 }, - { id: "alert-08", label: "sound.option.alert08", src: alert08 }, - { id: "alert-09", label: "sound.option.alert09", src: alert09 }, - { id: "alert-10", label: "sound.option.alert10", src: alert10 }, - { id: "bip-bop-01", label: "sound.option.bipbop01", src: bipbop01 }, - { id: "bip-bop-02", label: "sound.option.bipbop02", src: bipbop02 }, - { id: "bip-bop-03", label: "sound.option.bipbop03", src: bipbop03 }, - { id: "bip-bop-04", label: "sound.option.bipbop04", src: bipbop04 }, - { id: "bip-bop-05", label: "sound.option.bipbop05", src: bipbop05 }, - { id: "bip-bop-06", label: "sound.option.bipbop06", src: bipbop06 }, - { id: "bip-bop-07", label: "sound.option.bipbop07", src: bipbop07 }, - { id: "bip-bop-08", label: "sound.option.bipbop08", src: bipbop08 }, - { id: "bip-bop-09", label: "sound.option.bipbop09", src: bipbop09 }, - { id: "bip-bop-10", label: "sound.option.bipbop10", src: bipbop10 }, - { id: "staplebops-01", label: "sound.option.staplebops01", src: staplebops01 }, - { id: "staplebops-02", label: "sound.option.staplebops02", src: staplebops02 }, - { id: "staplebops-03", label: "sound.option.staplebops03", src: staplebops03 }, - { id: "staplebops-04", label: "sound.option.staplebops04", src: staplebops04 }, - { id: "staplebops-05", label: "sound.option.staplebops05", src: staplebops05 }, - { id: "staplebops-06", label: "sound.option.staplebops06", src: staplebops06 }, - { id: "staplebops-07", label: "sound.option.staplebops07", src: staplebops07 }, - { id: "nope-01", label: "sound.option.nope01", src: nope01 }, - { id: "nope-02", label: "sound.option.nope02", src: nope02 }, - { id: "nope-03", label: "sound.option.nope03", src: nope03 }, - { id: "nope-04", label: "sound.option.nope04", src: nope04 }, - { id: "nope-05", label: "sound.option.nope05", src: nope05 }, - { id: "nope-06", label: "sound.option.nope06", src: nope06 }, - { id: "nope-07", label: "sound.option.nope07", src: nope07 }, - { id: "nope-08", label: "sound.option.nope08", src: nope08 }, - { id: "nope-09", label: "sound.option.nope09", src: nope09 }, - { id: "nope-10", label: "sound.option.nope10", src: nope10 }, - { id: "nope-11", label: "sound.option.nope11", src: nope11 }, - { id: "nope-12", label: "sound.option.nope12", src: nope12 }, - { id: "yup-01", label: "sound.option.yup01", src: yup01 }, - { id: "yup-02", label: "sound.option.yup02", src: yup02 }, - { id: "yup-03", label: "sound.option.yup03", src: yup03 }, - { id: "yup-04", label: "sound.option.yup04", src: yup04 }, - { id: "yup-05", label: "sound.option.yup05", src: yup05 }, - { id: "yup-06", label: "sound.option.yup06", src: yup06 }, + { id: "alert-01", label: "sound.option.alert01" }, + { id: "alert-02", label: "sound.option.alert02" }, + { id: "alert-03", label: "sound.option.alert03" }, + { id: "alert-04", label: "sound.option.alert04" }, + { id: "alert-05", label: "sound.option.alert05" }, + { id: "alert-06", label: "sound.option.alert06" }, + { id: "alert-07", label: "sound.option.alert07" }, + { id: "alert-08", label: "sound.option.alert08" }, + { id: "alert-09", label: "sound.option.alert09" }, + { id: "alert-10", label: "sound.option.alert10" }, + { id: "bip-bop-01", label: "sound.option.bipbop01" }, + { id: "bip-bop-02", label: "sound.option.bipbop02" }, + { id: "bip-bop-03", label: "sound.option.bipbop03" }, + { id: "bip-bop-04", label: "sound.option.bipbop04" }, + { id: "bip-bop-05", label: "sound.option.bipbop05" }, + { id: "bip-bop-06", label: "sound.option.bipbop06" }, + { id: "bip-bop-07", label: "sound.option.bipbop07" }, + { id: "bip-bop-08", label: "sound.option.bipbop08" }, + { id: "bip-bop-09", label: "sound.option.bipbop09" }, + { id: "bip-bop-10", label: "sound.option.bipbop10" }, + { id: "staplebops-01", label: "sound.option.staplebops01" }, + { id: "staplebops-02", label: "sound.option.staplebops02" }, + { id: "staplebops-03", label: "sound.option.staplebops03" }, + { id: "staplebops-04", label: "sound.option.staplebops04" }, + { id: "staplebops-05", label: "sound.option.staplebops05" }, + { id: "staplebops-06", label: "sound.option.staplebops06" }, + { id: "staplebops-07", label: "sound.option.staplebops07" }, + { id: "nope-01", label: "sound.option.nope01" }, + { id: "nope-02", label: "sound.option.nope02" }, + { id: "nope-03", label: "sound.option.nope03" }, + { id: "nope-04", label: "sound.option.nope04" }, + { id: "nope-05", label: "sound.option.nope05" }, + { id: "nope-06", label: "sound.option.nope06" }, + { id: "nope-07", label: "sound.option.nope07" }, + { id: "nope-08", label: "sound.option.nope08" }, + { id: "nope-09", label: "sound.option.nope09" }, + { id: "nope-10", label: "sound.option.nope10" }, + { id: "nope-11", label: "sound.option.nope11" }, + { id: "nope-12", label: "sound.option.nope12" }, + { id: "yup-01", label: "sound.option.yup01" }, + { id: "yup-02", label: "sound.option.yup02" }, + { id: "yup-03", label: "sound.option.yup03" }, + { id: "yup-04", label: "sound.option.yup04" }, + { id: "yup-05", label: "sound.option.yup05" }, + { id: "yup-06", label: "sound.option.yup06" }, ] as const export type SoundOption = (typeof SOUND_OPTIONS)[number] export type SoundID = SoundOption["id"] -const soundById = Object.fromEntries(SOUND_OPTIONS.map((s) => [s.id, s.src])) as Record +function getLoads() { + if (loads) return loads + loads = Object.fromEntries( + Object.entries(getFiles()).flatMap(([path, load]) => { + const file = path.split("/").at(-1) + if (!file) return [] + return [[file.replace(/\.aac$/, ""), load] as const] + }), + ) as Record Promise> + return loads +} + +const cache = new Map>() export function soundSrc(id: string | undefined) { - if (!id) return - if (!(id in soundById)) return - return soundById[id as SoundID] + const loads = getLoads() + if (!id || !(id in loads)) return Promise.resolve(undefined) + const key = id as SoundID + const hit = cache.get(key) + if (hit) return hit + const next = loads[key]().catch(() => undefined) + cache.set(key, next) + return next } export function playSound(src: string | undefined) { @@ -108,10 +91,12 @@ export function playSound(src: string | undefined) { if (!src) return const audio = new Audio(src) audio.play().catch(() => undefined) - - // Return a cleanup function to pause the sound. return () => { audio.pause() audio.currentTime = 0 } } + +export function playSoundById(id: string | undefined) { + return soundSrc(id).then((src) => playSound(src)) +} diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index ec2b4d1e7a..44f2e6360c 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -6,6 +6,9 @@ import { AppBaseProviders, AppInterface, handleNotificationClick, + loadLocaleDict, + normalizeLocale, + type Locale, type Platform, PlatformProvider, ServerConnection, @@ -246,6 +249,17 @@ listenForDeepLinks() render(() => { const platform = createPlatform() + const loadLocale = async () => { + const current = await platform.storage?.("opencode.global.dat").getItem("language") + const legacy = current ? undefined : await platform.storage?.().getItem("language.v1") + const raw = current ?? legacy + if (!raw) return + const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1] + if (!locale) return + const next = normalizeLocale(locale) + if (next !== "en") await loadLocaleDict(next) + return next satisfies Locale + } const [windowCount] = createResource(() => window.api.getWindowCount()) @@ -257,6 +271,7 @@ render(() => { if (url) return ServerConnection.key({ type: "http", http: { url } }) }), ) + const [locale] = createResource(loadLocale) const servers = () => { const data = sidecar() @@ -309,15 +324,14 @@ render(() => { return ( - - + + {(_) => { return ( 1} > diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index e677956440..5fe88d501b 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -6,6 +6,9 @@ import { AppBaseProviders, AppInterface, handleNotificationClick, + loadLocaleDict, + normalizeLocale, + type Locale, type Platform, PlatformProvider, ServerConnection, @@ -414,6 +417,17 @@ void listenForDeepLinks() render(() => { const platform = createPlatform() + const loadLocale = async () => { + const current = await platform.storage?.("opencode.global.dat").getItem("language") + const legacy = current ? undefined : await platform.storage?.().getItem("language.v1") + const raw = current ?? legacy + if (!raw) return + const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1] + if (!locale) return + const next = normalizeLocale(locale) + if (next !== "en") await loadLocaleDict(next) + return next satisfies Locale + } // Fetch sidecar credentials from Rust (available immediately, before health check) const [sidecar] = createResource(() => commands.awaitInitialization(new Channel() as any)) @@ -423,6 +437,7 @@ render(() => { if (url) return ServerConnection.key({ type: "http", http: { url } }) }), ) + const [locale] = createResource(loadLocale) // Build the sidecar server connection once credentials arrive const servers = () => { @@ -465,8 +480,8 @@ render(() => { return ( - - + + {(_) => { return ( + + diff --git a/packages/ui/src/assets/icons/provider/alibaba-coding-plan.svg b/packages/ui/src/assets/icons/provider/alibaba-coding-plan.svg new file mode 100644 index 0000000000..b3a2edc3c0 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/alibaba-coding-plan.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/clarifai.svg b/packages/ui/src/assets/icons/provider/clarifai.svg new file mode 100644 index 0000000000..086e9aa1fc --- /dev/null +++ b/packages/ui/src/assets/icons/provider/clarifai.svg @@ -0,0 +1,24 @@ + + + + + diff --git a/packages/ui/src/assets/icons/provider/dinference.svg b/packages/ui/src/assets/icons/provider/dinference.svg new file mode 100644 index 0000000000..e045c96fb3 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/dinference.svg @@ -0,0 +1 @@ + diff --git a/packages/ui/src/assets/icons/provider/drun.svg b/packages/ui/src/assets/icons/provider/drun.svg new file mode 100644 index 0000000000..472dee9122 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/drun.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/ui/src/assets/icons/provider/perplexity-agent.svg b/packages/ui/src/assets/icons/provider/perplexity-agent.svg new file mode 100644 index 0000000000..a0f38862a4 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/perplexity-agent.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/tencent-coding-plan.svg b/packages/ui/src/assets/icons/provider/tencent-coding-plan.svg new file mode 100644 index 0000000000..502e51a5be --- /dev/null +++ b/packages/ui/src/assets/icons/provider/tencent-coding-plan.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/ui/src/assets/icons/provider/zenmux.svg b/packages/ui/src/assets/icons/provider/zenmux.svg index d8d9ef665f..9eb8045e45 100644 --- a/packages/ui/src/assets/icons/provider/zenmux.svg +++ b/packages/ui/src/assets/icons/provider/zenmux.svg @@ -1,3 +1,4 @@ - - + + + diff --git a/packages/ui/src/components/font.tsx b/packages/ui/src/components/font.tsx index f735747a49..e1a508f16a 100644 --- a/packages/ui/src/components/font.tsx +++ b/packages/ui/src/components/font.tsx @@ -1,121 +1,9 @@ +import { Link, Style } from "@solidjs/meta" import { Show } from "solid-js" -import { Style, Link } from "@solidjs/meta" import inter from "../assets/fonts/inter.woff2" -import ibmPlexMonoRegular from "../assets/fonts/ibm-plex-mono.woff2" -import ibmPlexMonoMedium from "../assets/fonts/ibm-plex-mono-medium.woff2" import ibmPlexMonoBold from "../assets/fonts/ibm-plex-mono-bold.woff2" - -import cascadiaCode from "../assets/fonts/cascadia-code-nerd-font.woff2" -import cascadiaCodeBold from "../assets/fonts/cascadia-code-nerd-font-bold.woff2" -import firaCode from "../assets/fonts/fira-code-nerd-font.woff2" -import firaCodeBold from "../assets/fonts/fira-code-nerd-font-bold.woff2" -import hack from "../assets/fonts/hack-nerd-font.woff2" -import hackBold from "../assets/fonts/hack-nerd-font-bold.woff2" -import inconsolata from "../assets/fonts/inconsolata-nerd-font.woff2" -import inconsolataBold from "../assets/fonts/inconsolata-nerd-font-bold.woff2" -import intelOneMono from "../assets/fonts/intel-one-mono-nerd-font.woff2" -import intelOneMonoBold from "../assets/fonts/intel-one-mono-nerd-font-bold.woff2" -import jetbrainsMono from "../assets/fonts/jetbrains-mono-nerd-font.woff2" -import jetbrainsMonoBold from "../assets/fonts/jetbrains-mono-nerd-font-bold.woff2" -import mesloLgs from "../assets/fonts/meslo-lgs-nerd-font.woff2" -import mesloLgsBold from "../assets/fonts/meslo-lgs-nerd-font-bold.woff2" -import robotoMono from "../assets/fonts/roboto-mono-nerd-font.woff2" -import robotoMonoBold from "../assets/fonts/roboto-mono-nerd-font-bold.woff2" -import sourceCodePro from "../assets/fonts/source-code-pro-nerd-font.woff2" -import sourceCodeProBold from "../assets/fonts/source-code-pro-nerd-font-bold.woff2" -import ubuntuMono from "../assets/fonts/ubuntu-mono-nerd-font.woff2" -import ubuntuMonoBold from "../assets/fonts/ubuntu-mono-nerd-font-bold.woff2" -import iosevka from "../assets/fonts/iosevka-nerd-font.woff2" -import iosevkaBold from "../assets/fonts/iosevka-nerd-font-bold.woff2" -import geistMono from "../assets/fonts/GeistMonoNerdFontMono-Regular.woff2" -import geistMonoBold from "../assets/fonts/GeistMonoNerdFontMono-Bold.woff2" - -type MonoFont = { - family: string - regular: string - bold: string -} - -export const MONO_NERD_FONTS = [ - { - family: "JetBrains Mono Nerd Font", - regular: jetbrainsMono, - bold: jetbrainsMonoBold, - }, - { - family: "Fira Code Nerd Font", - regular: firaCode, - bold: firaCodeBold, - }, - { - family: "Cascadia Code Nerd Font", - regular: cascadiaCode, - bold: cascadiaCodeBold, - }, - { - family: "Hack Nerd Font", - regular: hack, - bold: hackBold, - }, - { - family: "Source Code Pro Nerd Font", - regular: sourceCodePro, - bold: sourceCodeProBold, - }, - { - family: "Inconsolata Nerd Font", - regular: inconsolata, - bold: inconsolataBold, - }, - { - family: "Roboto Mono Nerd Font", - regular: robotoMono, - bold: robotoMonoBold, - }, - { - family: "Ubuntu Mono Nerd Font", - regular: ubuntuMono, - bold: ubuntuMonoBold, - }, - { - family: "Intel One Mono Nerd Font", - regular: intelOneMono, - bold: intelOneMonoBold, - }, - { - family: "Meslo LGS Nerd Font", - regular: mesloLgs, - bold: mesloLgsBold, - }, - { - family: "Iosevka Nerd Font", - regular: iosevka, - bold: iosevkaBold, - }, - { - family: "GeistMono Nerd Font", - regular: geistMono, - bold: geistMonoBold, - }, -] satisfies MonoFont[] - -const monoNerdCss = MONO_NERD_FONTS.map( - (font) => ` - @font-face { - font-family: "${font.family}"; - src: url("${font.regular}") format("woff2"); - font-display: swap; - font-style: normal; - font-weight: 400; - } - @font-face { - font-family: "${font.family}"; - src: url("${font.bold}") format("woff2"); - font-display: swap; - font-style: normal; - font-weight: 700; - }`, -).join("") +import ibmPlexMonoMedium from "../assets/fonts/ibm-plex-mono-medium.woff2" +import ibmPlexMonoRegular from "../assets/fonts/ibm-plex-mono.woff2" export const Font = () => { return ( @@ -165,7 +53,6 @@ export const Font = () => { descent-override: 25%; line-gap-override: 1%; } -${monoNerdCss} `} diff --git a/packages/ui/src/font-loader.ts b/packages/ui/src/font-loader.ts new file mode 100644 index 0000000000..f2b1e6be13 --- /dev/null +++ b/packages/ui/src/font-loader.ts @@ -0,0 +1,133 @@ +type MonoFont = { + id: string + family: string + regular: string + bold: string +} + +let files: Record Promise> | undefined + +function getFiles() { + if (files) return files + files = import.meta.glob("./assets/fonts/*.woff2", { import: "default" }) as Record Promise> + return files +} + +export const MONO_NERD_FONTS = [ + { + id: "jetbrains-mono", + family: "JetBrains Mono Nerd Font", + regular: "./assets/fonts/jetbrains-mono-nerd-font.woff2", + bold: "./assets/fonts/jetbrains-mono-nerd-font-bold.woff2", + }, + { + id: "fira-code", + family: "Fira Code Nerd Font", + regular: "./assets/fonts/fira-code-nerd-font.woff2", + bold: "./assets/fonts/fira-code-nerd-font-bold.woff2", + }, + { + id: "cascadia-code", + family: "Cascadia Code Nerd Font", + regular: "./assets/fonts/cascadia-code-nerd-font.woff2", + bold: "./assets/fonts/cascadia-code-nerd-font-bold.woff2", + }, + { + id: "hack", + family: "Hack Nerd Font", + regular: "./assets/fonts/hack-nerd-font.woff2", + bold: "./assets/fonts/hack-nerd-font-bold.woff2", + }, + { + id: "source-code-pro", + family: "Source Code Pro Nerd Font", + regular: "./assets/fonts/source-code-pro-nerd-font.woff2", + bold: "./assets/fonts/source-code-pro-nerd-font-bold.woff2", + }, + { + id: "inconsolata", + family: "Inconsolata Nerd Font", + regular: "./assets/fonts/inconsolata-nerd-font.woff2", + bold: "./assets/fonts/inconsolata-nerd-font-bold.woff2", + }, + { + id: "roboto-mono", + family: "Roboto Mono Nerd Font", + regular: "./assets/fonts/roboto-mono-nerd-font.woff2", + bold: "./assets/fonts/roboto-mono-nerd-font-bold.woff2", + }, + { + id: "ubuntu-mono", + family: "Ubuntu Mono Nerd Font", + regular: "./assets/fonts/ubuntu-mono-nerd-font.woff2", + bold: "./assets/fonts/ubuntu-mono-nerd-font-bold.woff2", + }, + { + id: "intel-one-mono", + family: "Intel One Mono Nerd Font", + regular: "./assets/fonts/intel-one-mono-nerd-font.woff2", + bold: "./assets/fonts/intel-one-mono-nerd-font-bold.woff2", + }, + { + id: "meslo-lgs", + family: "Meslo LGS Nerd Font", + regular: "./assets/fonts/meslo-lgs-nerd-font.woff2", + bold: "./assets/fonts/meslo-lgs-nerd-font-bold.woff2", + }, + { + id: "iosevka", + family: "Iosevka Nerd Font", + regular: "./assets/fonts/iosevka-nerd-font.woff2", + bold: "./assets/fonts/iosevka-nerd-font-bold.woff2", + }, + { + id: "geist-mono", + family: "GeistMono Nerd Font", + regular: "./assets/fonts/GeistMonoNerdFontMono-Regular.woff2", + bold: "./assets/fonts/GeistMonoNerdFontMono-Bold.woff2", + }, +] satisfies MonoFont[] + +const mono = Object.fromEntries(MONO_NERD_FONTS.map((font) => [font.id, font])) as Record +const loads = new Map>() + +function css(font: { family: string; regular: string; bold: string }) { + return ` + @font-face { + font-family: "${font.family}"; + src: url("${font.regular}") format("woff2"); + font-display: swap; + font-style: normal; + font-weight: 400; + } + @font-face { + font-family: "${font.family}"; + src: url("${font.bold}") format("woff2"); + font-display: swap; + font-style: normal; + font-weight: 700; + } + ` +} + +export function ensureMonoFont(id: string | undefined) { + if (!id || id === "ibm-plex-mono") return Promise.resolve() + if (typeof document !== "object") return Promise.resolve() + const font = mono[id] + if (!font) return Promise.resolve() + const styleId = `oc-font-${font.id}` + if (document.getElementById(styleId)) return Promise.resolve() + const hit = loads.get(font.id) + if (hit) return hit + const files = getFiles() + const load = Promise.all([files[font.regular]?.(), files[font.bold]?.()]).then(([regular, bold]) => { + if (!regular || !bold) return + if (document.getElementById(styleId)) return + const style = document.createElement("style") + style.id = styleId + style.textContent = css({ family: font.family, regular, bold }) + document.head.appendChild(style) + }) + loads.set(font.id, load) + return load +} diff --git a/packages/ui/src/theme/context.tsx b/packages/ui/src/theme/context.tsx index 9808c8e841..7d25ac3972 100644 --- a/packages/ui/src/theme/context.tsx +++ b/packages/ui/src/theme/context.tsx @@ -1,7 +1,7 @@ import { createEffect, onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "../context/helper" -import { DEFAULT_THEMES } from "./default-themes" +import oc2ThemeJson from "./themes/oc-2.json" import { resolveThemeVariant, themeToCss } from "./resolve" import type { DesktopTheme } from "./types" @@ -15,14 +15,101 @@ const STORAGE_KEYS = { } as const const THEME_STYLE_ID = "oc-theme" +let files: Record Promise<{ default: DesktopTheme }>> | undefined +let ids: string[] | undefined +let known: Set | undefined + +function getFiles() { + if (files) return files + files = import.meta.glob<{ default: DesktopTheme }>("./themes/*.json") + return files +} + +function themeIDs() { + if (ids) return ids + ids = Object.keys(getFiles()) + .map((path) => path.slice("./themes/".length, -".json".length)) + .sort() + return ids +} + +function knownThemes() { + if (known) return known + known = new Set(themeIDs()) + return known +} + +const names: Record = { + "oc-2": "OC-2", + amoled: "AMOLED", + aura: "Aura", + ayu: "Ayu", + carbonfox: "Carbonfox", + catppuccin: "Catppuccin", + "catppuccin-frappe": "Catppuccin Frappe", + "catppuccin-macchiato": "Catppuccin Macchiato", + cobalt2: "Cobalt2", + cursor: "Cursor", + dracula: "Dracula", + everforest: "Everforest", + flexoki: "Flexoki", + github: "GitHub", + gruvbox: "Gruvbox", + kanagawa: "Kanagawa", + "lucent-orng": "Lucent Orng", + material: "Material", + matrix: "Matrix", + mercury: "Mercury", + monokai: "Monokai", + nightowl: "Night Owl", + nord: "Nord", + "one-dark": "One Dark", + onedarkpro: "One Dark Pro", + opencode: "OpenCode", + orng: "Orng", + "osaka-jade": "Osaka Jade", + palenight: "Palenight", + rosepine: "Rose Pine", + shadesofpurple: "Shades of Purple", + solarized: "Solarized", + synthwave84: "Synthwave '84", + tokyonight: "Tokyonight", + vercel: "Vercel", + vesper: "Vesper", + zenburn: "Zenburn", +} +const oc2Theme = oc2ThemeJson as DesktopTheme function normalize(id: string | null | undefined) { return id === "oc-1" ? "oc-2" : id } +function read(key: string) { + if (typeof localStorage !== "object") return null + try { + return localStorage.getItem(key) + } catch { + return null + } +} + +function write(key: string, value: string) { + if (typeof localStorage !== "object") return + try { + localStorage.setItem(key, value) + } catch {} +} + +function drop(key: string) { + if (typeof localStorage !== "object") return + try { + localStorage.removeItem(key) + } catch {} +} + function clear() { - localStorage.removeItem(STORAGE_KEYS.THEME_CSS_LIGHT) - localStorage.removeItem(STORAGE_KEYS.THEME_CSS_DARK) + drop(STORAGE_KEYS.THEME_CSS_LIGHT) + drop(STORAGE_KEYS.THEME_CSS_DARK) } function ensureThemeStyleElement(): HTMLStyleElement { @@ -35,6 +122,7 @@ function ensureThemeStyleElement(): HTMLStyleElement { } function getSystemMode(): "light" | "dark" { + if (typeof window !== "object") return "light" return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" } @@ -45,9 +133,7 @@ function applyThemeCss(theme: DesktopTheme, themeId: string, mode: "light" | "da const css = themeToCss(tokens) if (themeId !== "oc-2") { - try { - localStorage.setItem(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) - } catch {} + write(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) } const fullCss = `:root { @@ -69,74 +155,122 @@ function cacheThemeVariants(theme: DesktopTheme, themeId: string) { const variant = isDark ? theme.dark : theme.light const tokens = resolveThemeVariant(variant, isDark) const css = themeToCss(tokens) - try { - localStorage.setItem(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) - } catch {} + write(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) } } export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ name: "Theme", init: (props: { defaultTheme?: string; onThemeApplied?: (theme: DesktopTheme, mode: "light" | "dark") => void }) => { + const themeId = normalize(read(STORAGE_KEYS.THEME_ID) ?? props.defaultTheme) ?? "oc-2" + const colorScheme = (read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null) ?? "system" + const mode = colorScheme === "system" ? getSystemMode() : colorScheme const [store, setStore] = createStore({ - themes: DEFAULT_THEMES as Record, - themeId: normalize(props.defaultTheme) ?? "oc-2", - colorScheme: "system" as ColorScheme, - mode: getSystemMode(), + themes: { + "oc-2": oc2Theme, + } as Record, + themeId, + colorScheme, + mode, previewThemeId: null as string | null, 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)) - } - }) + const loads = new Map>() - onMount(() => { - const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") - const handler = () => { - if (store.colorScheme === "system") { - setStore("mode", getSystemMode()) - } - } - mediaQuery.addEventListener("change", handler) - onCleanup(() => mediaQuery.removeEventListener("change", handler)) - - const savedTheme = localStorage.getItem(STORAGE_KEYS.THEME_ID) - const themeId = normalize(savedTheme) - const savedScheme = localStorage.getItem(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null - if (themeId && store.themes[themeId]) { - setStore("themeId", themeId) - } - if (savedTheme && themeId && savedTheme !== themeId) { - localStorage.setItem(STORAGE_KEYS.THEME_ID, themeId) - clear() - } - if (savedScheme) { - setStore("colorScheme", savedScheme) - if (savedScheme !== "system") { - setStore("mode", savedScheme) - } - } - const currentTheme = store.themes[store.themeId] - if (currentTheme) { - cacheThemeVariants(currentTheme, store.themeId) - } - }) + const load = (id: string) => { + const next = normalize(id) + if (!next) return Promise.resolve(undefined) + const hit = store.themes[next] + if (hit) return Promise.resolve(hit) + const pending = loads.get(next) + if (pending) return pending + const file = getFiles()[`./themes/${next}.json`] + if (!file) return Promise.resolve(undefined) + const task = file() + .then((mod) => { + const theme = mod.default + setStore("themes", next, theme) + return theme + }) + .finally(() => { + loads.delete(next) + }) + loads.set(next, task) + return task + } const applyTheme = (theme: DesktopTheme, themeId: string, mode: "light" | "dark") => { applyThemeCss(theme, themeId, mode) props.onThemeApplied?.(theme, mode) } + const ids = () => { + const extra = Object.keys(store.themes) + .filter((id) => !knownThemes().has(id)) + .sort() + const all = themeIDs() + if (extra.length === 0) return all + return [...all, ...extra] + } + + const loadThemes = () => Promise.all(themeIDs().map(load)).then(() => store.themes) + + const onStorage = (e: StorageEvent) => { + if (e.key === STORAGE_KEYS.THEME_ID && e.newValue) { + const next = normalize(e.newValue) + if (!next) return + if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) return + setStore("themeId", next) + if (next === "oc-2") { + clear() + return + } + void load(next).then((theme) => { + if (!theme || store.themeId !== next) return + cacheThemeVariants(theme, next) + }) + } + if (e.key === STORAGE_KEYS.COLOR_SCHEME && e.newValue) { + setStore("colorScheme", e.newValue as ColorScheme) + setStore("mode", e.newValue === "system" ? getSystemMode() : (e.newValue as "light" | "dark")) + } + } + + if (typeof window === "object") { + window.addEventListener("storage", onStorage) + onCleanup(() => window.removeEventListener("storage", onStorage)) + } + + onMount(() => { + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") + const onMedia = () => { + if (store.colorScheme !== "system") return + setStore("mode", getSystemMode()) + } + mediaQuery.addEventListener("change", onMedia) + onCleanup(() => mediaQuery.removeEventListener("change", onMedia)) + + const rawTheme = read(STORAGE_KEYS.THEME_ID) + const savedTheme = normalize(rawTheme ?? props.defaultTheme) ?? "oc-2" + const savedScheme = (read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null) ?? "system" + if (rawTheme && rawTheme !== savedTheme) { + write(STORAGE_KEYS.THEME_ID, savedTheme) + clear() + } + if (savedTheme !== store.themeId) setStore("themeId", savedTheme) + if (savedScheme !== store.colorScheme) setStore("colorScheme", savedScheme) + setStore("mode", savedScheme === "system" ? getSystemMode() : savedScheme) + void load(savedTheme).then((theme) => { + if (!theme || store.themeId !== savedTheme) return + cacheThemeVariants(theme, savedTheme) + }) + }) + createEffect(() => { const theme = store.themes[store.themeId] - if (theme) { - applyTheme(theme, store.themeId, store.mode) - } + if (!theme) return + applyTheme(theme, store.themeId, store.mode) }) const setTheme = (id: string) => { @@ -145,23 +279,26 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ console.warn(`Theme "${id}" not found`) return } - const theme = store.themes[next] - if (!theme) { + if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) { console.warn(`Theme "${id}" not found`) return } setStore("themeId", next) - localStorage.setItem(STORAGE_KEYS.THEME_ID, next) if (next === "oc-2") { + write(STORAGE_KEYS.THEME_ID, next) clear() return } - cacheThemeVariants(theme, next) + void load(next).then((theme) => { + if (!theme || store.themeId !== next) return + cacheThemeVariants(theme, next) + write(STORAGE_KEYS.THEME_ID, next) + }) } const setColorScheme = (scheme: ColorScheme) => { setStore("colorScheme", scheme) - localStorage.setItem(STORAGE_KEYS.COLOR_SCHEME, scheme) + write(STORAGE_KEYS.COLOR_SCHEME, scheme) setStore("mode", scheme === "system" ? getSystemMode() : scheme) } @@ -169,6 +306,9 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ themeId: () => store.themeId, colorScheme: () => store.colorScheme, mode: () => store.mode, + ids, + name: (id: string) => store.themes[id]?.name ?? names[id] ?? id, + loadThemes, themes: () => store.themes, setTheme, setColorScheme, @@ -176,24 +316,28 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ previewTheme: (id: string) => { const next = normalize(id) if (!next) return - const theme = store.themes[next] - if (!theme) return + if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) return setStore("previewThemeId", next) - const previewMode = store.previewScheme - ? store.previewScheme === "system" - ? getSystemMode() - : store.previewScheme - : store.mode - applyTheme(theme, next, previewMode) + void load(next).then((theme) => { + if (!theme || store.previewThemeId !== next) return + const mode = store.previewScheme + ? store.previewScheme === "system" + ? getSystemMode() + : store.previewScheme + : store.mode + applyTheme(theme, next, mode) + }) }, previewColorScheme: (scheme: ColorScheme) => { setStore("previewScheme", scheme) - const previewMode = scheme === "system" ? getSystemMode() : scheme + const mode = scheme === "system" ? getSystemMode() : scheme const id = store.previewThemeId ?? store.themeId - const theme = store.themes[id] - if (theme) { - applyTheme(theme, id, previewMode) - } + void load(id).then((theme) => { + if (!theme) return + if ((store.previewThemeId ?? store.themeId) !== id) return + if (store.previewScheme !== scheme) return + applyTheme(theme, id, mode) + }) }, commitPreview: () => { if (store.previewThemeId) { @@ -208,10 +352,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ cancelPreview: () => { setStore("previewThemeId", null) setStore("previewScheme", null) - const theme = store.themes[store.themeId] - if (theme) { + void load(store.themeId).then((theme) => { + if (!theme) return applyTheme(theme, store.themeId, store.mode) - } + }) }, } }, From 41c77ccb33b26c09aca2ab96661dc31a5db70264 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 24 Mar 2026 10:35:24 -0400 Subject: [PATCH 08/48] fix: restore cross-spawn behavior for effect child processes (#18798) --- .../src/effect/cross-spawn-spawner.ts | 476 ++++++++++++++++ packages/opencode/src/installation/index.ts | 5 +- packages/opencode/src/snapshot/index.ts | 7 +- .../test/effect/cross-spawn-spawner.test.ts | 518 ++++++++++++++++++ 4 files changed, 1001 insertions(+), 5 deletions(-) create mode 100644 packages/opencode/src/effect/cross-spawn-spawner.ts create mode 100644 packages/opencode/test/effect/cross-spawn-spawner.test.ts diff --git a/packages/opencode/src/effect/cross-spawn-spawner.ts b/packages/opencode/src/effect/cross-spawn-spawner.ts new file mode 100644 index 0000000000..f7b8786d08 --- /dev/null +++ b/packages/opencode/src/effect/cross-spawn-spawner.ts @@ -0,0 +1,476 @@ +import type * as Arr from "effect/Array" +import { NodeSink, NodeStream } from "@effect/platform-node" +import * as Deferred from "effect/Deferred" +import * as Effect from "effect/Effect" +import * as Exit from "effect/Exit" +import * as FileSystem from "effect/FileSystem" +import * as Layer from "effect/Layer" +import * as Path from "effect/Path" +import * as PlatformError from "effect/PlatformError" +import * as Predicate from "effect/Predicate" +import type * as Scope from "effect/Scope" +import * as Sink from "effect/Sink" +import * as Stream from "effect/Stream" +import * as ChildProcess from "effect/unstable/process/ChildProcess" +import type { ChildProcessHandle } from "effect/unstable/process/ChildProcessSpawner" +import { + ChildProcessSpawner, + ExitCode, + make as makeSpawner, + makeHandle, + ProcessId, +} from "effect/unstable/process/ChildProcessSpawner" +import * as NodeChildProcess from "node:child_process" +import { PassThrough } from "node:stream" +import launch from "cross-spawn" + +const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err))) + +const toTag = (err: NodeJS.ErrnoException): PlatformError.SystemErrorTag => { + switch (err.code) { + case "ENOENT": + return "NotFound" + case "EACCES": + return "PermissionDenied" + case "EEXIST": + return "AlreadyExists" + case "EISDIR": + return "BadResource" + case "ENOTDIR": + return "BadResource" + case "EBUSY": + return "Busy" + case "ELOOP": + return "BadResource" + default: + return "Unknown" + } +} + +const flatten = (command: ChildProcess.Command) => { + const commands: Array = [] + const opts: Array = [] + + const walk = (cmd: ChildProcess.Command): void => { + switch (cmd._tag) { + case "StandardCommand": + commands.push(cmd) + return + case "PipedCommand": + walk(cmd.left) + opts.push(cmd.options) + walk(cmd.right) + return + } + } + + walk(command) + if (commands.length === 0) throw new Error("flatten produced empty commands array") + const [head, ...tail] = commands + return { + commands: [head, ...tail] as Arr.NonEmptyReadonlyArray, + opts, + } +} + +const toPlatformError = ( + method: string, + err: NodeJS.ErrnoException, + command: ChildProcess.Command, +): PlatformError.PlatformError => { + const cmd = flatten(command) + .commands.map((x) => `${x.command} ${x.args.join(" ")}`) + .join(" | ") + return PlatformError.systemError({ + _tag: toTag(err), + module: "ChildProcess", + method, + pathOrDescriptor: cmd, + syscall: err.syscall, + cause: err, + }) +} + +type ExitSignal = Deferred.Deferred + +export const make = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + + const cwd = Effect.fnUntraced(function* (opts: ChildProcess.CommandOptions) { + if (Predicate.isUndefined(opts.cwd)) return undefined + yield* fs.access(opts.cwd) + return path.resolve(opts.cwd) + }) + + const env = (opts: ChildProcess.CommandOptions) => + opts.extendEnv ? { ...globalThis.process.env, ...opts.env } : opts.env + + const input = (x: ChildProcess.CommandInput | undefined): NodeChildProcess.IOType | undefined => + Stream.isStream(x) ? "pipe" : x + + const output = (x: ChildProcess.CommandOutput | undefined): NodeChildProcess.IOType | undefined => + Sink.isSink(x) ? "pipe" : x + + const stdin = (opts: ChildProcess.CommandOptions): ChildProcess.StdinConfig => { + const cfg: ChildProcess.StdinConfig = { stream: "pipe", encoding: "utf-8", endOnDone: true } + if (Predicate.isUndefined(opts.stdin)) return cfg + if (typeof opts.stdin === "string") return { ...cfg, stream: opts.stdin } + if (Stream.isStream(opts.stdin)) return { ...cfg, stream: opts.stdin } + return { + stream: opts.stdin.stream, + encoding: opts.stdin.encoding ?? cfg.encoding, + endOnDone: opts.stdin.endOnDone ?? cfg.endOnDone, + } + } + + const stdio = (opts: ChildProcess.CommandOptions, key: "stdout" | "stderr"): ChildProcess.StdoutConfig => { + const cfg = opts[key] + if (Predicate.isUndefined(cfg)) return { stream: "pipe" } + if (typeof cfg === "string") return { stream: cfg } + if (Sink.isSink(cfg)) return { stream: cfg } + return { stream: cfg.stream } + } + + const fds = (opts: ChildProcess.CommandOptions) => { + if (Predicate.isUndefined(opts.additionalFds)) return [] + return Object.entries(opts.additionalFds) + .flatMap(([name, config]) => { + const fd = ChildProcess.parseFdName(name) + return Predicate.isUndefined(fd) ? [] : [{ fd, config }] + }) + .toSorted((a, b) => a.fd - b.fd) + } + + const stdios = ( + sin: ChildProcess.StdinConfig, + sout: ChildProcess.StdoutConfig, + serr: ChildProcess.StderrConfig, + extra: ReadonlyArray<{ fd: number; config: ChildProcess.AdditionalFdConfig }>, + ): NodeChildProcess.StdioOptions => { + const pipe = (x: NodeChildProcess.IOType | undefined) => + process.platform === "win32" && x === "pipe" ? "overlapped" : x + const arr: Array = [ + pipe(input(sin.stream)), + pipe(output(sout.stream)), + pipe(output(serr.stream)), + ] + if (extra.length === 0) return arr as NodeChildProcess.StdioOptions + const max = extra.reduce((acc, x) => Math.max(acc, x.fd), 2) + for (let i = 3; i <= max; i++) arr[i] = "ignore" + for (const x of extra) arr[x.fd] = pipe("pipe") + return arr as NodeChildProcess.StdioOptions + } + + const setupFds = Effect.fnUntraced(function* ( + command: ChildProcess.StandardCommand, + proc: NodeChildProcess.ChildProcess, + extra: ReadonlyArray<{ fd: number; config: ChildProcess.AdditionalFdConfig }>, + ) { + if (extra.length === 0) { + return { + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + } + } + + const ins = new Map>() + const outs = new Map>() + + for (const x of extra) { + const node = proc.stdio[x.fd] + switch (x.config.type) { + case "input": { + let sink: Sink.Sink = Sink.drain + if (node && "write" in node) { + sink = NodeSink.fromWritable({ + evaluate: () => node, + onError: (err) => toPlatformError(`fromWritable(fd${x.fd})`, toError(err), command), + endOnDone: true, + }) + } + if (x.config.stream) yield* Effect.forkScoped(Stream.run(x.config.stream, sink)) + ins.set(x.fd, sink) + break + } + case "output": { + let stream: Stream.Stream = Stream.empty + if (node && "read" in node) { + const tap = new PassThrough() + node.on("error", (err) => tap.destroy(toError(err))) + node.pipe(tap) + stream = NodeStream.fromReadable({ + evaluate: () => tap, + onError: (err) => toPlatformError(`fromReadable(fd${x.fd})`, toError(err), command), + }) + } + if (x.config.sink) stream = Stream.transduce(stream, x.config.sink) + outs.set(x.fd, stream) + break + } + } + } + + return { + getInputFd: (fd: number) => ins.get(fd) ?? Sink.drain, + getOutputFd: (fd: number) => outs.get(fd) ?? Stream.empty, + } + }) + + const setupStdin = ( + command: ChildProcess.StandardCommand, + proc: NodeChildProcess.ChildProcess, + cfg: ChildProcess.StdinConfig, + ) => + Effect.suspend(() => { + let sink: Sink.Sink = Sink.drain + if (Predicate.isNotNull(proc.stdin)) { + sink = NodeSink.fromWritable({ + evaluate: () => proc.stdin!, + onError: (err) => toPlatformError("fromWritable(stdin)", toError(err), command), + endOnDone: cfg.endOnDone, + encoding: cfg.encoding, + }) + } + if (Stream.isStream(cfg.stream)) return Effect.as(Effect.forkScoped(Stream.run(cfg.stream, sink)), sink) + return Effect.succeed(sink) + }) + + const setupOutput = ( + command: ChildProcess.StandardCommand, + proc: NodeChildProcess.ChildProcess, + out: ChildProcess.StdoutConfig, + err: ChildProcess.StderrConfig, + ) => { + let stdout = proc.stdout + ? NodeStream.fromReadable({ + evaluate: () => proc.stdout!, + onError: (cause) => toPlatformError("fromReadable(stdout)", toError(cause), command), + }) + : Stream.empty + let stderr = proc.stderr + ? NodeStream.fromReadable({ + evaluate: () => proc.stderr!, + onError: (cause) => toPlatformError("fromReadable(stderr)", toError(cause), command), + }) + : Stream.empty + + if (Sink.isSink(out.stream)) stdout = Stream.transduce(stdout, out.stream) + if (Sink.isSink(err.stream)) stderr = Stream.transduce(stderr, err.stream) + + return { stdout, stderr, all: Stream.merge(stdout, stderr) } + } + + const spawn = (command: ChildProcess.StandardCommand, opts: NodeChildProcess.SpawnOptions) => + Effect.callback((resume) => { + const signal = Deferred.makeUnsafe() + const proc = launch(command.command, command.args, opts) + let end = false + let exit: readonly [code: number | null, signal: NodeJS.Signals | null] | undefined + proc.on("error", (err) => { + resume(Effect.fail(toPlatformError("spawn", err, command))) + }) + proc.on("exit", (...args) => { + exit = args + }) + proc.on("close", (...args) => { + if (end) return + end = true + Deferred.doneUnsafe(signal, Exit.succeed(exit ?? args)) + }) + proc.on("spawn", () => { + resume(Effect.succeed([proc, signal])) + }) + return Effect.sync(() => { + proc.kill("SIGTERM") + }) + }) + + const killGroup = ( + command: ChildProcess.StandardCommand, + proc: NodeChildProcess.ChildProcess, + signal: NodeJS.Signals, + ) => { + if (globalThis.process.platform === "win32") { + return Effect.callback((resume) => { + NodeChildProcess.exec(`taskkill /pid ${proc.pid} /T /F`, { windowsHide: true }, (err) => { + if (err) return resume(Effect.fail(toPlatformError("kill", toError(err), command))) + resume(Effect.void) + }) + }) + } + + return Effect.try({ + try: () => { + globalThis.process.kill(-proc.pid!, signal) + }, + catch: (err) => toPlatformError("kill", toError(err), command), + }) + } + + const killOne = ( + command: ChildProcess.StandardCommand, + proc: NodeChildProcess.ChildProcess, + signal: NodeJS.Signals, + ) => + Effect.suspend(() => { + if (proc.kill(signal)) return Effect.void + return Effect.fail(toPlatformError("kill", new Error("Failed to kill child process"), command)) + }) + + const timeout = + ( + proc: NodeChildProcess.ChildProcess, + command: ChildProcess.StandardCommand, + opts: ChildProcess.KillOptions | undefined, + ) => + ( + f: ( + command: ChildProcess.StandardCommand, + proc: NodeChildProcess.ChildProcess, + signal: NodeJS.Signals, + ) => Effect.Effect, + ) => { + const signal = opts?.killSignal ?? "SIGTERM" + if (Predicate.isUndefined(opts?.forceKillAfter)) return f(command, proc, signal) + return Effect.timeoutOrElse(f(command, proc, signal), { + duration: opts.forceKillAfter, + onTimeout: () => f(command, proc, "SIGKILL"), + }) + } + + const source = (handle: ChildProcessHandle, from: ChildProcess.PipeFromOption | undefined) => { + const opt = from ?? "stdout" + switch (opt) { + case "stdout": + return handle.stdout + case "stderr": + return handle.stderr + case "all": + return handle.all + default: { + const fd = ChildProcess.parseFdName(opt) + return Predicate.isNotUndefined(fd) ? handle.getOutputFd(fd) : handle.stdout + } + } + } + + const spawnCommand: ( + command: ChildProcess.Command, + ) => Effect.Effect = Effect.fnUntraced( + function* (command) { + switch (command._tag) { + case "StandardCommand": { + const sin = stdin(command.options) + const sout = stdio(command.options, "stdout") + const serr = stdio(command.options, "stderr") + const extra = fds(command.options) + const dir = yield* cwd(command.options) + + const [proc, signal] = yield* Effect.acquireRelease( + spawn(command, { + cwd: dir, + env: env(command.options), + stdio: stdios(sin, sout, serr, extra), + detached: command.options.detached ?? process.platform !== "win32", + shell: command.options.shell, + windowsHide: process.platform === "win32", + }), + Effect.fnUntraced(function* ([proc, signal]) { + const done = yield* Deferred.isDone(signal) + const kill = timeout(proc, command, command.options) + if (done) { + const [code] = yield* Deferred.await(signal) + if (process.platform === "win32") return yield* Effect.void + if (code !== 0 && Predicate.isNotNull(code)) return yield* Effect.ignore(kill(killGroup)) + return yield* Effect.void + } + return yield* kill((command, proc, signal) => + Effect.catch(killGroup(command, proc, signal), () => killOne(command, proc, signal)), + ).pipe(Effect.andThen(Deferred.await(signal)), Effect.ignore) + }), + ) + + const fd = yield* setupFds(command, proc, extra) + const out = setupOutput(command, proc, sout, serr) + return makeHandle({ + pid: ProcessId(proc.pid!), + stdin: yield* setupStdin(command, proc, sin), + stdout: out.stdout, + stderr: out.stderr, + all: out.all, + getInputFd: fd.getInputFd, + getOutputFd: fd.getOutputFd, + isRunning: Effect.map(Deferred.isDone(signal), (done) => !done), + exitCode: Effect.flatMap(Deferred.await(signal), ([code, signal]) => { + if (Predicate.isNotNull(code)) return Effect.succeed(ExitCode(code)) + return Effect.fail( + toPlatformError( + "exitCode", + new Error(`Process interrupted due to receipt of signal: '${signal}'`), + command, + ), + ) + }), + kill: (opts?: ChildProcess.KillOptions) => + timeout( + proc, + command, + opts, + )((command, proc, signal) => + Effect.catch(killGroup(command, proc, signal), () => killOne(command, proc, signal)), + ).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid), + }) + } + case "PipedCommand": { + const flat = flatten(command) + const [head, ...tail] = flat.commands + let handle = spawnCommand(head) + for (let i = 0; i < tail.length; i++) { + const next = tail[i] + const opts = flat.opts[i] ?? {} + const sin = stdin(next.options) + const stream = Stream.unwrap(Effect.map(handle, (x) => source(x, opts.from))) + const to = opts.to ?? "stdin" + if (to === "stdin") { + handle = spawnCommand( + ChildProcess.make(next.command, next.args, { + ...next.options, + stdin: { ...sin, stream }, + }), + ) + continue + } + const fd = ChildProcess.parseFdName(to) + if (Predicate.isUndefined(fd)) { + handle = spawnCommand( + ChildProcess.make(next.command, next.args, { + ...next.options, + stdin: { ...sin, stream }, + }), + ) + continue + } + handle = spawnCommand( + ChildProcess.make(next.command, next.args, { + ...next.options, + additionalFds: { + ...next.options.additionalFds, + [ChildProcess.fdName(fd) as `fd${number}`]: { type: "input", stream }, + }, + }), + ) + } + return yield* handle + } + } + }, + ) + + return makeSpawner(spawnCommand) +}) + +export const layer: Layer.Layer = Layer.effect( + ChildProcessSpawner, + make, +) diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 3551c861e4..912951a0ba 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -1,6 +1,7 @@ -import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" +import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Effect, Layer, Schema, ServiceMap, Stream } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import { makeRunPromise } from "@/effect/run-service" import { withTransientReadRetry } from "@/util/effect-http-client" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" @@ -340,7 +341,7 @@ export namespace Installation { export const defaultLayer = layer.pipe( Layer.provide(FetchHttpClient.layer), - Layer.provide(NodeChildProcessSpawner.layer), + Layer.provide(CrossSpawnSpawner.layer), Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer), ) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 5f8c5aeffd..7068545d26 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -1,8 +1,9 @@ -import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" +import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import path from "path" import z from "zod" +import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import { InstanceState } from "@/effect/instance-state" import { makeRunPromise } from "@/effect/run-service" import { AppFileSystem } from "@/filesystem" @@ -354,9 +355,9 @@ export namespace Snapshot { ) export const defaultLayer = layer.pipe( - Layer.provide(NodeChildProcessSpawner.layer), + Layer.provide(CrossSpawnSpawner.layer), Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner + Layer.provide(NodeFileSystem.layer), // needed by CrossSpawnSpawner Layer.provide(NodePath.layer), ) diff --git a/packages/opencode/test/effect/cross-spawn-spawner.test.ts b/packages/opencode/test/effect/cross-spawn-spawner.test.ts new file mode 100644 index 0000000000..7fdcb61cd4 --- /dev/null +++ b/packages/opencode/test/effect/cross-spawn-spawner.test.ts @@ -0,0 +1,518 @@ +import { NodeFileSystem, NodePath } from "@effect/platform-node" +import { describe, expect } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" +import { Effect, Exit, Layer, Stream } from "effect" +import type * as PlatformError from "effect/PlatformError" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { tmpdir } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const live = CrossSpawnSpawner.layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer)) +const fx = testEffect(live) + +function js(code: string, opts?: ChildProcess.CommandOptions) { + return ChildProcess.make("node", ["-e", code], opts) +} + +function decodeByteStream(stream: Stream.Stream) { + return Stream.runCollect(stream).pipe( + Effect.map((chunks) => { + const total = chunks.reduce((acc, x) => acc + x.length, 0) + const out = new Uint8Array(total) + let off = 0 + for (const chunk of chunks) { + out.set(chunk, off) + off += chunk.length + } + return new TextDecoder("utf-8").decode(out).trim() + }), + ) +} + +function alive(pid: number) { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +async function gone(pid: number, timeout = 5_000) { + const end = Date.now() + timeout + while (Date.now() < end) { + if (!alive(pid)) return true + await Bun.sleep(50) + } + return !alive(pid) +} + +describe("cross-spawn spawner", () => { + describe("basic spawning", () => { + fx.effect( + "captures stdout", + Effect.gen(function* () { + const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) => + svc.string(ChildProcess.make(process.execPath, ["-e", 'process.stdout.write("ok")'])), + ) + expect(out).toBe("ok") + }), + ) + + fx.effect( + "captures multiple lines", + Effect.gen(function* () { + const handle = yield* js('console.log("line1"); console.log("line2"); console.log("line3")') + const out = yield* decodeByteStream(handle.stdout) + expect(out).toBe("line1\nline2\nline3") + }), + ) + + fx.effect( + "returns exit code", + Effect.gen(function* () { + const handle = yield* js("process.exit(0)") + const code = yield* handle.exitCode + expect(code).toBe(ChildProcessSpawner.ExitCode(0)) + }), + ) + + fx.effect( + "returns non-zero exit code", + Effect.gen(function* () { + const handle = yield* js("process.exit(42)") + const code = yield* handle.exitCode + expect(code).toBe(ChildProcessSpawner.ExitCode(42)) + }), + ) + }) + + describe("cwd option", () => { + fx.effect( + "uses cwd when spawning commands", + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) => + svc.string( + ChildProcess.make(process.execPath, ["-e", "process.stdout.write(process.cwd())"], { cwd: tmp.path }), + ), + ) + expect(out).toBe(tmp.path) + }), + ) + + fx.effect( + "fails for invalid cwd", + Effect.gen(function* () { + const exit = yield* Effect.exit( + ChildProcess.make("echo", ["test"], { cwd: "/nonexistent/directory/path" }).asEffect(), + ) + expect(Exit.isFailure(exit)).toBe(true) + }), + ) + }) + + describe("env option", () => { + fx.effect( + "passes environment variables with extendEnv", + Effect.gen(function* () { + const handle = yield* js('process.stdout.write(process.env.TEST_VAR ?? "")', { + env: { TEST_VAR: "test_value" }, + extendEnv: true, + }) + const out = yield* decodeByteStream(handle.stdout) + expect(out).toBe("test_value") + }), + ) + + fx.effect( + "passes multiple environment variables", + Effect.gen(function* () { + const handle = yield* js( + "process.stdout.write(`${process.env.VAR1}-${process.env.VAR2}-${process.env.VAR3}`)", + { + env: { VAR1: "one", VAR2: "two", VAR3: "three" }, + extendEnv: true, + }, + ) + const out = yield* decodeByteStream(handle.stdout) + expect(out).toBe("one-two-three") + }), + ) + }) + + describe("stderr", () => { + fx.effect( + "captures stderr output", + Effect.gen(function* () { + const handle = yield* js('process.stderr.write("error message")') + const err = yield* decodeByteStream(handle.stderr) + expect(err).toBe("error message") + }), + ) + + fx.effect( + "captures both stdout and stderr", + Effect.gen(function* () { + const handle = yield* js('process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")') + const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)]) + expect(stdout).toBe("stdout") + expect(stderr).toBe("stderr") + }), + ) + }) + + describe("combined output (all)", () => { + fx.effect( + "captures stdout via .all when no stderr", + Effect.gen(function* () { + const handle = yield* ChildProcess.make("echo", ["hello from stdout"]) + const all = yield* decodeByteStream(handle.all) + expect(all).toBe("hello from stdout") + }), + ) + + fx.effect( + "captures stderr via .all when no stdout", + Effect.gen(function* () { + const handle = yield* js('process.stderr.write("hello from stderr")') + const all = yield* decodeByteStream(handle.all) + expect(all).toBe("hello from stderr") + }), + ) + }) + + describe("stdin", () => { + fx.effect( + "allows providing standard input to a command", + Effect.gen(function* () { + const input = "a b c" + const stdin = Stream.make(Buffer.from(input, "utf-8")) + const handle = yield* js( + 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))', + { stdin }, + ) + const out = yield* decodeByteStream(handle.stdout) + yield* handle.exitCode + expect(out).toBe("a b c") + }), + ) + }) + + describe("process control", () => { + fx.effect( + "kills a running process", + Effect.gen(function* () { + const exit = yield* Effect.exit( + Effect.gen(function* () { + const handle = yield* js("setTimeout(() => {}, 10_000)") + yield* handle.kill() + return yield* handle.exitCode + }), + ) + expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true) + }), + ) + + fx.effect( + "kills a child when scope exits", + Effect.gen(function* () { + const pid = yield* Effect.scoped( + Effect.gen(function* () { + const handle = yield* js("setInterval(() => {}, 10_000)") + return Number(handle.pid) + }), + ) + const done = yield* Effect.promise(() => gone(pid)) + expect(done).toBe(true) + }), + ) + + fx.effect( + "forceKillAfter escalates for stubborn processes", + Effect.gen(function* () { + if (process.platform === "win32") return + + const started = Date.now() + const exit = yield* Effect.exit( + Effect.gen(function* () { + const handle = yield* js('process.on("SIGTERM", () => {}); setInterval(() => {}, 10_000)') + yield* handle.kill({ forceKillAfter: 100 }) + return yield* handle.exitCode + }), + ) + + expect(Date.now() - started).toBeLessThan(1_000) + expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true) + }), + ) + + fx.effect( + "isRunning reflects process state", + Effect.gen(function* () { + const handle = yield* js('process.stdout.write("done")') + yield* handle.exitCode + const running = yield* handle.isRunning + expect(running).toBe(false) + }), + ) + }) + + describe("error handling", () => { + fx.effect( + "fails for invalid command", + Effect.gen(function* () { + const exit = yield* Effect.exit( + Effect.gen(function* () { + const handle = yield* ChildProcess.make("nonexistent-command-12345") + return yield* handle.exitCode + }), + ) + expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true) + }), + ) + }) + + describe("pipeline", () => { + fx.effect( + "pipes stdout of one command to stdin of another", + Effect.gen(function* () { + const handle = yield* js('process.stdout.write("hello world")').pipe( + ChildProcess.pipeTo( + js( + 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.toUpperCase()))', + ), + ), + ) + const out = yield* decodeByteStream(handle.stdout) + yield* handle.exitCode + expect(out).toBe("HELLO WORLD") + }), + ) + + fx.effect( + "three-stage pipeline", + Effect.gen(function* () { + const handle = yield* js('process.stdout.write("hello world")').pipe( + ChildProcess.pipeTo( + js( + 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.toUpperCase()))', + ), + ), + ChildProcess.pipeTo( + js( + 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.replaceAll(" ", "-")))', + ), + ), + ) + const out = yield* decodeByteStream(handle.stdout) + yield* handle.exitCode + expect(out).toBe("HELLO-WORLD") + }), + ) + + fx.effect( + "pipes stderr with { from: 'stderr' }", + Effect.gen(function* () { + const handle = yield* js('process.stderr.write("error")').pipe( + ChildProcess.pipeTo( + js( + 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))', + ), + { from: "stderr" }, + ), + ) + const out = yield* decodeByteStream(handle.stdout) + yield* handle.exitCode + expect(out).toBe("error") + }), + ) + + fx.effect( + "pipes combined output with { from: 'all' }", + Effect.gen(function* () { + const handle = yield* js('process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")').pipe( + ChildProcess.pipeTo( + js( + 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))', + ), + { from: "all" }, + ), + ) + const out = yield* decodeByteStream(handle.stdout) + yield* handle.exitCode + expect(out).toContain("stdout") + expect(out).toContain("stderr") + }), + ) + + fx.effect( + "pipes output fd3 with { from: 'fd3' }", + Effect.gen(function* () { + const handle = yield* js('require("node:fs").writeSync(3, "hello from fd3\\n")', { + additionalFds: { fd3: { type: "output" } }, + }).pipe( + ChildProcess.pipeTo( + js( + 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))', + ), + { from: "fd3" }, + ), + ) + const out = yield* decodeByteStream(handle.stdout) + yield* handle.exitCode + expect(out).toBe("hello from fd3") + }), + ) + + fx.effect( + "pipes stdout to fd3", + Effect.gen(function* () { + if (process.platform === "win32") return + + const handle = yield* js('process.stdout.write("hello from stdout")').pipe( + ChildProcess.pipeTo(js('process.stdout.write(require("node:fs").readFileSync(3, "utf8"))'), { to: "fd3" }), + ) + const out = yield* decodeByteStream(handle.stdout) + yield* handle.exitCode + expect(out).toBe("hello from stdout") + }), + ) + }) + + describe("additional fds", () => { + fx.effect( + "reads data from output fd3", + Effect.gen(function* () { + const handle = yield* js('require("node:fs").writeSync(3, "hello from fd3\\n")', { + additionalFds: { fd3: { type: "output" } }, + }) + const out = yield* decodeByteStream(handle.getOutputFd(3)) + yield* handle.exitCode + expect(out).toBe("hello from fd3") + }), + ) + + fx.effect( + "writes data to input fd3", + Effect.gen(function* () { + if (process.platform === "win32") return + + const input = Stream.make(new TextEncoder().encode("data from parent")) + const handle = yield* js('process.stdout.write(require("node:fs").readFileSync(3, "utf8"))', { + additionalFds: { fd3: { type: "input", stream: input } }, + }) + const out = yield* decodeByteStream(handle.stdout) + yield* handle.exitCode + expect(out).toBe("data from parent") + }), + ) + + fx.effect( + "returns empty stream for unconfigured fd", + Effect.gen(function* () { + const handle = + process.platform === "win32" + ? yield* js('process.stdout.write("test")') + : yield* ChildProcess.make("echo", ["test"]) + const out = yield* decodeByteStream(handle.getOutputFd(3)) + yield* handle.exitCode + expect(out).toBe("") + }), + ) + + fx.effect( + "works alongside normal stdout and stderr", + Effect.gen(function* () { + const handle = yield* js( + 'require("node:fs").writeSync(3, "fd3\\n"); process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")', + { + additionalFds: { fd3: { type: "output" } }, + }, + ) + const stdout = yield* decodeByteStream(handle.stdout) + const stderr = yield* decodeByteStream(handle.stderr) + const fd3 = yield* decodeByteStream(handle.getOutputFd(3)) + yield* handle.exitCode + expect(stdout).toBe("stdout") + expect(stderr).toBe("stderr") + expect(fd3).toBe("fd3") + }), + ) + }) + + describe("large output", () => { + fx.effect( + "does not deadlock on large stdout", + Effect.gen(function* () { + const handle = yield* js("for (let i = 1; i <= 100000; i++) process.stdout.write(`${i}\\n`)") + const out = yield* handle.stdout.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + ) + yield* handle.exitCode + const lines = out.trim().split("\n") + expect(lines.length).toBe(100000) + expect(lines[0]).toBe("1") + expect(lines[99999]).toBe("100000") + }), + { timeout: 10_000 }, + ) + }) + + describe("Windows-specific", () => { + fx.effect( + "uses shell routing on Windows", + Effect.gen(function* () { + if (process.platform !== "win32") return + + const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) => + svc.string( + ChildProcess.make("set", ["OPENCODE_TEST_SHELL"], { + shell: true, + extendEnv: true, + env: { OPENCODE_TEST_SHELL: "ok" }, + }), + ), + ) + expect(out).toContain("OPENCODE_TEST_SHELL=ok") + }), + ) + + fx.effect( + "runs cmd scripts with spaces on Windows without shell", + Effect.gen(function* () { + if (process.platform !== "win32") return + + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const dir = path.join(tmp.path, "with space") + const file = path.join(dir, "echo cmd.cmd") + + yield* Effect.promise(() => fs.mkdir(dir, { recursive: true })) + yield* Effect.promise(() => Bun.write(file, "@echo off\r\nif %~1==--stdio exit /b 0\r\nexit /b 7\r\n")) + + const code = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) => + svc.exitCode( + ChildProcess.make(file, ["--stdio"], { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }), + ), + ) + expect(code).toBe(ChildProcessSpawner.ExitCode(0)) + }), + ) + }) +}) From 037077285ac36b8a427aa330d331e099360f1e55 Mon Sep 17 00:00:00 2001 From: Caleb Norton Date: Tue, 24 Mar 2026 11:30:39 -0500 Subject: [PATCH 09/48] fix: better nix hash detection (#18957) --- .github/workflows/nix-hashes.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/nix-hashes.yml b/.github/workflows/nix-hashes.yml index 2529c14c20..9ebdb01882 100644 --- a/.github/workflows/nix-hashes.yml +++ b/.github/workflows/nix-hashes.yml @@ -56,7 +56,7 @@ jobs: nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true # Extract hash from build log with portability - HASH="$(grep -oE 'sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)" + HASH="$(grep -oE 'got:\s*sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)" if [ -z "$HASH" ]; then echo "::error::Failed to compute hash for ${SYSTEM}" From 31c4a4fb478d765d39ead26f81db9bf5ab54eb6c Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 24 Mar 2026 16:43:24 +0000 Subject: [PATCH 10/48] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 2065007431..944cb2c5b6 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-nMERinypUtIZGfLlAS5meYrvH5tTl2SkdG3GUguhOos=", - "aarch64-linux": "sha256-aQ42YVcjXSxpweA3e0SfJ8mnMWEqGeIOKg1cIhn8szA=", - "aarch64-darwin": "sha256-OGtUfhKWTRqi8bYcqkvfb1RZa3iS0DVy5bbRry47Og4=", - "x86_64-darwin": "sha256-kdzsr67cGduvGl+4UVdngiKNCaVw88WeMgx1ckVbG30=" + "x86_64-linux": "got:sha256-nMERinypUtIZGfLlAS5meYrvH5tTl2SkdG3GUguhOos=", + "aarch64-linux": "got:sha256-aQ42YVcjXSxpweA3e0SfJ8mnMWEqGeIOKg1cIhn8szA=", + "aarch64-darwin": "got:sha256-OGtUfhKWTRqi8bYcqkvfb1RZa3iS0DVy5bbRry47Og4=", + "x86_64-darwin": "got:sha256-kdzsr67cGduvGl+4UVdngiKNCaVw88WeMgx1ckVbG30=" } } From 7c5ed771c36f5acbd47a1070afc1935e8a50650b Mon Sep 17 00:00:00 2001 From: Jack Date: Wed, 25 Mar 2026 01:03:01 +0800 Subject: [PATCH 11/48] fix: update Feishu community links for zh locales (#18975) --- README.zh.md | 2 +- README.zht.md | 2 +- packages/console/app/src/routes/feishu.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.zh.md b/README.zh.md index 0859ed11d0..46d9f761cb 100644 --- a/README.zh.md +++ b/README.zh.md @@ -137,4 +137,4 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换: --- -**加入我们的社区** [飞书](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) +**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode) diff --git a/README.zht.md b/README.zht.md index b7d8b8fc47..7ef51d8fdd 100644 --- a/README.zht.md +++ b/README.zht.md @@ -137,4 +137,4 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。 --- -**加入我們的社群** [飞书](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) +**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode) diff --git a/packages/console/app/src/routes/feishu.ts b/packages/console/app/src/routes/feishu.ts index 35d2fcb0e2..3366e7208b 100644 --- a/packages/console/app/src/routes/feishu.ts +++ b/packages/console/app/src/routes/feishu.ts @@ -2,6 +2,6 @@ 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", + "https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true", ) } From 5c1bb5de86d62bd598a89cd1ba0c1c02de103a90 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 24 Mar 2026 13:04:04 -0400 Subject: [PATCH 12/48] fix: remove flaky cross-spawn spawner tests (#18977) --- .../test/effect/cross-spawn-spawner.test.ts | 114 ------------------ 1 file changed, 114 deletions(-) diff --git a/packages/opencode/test/effect/cross-spawn-spawner.test.ts b/packages/opencode/test/effect/cross-spawn-spawner.test.ts index 7fdcb61cd4..6da0715212 100644 --- a/packages/opencode/test/effect/cross-spawn-spawner.test.ts +++ b/packages/opencode/test/effect/cross-spawn-spawner.test.ts @@ -351,122 +351,8 @@ describe("cross-spawn spawner", () => { }), ) - fx.effect( - "pipes output fd3 with { from: 'fd3' }", - Effect.gen(function* () { - const handle = yield* js('require("node:fs").writeSync(3, "hello from fd3\\n")', { - additionalFds: { fd3: { type: "output" } }, - }).pipe( - ChildProcess.pipeTo( - js( - 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))', - ), - { from: "fd3" }, - ), - ) - const out = yield* decodeByteStream(handle.stdout) - yield* handle.exitCode - expect(out).toBe("hello from fd3") - }), - ) - - fx.effect( - "pipes stdout to fd3", - Effect.gen(function* () { - if (process.platform === "win32") return - - const handle = yield* js('process.stdout.write("hello from stdout")').pipe( - ChildProcess.pipeTo(js('process.stdout.write(require("node:fs").readFileSync(3, "utf8"))'), { to: "fd3" }), - ) - const out = yield* decodeByteStream(handle.stdout) - yield* handle.exitCode - expect(out).toBe("hello from stdout") - }), - ) }) - describe("additional fds", () => { - fx.effect( - "reads data from output fd3", - Effect.gen(function* () { - const handle = yield* js('require("node:fs").writeSync(3, "hello from fd3\\n")', { - additionalFds: { fd3: { type: "output" } }, - }) - const out = yield* decodeByteStream(handle.getOutputFd(3)) - yield* handle.exitCode - expect(out).toBe("hello from fd3") - }), - ) - - fx.effect( - "writes data to input fd3", - Effect.gen(function* () { - if (process.platform === "win32") return - - const input = Stream.make(new TextEncoder().encode("data from parent")) - const handle = yield* js('process.stdout.write(require("node:fs").readFileSync(3, "utf8"))', { - additionalFds: { fd3: { type: "input", stream: input } }, - }) - const out = yield* decodeByteStream(handle.stdout) - yield* handle.exitCode - expect(out).toBe("data from parent") - }), - ) - - fx.effect( - "returns empty stream for unconfigured fd", - Effect.gen(function* () { - const handle = - process.platform === "win32" - ? yield* js('process.stdout.write("test")') - : yield* ChildProcess.make("echo", ["test"]) - const out = yield* decodeByteStream(handle.getOutputFd(3)) - yield* handle.exitCode - expect(out).toBe("") - }), - ) - - fx.effect( - "works alongside normal stdout and stderr", - Effect.gen(function* () { - const handle = yield* js( - 'require("node:fs").writeSync(3, "fd3\\n"); process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")', - { - additionalFds: { fd3: { type: "output" } }, - }, - ) - const stdout = yield* decodeByteStream(handle.stdout) - const stderr = yield* decodeByteStream(handle.stderr) - const fd3 = yield* decodeByteStream(handle.getOutputFd(3)) - yield* handle.exitCode - expect(stdout).toBe("stdout") - expect(stderr).toBe("stderr") - expect(fd3).toBe("fd3") - }), - ) - }) - - describe("large output", () => { - fx.effect( - "does not deadlock on large stdout", - Effect.gen(function* () { - const handle = yield* js("for (let i = 1; i <= 100000; i++) process.stdout.write(`${i}\\n`)") - const out = yield* handle.stdout.pipe( - Stream.decodeText(), - Stream.runFold( - () => "", - (acc, chunk) => acc + chunk, - ), - ) - yield* handle.exitCode - const lines = out.trim().split("\n") - expect(lines.length).toBe(100000) - expect(lines[0]).toBe("1") - expect(lines[99999]).toBe("100000") - }), - { timeout: 10_000 }, - ) - }) describe("Windows-specific", () => { fx.effect( From 1d3232b3885daa471309abab59e145d8e16f1736 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 24 Mar 2026 17:05:02 +0000 Subject: [PATCH 13/48] chore: generate --- packages/opencode/test/effect/cross-spawn-spawner.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/opencode/test/effect/cross-spawn-spawner.test.ts b/packages/opencode/test/effect/cross-spawn-spawner.test.ts index 6da0715212..08cae76e2f 100644 --- a/packages/opencode/test/effect/cross-spawn-spawner.test.ts +++ b/packages/opencode/test/effect/cross-spawn-spawner.test.ts @@ -350,10 +350,8 @@ describe("cross-spawn spawner", () => { expect(out).toContain("stderr") }), ) - }) - describe("Windows-specific", () => { fx.effect( "uses shell routing on Windows", From 1238d1f61acccf05330ff8fb59f3e355239b5f82 Mon Sep 17 00:00:00 2001 From: Caleb Norton Date: Tue, 24 Mar 2026 12:32:48 -0500 Subject: [PATCH 14/48] fix: nix hash update parsing (#18979) --- .github/workflows/nix-hashes.yml | 2 +- nix/hashes.json | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/nix-hashes.yml b/.github/workflows/nix-hashes.yml index 9ebdb01882..47385d20c6 100644 --- a/.github/workflows/nix-hashes.yml +++ b/.github/workflows/nix-hashes.yml @@ -56,7 +56,7 @@ jobs: nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true # Extract hash from build log with portability - HASH="$(grep -oE 'got:\s*sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)" + HASH="$(grep -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)" if [ -z "$HASH" ]; then echo "::error::Failed to compute hash for ${SYSTEM}" diff --git a/nix/hashes.json b/nix/hashes.json index 944cb2c5b6..2065007431 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "got:sha256-nMERinypUtIZGfLlAS5meYrvH5tTl2SkdG3GUguhOos=", - "aarch64-linux": "got:sha256-aQ42YVcjXSxpweA3e0SfJ8mnMWEqGeIOKg1cIhn8szA=", - "aarch64-darwin": "got:sha256-OGtUfhKWTRqi8bYcqkvfb1RZa3iS0DVy5bbRry47Og4=", - "x86_64-darwin": "got:sha256-kdzsr67cGduvGl+4UVdngiKNCaVw88WeMgx1ckVbG30=" + "x86_64-linux": "sha256-nMERinypUtIZGfLlAS5meYrvH5tTl2SkdG3GUguhOos=", + "aarch64-linux": "sha256-aQ42YVcjXSxpweA3e0SfJ8mnMWEqGeIOKg1cIhn8szA=", + "aarch64-darwin": "sha256-OGtUfhKWTRqi8bYcqkvfb1RZa3iS0DVy5bbRry47Og4=", + "x86_64-darwin": "sha256-kdzsr67cGduvGl+4UVdngiKNCaVw88WeMgx1ckVbG30=" } } From 9330bc5339b3ca82975f768200450d4c9aabcd35 Mon Sep 17 00:00:00 2001 From: Vladimir Glafirov Date: Tue, 24 Mar 2026 18:33:18 +0100 Subject: [PATCH 15/48] fix: route GitLab Duo Workflow system prompt via flowConfig (#18928) --- bun.lock | 4 ++-- packages/opencode/package.json | 2 +- packages/opencode/src/session/llm.ts | 22 +++++++++++++--------- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/bun.lock b/bun.lock index 8db8852a0b..36b8f77483 100644 --- a/bun.lock +++ b/bun.lock @@ -358,7 +358,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "5.3.1", + "gitlab-ai-provider": "5.3.2", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -3036,7 +3036,7 @@ "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], - "gitlab-ai-provider": ["gitlab-ai-provider@5.3.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-QeNP2af/5wyOHYaLvDxn72n4xbMbJNqRiKExZJM8MnynebnqnoaJoojbtue7roCl/XcnjX6Of2+oc7hS44S45Q=="], + "gitlab-ai-provider": ["gitlab-ai-provider@5.3.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-EiAipDMa4Ngsxp4MMaua5YHWsHhc9kGXKmBxulJg1Gueb+5IZmMwxaVtgWTGWZITxC3tzKEeRt/3U4McE2vTIA=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 39b4e6232b..97a6457cf9 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -121,7 +121,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "5.3.1", + "gitlab-ai-provider": "5.3.2", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index a22c6d8560..075f070e42 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -113,17 +113,20 @@ export namespace LLM { options.instructions = system.join("\n") } + const isWorkflow = language instanceof GitLabWorkflowLanguageModel const messages = isOpenaiOauth ? input.messages - : [ - ...system.map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), - ...input.messages, - ] + : isWorkflow + ? input.messages + : [ + ...system.map( + (x): ModelMessage => ({ + role: "system", + content: x, + }), + ), + ...input.messages, + ] const params = await Plugin.trigger( "chat.params", @@ -190,6 +193,7 @@ export namespace LLM { // and results sent back over the WebSocket. if (language instanceof GitLabWorkflowLanguageModel) { const workflowModel = language + workflowModel.systemPrompt = system.join("\n") workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => { const t = tools[toolName] if (!t || !t.execute) { From 235a82aea97cd35c190bc95e916be5bdc0cce04a Mon Sep 17 00:00:00 2001 From: Caleb Norton Date: Tue, 24 Mar 2026 12:50:25 -0500 Subject: [PATCH 16/48] chore: update flake.lock (#18976) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index 59eb118fa4..805be8739b 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1772091128, - "narHash": "sha256-TnrYykX8Mf/Ugtkix6V+PjW7miU2yClA6uqWl/v6KWM=", + "lastModified": 1773909469, + "narHash": "sha256-vglVrLfHjFIzIdV9A27Ugul6rh3I1qHbbitGW7dk420=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "3f0336406035444b4a24b942788334af5f906259", + "rev": "7149c06513f335be57f26fcbbbe34afda923882b", "type": "github" }, "original": { From 814a515a8a2f474585ea061a99e1058b2bb8b374 Mon Sep 17 00:00:00 2001 From: Ryan Skidmore Date: Tue, 24 Mar 2026 12:50:55 -0500 Subject: [PATCH 17/48] =?UTF-8?q?fix:=20improve=20plugin=20system=20robust?= =?UTF-8?q?ness=20=E2=80=94=20agent/command=20resolution,=20async=20errors?= =?UTF-8?q?,=20hook=20timing,=20two-phase=20init=20(#18280)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> --- packages/opencode/src/plugin/index.ts | 6 +- .../opencode/src/server/routes/session.ts | 10 ++- packages/opencode/src/session/prompt.ts | 52 ++++++++++++- .../test/plugin/auth-override.test.ts | 16 ++++ .../test/server/session-messages.test.ts | 13 ++++ packages/opencode/test/session/prompt.test.ts | 76 +++++++++++++++++++ 6 files changed, 169 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 57dcff8f67..e519f9f350 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -136,7 +136,11 @@ export namespace Plugin { // Notify plugins of current config for (const hook of hooks) { - await (hook as any).config?.(cfg) + try { + await (hook as any).config?.(cfg) + } catch (err) { + log.error("plugin config hook failed", { error: err }) + } } }) diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index abc820c2af..3c9ebfdc5e 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -19,6 +19,8 @@ import { PermissionID } from "@/permission/schema" import { ModelID, ProviderID } from "@/provider/schema" import { errors } from "../error" import { lazy } from "../../util/lazy" +import { Bus } from "../../bus" +import { NamedError } from "@opencode-ai/util/error" const log = Log.create({ service: "server" }) @@ -846,7 +848,13 @@ export const SessionRoutes = lazy(() => return stream(c, async () => { const sessionID = c.req.valid("param").sessionID const body = c.req.valid("json") - SessionPrompt.prompt({ ...body, sessionID }) + SessionPrompt.prompt({ ...body, sessionID }).catch((err) => { + log.error("prompt_async failed", { sessionID, error: err }) + Bus.publish(Session.Event.Error, { + sessionID, + error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(), + }) + }) }) }, ) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index dca8085c5b..b3c34539e7 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -418,6 +418,16 @@ export namespace SessionPrompt { ) let executionError: Error | undefined const taskAgent = await Agent.get(task.agent) + if (!taskAgent) { + const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name)) + const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" + const error = new NamedError.Unknown({ message: `Agent not found: "${task.agent}".${hint}` }) + Bus.publish(Session.Event.Error, { + sessionID, + error: error.toObject(), + }) + throw error + } const taskCtx: Tool.Context = { agent: task.agent, messageID: assistantMessage.id, @@ -560,6 +570,16 @@ export namespace SessionPrompt { // normal processing const agent = await Agent.get(lastUser.agent) + if (!agent) { + const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name)) + const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" + const error = new NamedError.Unknown({ message: `Agent not found: "${lastUser.agent}".${hint}` }) + Bus.publish(Session.Event.Error, { + sessionID, + error: error.toObject(), + }) + throw error + } const maxSteps = agent.steps ?? Infinity const isLastStep = step >= maxSteps msgs = await insertReminders({ @@ -964,7 +984,18 @@ export namespace SessionPrompt { } async function createUserMessage(input: PromptInput) { - const agent = await Agent.get(input.agent ?? (await Agent.defaultAgent())) + const agentName = input.agent || (await Agent.defaultAgent()) + const agent = await Agent.get(agentName) + if (!agent) { + const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name)) + const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" + const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` }) + Bus.publish(Session.Event.Error, { + sessionID: input.sessionID, + error: error.toObject(), + }) + throw error + } const model = input.model ?? agent.model ?? (await lastModel(input.sessionID)) const full = @@ -1531,6 +1562,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the await SessionRevert.cleanup(session) } const agent = await Agent.get(input.agent) + if (!agent) { + const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name)) + const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" + const error = new NamedError.Unknown({ message: `Agent not found: "${input.agent}".${hint}` }) + Bus.publish(Session.Event.Error, { + sessionID: input.sessionID, + error: error.toObject(), + }) + throw error + } const model = input.model ?? agent.model ?? (await lastModel(input.sessionID)) const userMsg: MessageV2.User = { id: MessageID.ascending(), @@ -1783,7 +1824,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the log.info("command", input) const command = await Command.get(input.command) if (!command) { - throw new NamedError.Unknown({ message: `Command not found: "${input.command}"` }) + const available = await Command.list().then((cmds) => cmds.map((c) => c.name)) + const hint = available.length ? ` Available commands: ${available.join(", ")}` : "" + const error = new NamedError.Unknown({ message: `Command not found: "${input.command}".${hint}` }) + Bus.publish(Session.Event.Error, { + sessionID: input.sessionID, + error: error.toObject(), + }) + throw error } const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent()) diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts index b967262254..667b7ba9aa 100644 --- a/packages/opencode/test/plugin/auth-override.test.ts +++ b/packages/opencode/test/plugin/auth-override.test.ts @@ -54,3 +54,19 @@ describe("plugin.auth-override", () => { expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth") }, 30000) // Increased timeout for plugin installation }) + +const file = path.join(import.meta.dir, "../../src/plugin/index.ts") + +describe("plugin.config-hook-error-isolation", () => { + test("config hooks are individually error-isolated in the layer factory", async () => { + const src = await Bun.file(file).text() + + // The config hook try/catch lives in the InstanceState factory (layer definition), + // not in init() which now just delegates to the Effect service. + expect(src).toContain("plugin config hook failed") + + const pattern = + /for\s*\(const hook of hooks\)\s*\{[\s\S]*?try\s*\{[\s\S]*?\.config\?\.\([\s\S]*?\}\s*catch\s*\(err\)\s*\{[\s\S]*?plugin config hook failed[\s\S]*?\}/ + expect(pattern.test(src)).toBe(true) + }) +}) diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index ee4c51646f..91e0fd9263 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -117,3 +117,16 @@ describe("session messages endpoint", () => { }) }) }) + +describe("session.prompt_async error handling", () => { + test("prompt_async route has error handler for detached prompt call", async () => { + const src = await Bun.file(path.join(import.meta.dir, "../../src/server/routes/session.ts")).text() + const start = src.indexOf('"/:sessionID/prompt_async"') + const end = src.indexOf('"/:sessionID/command"', start) + expect(start).toBeGreaterThan(-1) + expect(end).toBeGreaterThan(start) + const route = src.slice(start, end) + expect(route).toContain(".catch(") + expect(route).toContain("Bus.publish(Session.Event.Error") + }) +}) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 3986271dab..7d1d429057 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1,5 +1,6 @@ import path from "path" import { describe, expect, test } from "bun:test" +import { NamedError } from "@opencode-ai/util/error" import { fileURLToPath } from "url" import { Instance } from "../../src/project/instance" import { ModelID, ProviderID } from "../../src/provider/schema" @@ -210,3 +211,78 @@ describe("session.prompt agent variant", () => { } }) }) + +describe("session.agent-resolution", () => { + test("unknown agent throws typed error", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const err = await SessionPrompt.prompt({ + sessionID: session.id, + agent: "nonexistent-agent-xyz", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }).then( + () => undefined, + (e) => e, + ) + expect(err).toBeDefined() + expect(err).not.toBeInstanceOf(TypeError) + expect(NamedError.Unknown.isInstance(err)).toBe(true) + if (NamedError.Unknown.isInstance(err)) { + expect(err.data.message).toContain('Agent not found: "nonexistent-agent-xyz"') + } + }, + }) + }, 30000) + + test("unknown agent error includes available agent names", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const err = await SessionPrompt.prompt({ + sessionID: session.id, + agent: "nonexistent-agent-xyz", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }).then( + () => undefined, + (e) => e, + ) + expect(NamedError.Unknown.isInstance(err)).toBe(true) + if (NamedError.Unknown.isInstance(err)) { + expect(err.data.message).toContain("build") + } + }, + }) + }, 30000) + + test("unknown command throws typed error with available names", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const err = await SessionPrompt.command({ + sessionID: session.id, + command: "nonexistent-command-xyz", + arguments: "", + }).then( + () => undefined, + (e) => e, + ) + expect(err).toBeDefined() + expect(err).not.toBeInstanceOf(TypeError) + expect(NamedError.Unknown.isInstance(err)).toBe(true) + if (NamedError.Unknown.isInstance(err)) { + expect(err.data.message).toContain('Command not found: "nonexistent-command-xyz"') + expect(err.data.message).toContain("init") + } + }, + }) + }, 30000) +}) From 539b01f20fc3677155b3bdbb428c69423a805578 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 24 Mar 2026 14:04:22 -0400 Subject: [PATCH 18/48] effectify Project service (#18808) --- packages/opencode/specs/effect-migration.md | 2 +- packages/opencode/src/project/project.ts | 728 ++++++++++-------- .../opencode/src/server/routes/project.ts | 2 +- .../opencode/test/project/project.test.ts | 302 +++++--- 4 files changed, 578 insertions(+), 456 deletions(-) diff --git a/packages/opencode/specs/effect-migration.md b/packages/opencode/specs/effect-migration.md index 12017b0e45..cf217871da 100644 --- a/packages/opencode/specs/effect-migration.md +++ b/packages/opencode/specs/effect-migration.md @@ -173,6 +173,6 @@ Still open and likely worth migrating: - [ ] `SessionPrompt` - [ ] `SessionCompaction` - [ ] `Provider` -- [ ] `Project` +- [x] `Project` - [ ] `LSP` - [ ] `MCP` diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 1cef41c85c..3d20f58d45 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -1,36 +1,23 @@ import z from "zod" -import { Filesystem } from "../util/filesystem" -import path from "path" import { and, Database, eq } from "../storage/db" import { ProjectTable } from "./project.sql" import { SessionTable } from "../session/session.sql" import { Log } from "../util/log" import { Flag } from "@/flag/flag" -import { fn } from "@opencode-ai/util/fn" import { BusEvent } from "@/bus/bus-event" -import { iife } from "@/util/iife" import { GlobalBus } from "@/bus/global" -import { existsSync } from "fs" -import { git } from "../util/git" -import { Glob } from "../util/glob" import { which } from "../util/which" import { ProjectID } from "./schema" +import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import { NodeFileSystem, NodePath } from "@effect/platform-node" +import { makeRunPromise } from "@/effect/run-service" +import { AppFileSystem } from "@/filesystem" +import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" export namespace Project { const log = Log.create({ service: "project" }) - function gitpath(cwd: string, name: string) { - if (!name) return cwd - // git output includes trailing newlines; keep path whitespace intact. - name = name.replace(/[\r\n]+$/, "") - if (!name) return cwd - - name = Filesystem.windowsPath(name) - - if (path.isAbsolute(name)) return path.normalize(name) - return path.resolve(cwd, name) - } - export const Info = z .object({ id: ProjectID.zod, @@ -73,7 +60,7 @@ export namespace Project { ? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined } : undefined return { - id: ProjectID.make(row.id), + id: row.id, worktree: row.worktree, vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined, name: row.name ?? undefined, @@ -88,245 +75,401 @@ export namespace Project { } } - function readCachedId(dir: string) { - return Filesystem.readText(path.join(dir, "opencode")) - .then((x) => x.trim()) - .then(ProjectID.make) - .catch(() => undefined) + export const UpdateInput = z.object({ + projectID: ProjectID.zod, + name: z.string().optional(), + icon: Info.shape.icon.optional(), + commands: Info.shape.commands.optional(), + }) + export type UpdateInput = z.infer + + // --------------------------------------------------------------------------- + // Effect service + // --------------------------------------------------------------------------- + + export interface Interface { + readonly fromDirectory: (directory: string) => Effect.Effect<{ project: Info; sandbox: string }> + readonly discover: (input: Info) => Effect.Effect + readonly list: () => Effect.Effect + readonly get: (id: ProjectID) => Effect.Effect + readonly update: (input: UpdateInput) => Effect.Effect + readonly initGit: (input: { directory: string; project: Info }) => Effect.Effect + readonly setInitialized: (id: ProjectID) => Effect.Effect + readonly sandboxes: (id: ProjectID) => Effect.Effect + readonly addSandbox: (id: ProjectID, directory: string) => Effect.Effect + readonly removeSandbox: (id: ProjectID, directory: string) => Effect.Effect } - export async function fromDirectory(directory: string) { - log.info("fromDirectory", { directory }) + export class Service extends ServiceMap.Service()("@opencode/Project") {} - const data = await iife(async () => { - const matches = Filesystem.up({ targets: [".git"], start: directory }) - const dotgit = await matches.next().then((x) => x.value) - await matches.return() - if (dotgit) { - let sandbox = path.dirname(dotgit) + type GitResult = { code: number; text: string; stderr: string } - const gitBinary = which("git") + export const layer: Layer.Layer< + Service, + never, + AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner + > = Layer.effect( + Service, + Effect.gen(function* () { + const fsys = yield* AppFileSystem.Service + const pathSvc = yield* Path.Path + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner - // cached id calculation - let id = await readCachedId(dotgit) + const git = Effect.fnUntraced( + function* (args: string[], opts?: { cwd?: string }) { + const handle = yield* spawner.spawn( + ChildProcess.make("git", args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }), + ) + const [text, stderr] = yield* Effect.all( + [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], + { concurrency: 2 }, + ) + const code = yield* handle.exitCode + return { code, text, stderr } satisfies GitResult + }, + Effect.scoped, + Effect.catch(() => Effect.succeed({ code: 1, text: "", stderr: "" } satisfies GitResult)), + ) - if (!gitBinary) { - return { - id: id ?? ProjectID.global, - worktree: sandbox, - sandbox, - vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), - } - } + const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => + Effect.sync(() => Database.use(fn)) - const worktree = await git(["rev-parse", "--git-common-dir"], { - cwd: sandbox, - }) - .then(async (result) => { - const common = gitpath(sandbox, await result.text()) - // Avoid going to parent of sandbox when git-common-dir is empty. - return common === sandbox ? sandbox : path.dirname(common) - }) - .catch(() => undefined) + const emitUpdated = (data: Info) => + Effect.sync(() => + GlobalBus.emit("event", { + payload: { type: Event.Updated.type, properties: data }, + }), + ) - if (!worktree) { - return { - id: id ?? ProjectID.global, - worktree: sandbox, - sandbox, - vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), - } - } + const fakeVcs = Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS) - // In the case of a git worktree, it can't cache the id - // because `.git` is not a folder, but it always needs the - // same project id as the common dir, so we resolve it now - if (id == null) { - id = await readCachedId(path.join(worktree, ".git")) - } + const resolveGitPath = (cwd: string, name: string) => { + if (!name) return cwd + name = name.replace(/[\r\n]+$/, "") + if (!name) return cwd + name = AppFileSystem.windowsPath(name) + if (pathSvc.isAbsolute(name)) return pathSvc.normalize(name) + return pathSvc.resolve(cwd, name) + } - // generate id from root commit - if (!id) { - const roots = await git(["rev-list", "--max-parents=0", "HEAD"], { - cwd: sandbox, - }) - .then(async (result) => - (await result.text()) - .split("\n") - .filter(Boolean) - .map((x) => x.trim()) - .toSorted(), - ) - .catch(() => undefined) + const scope = yield* Scope.Scope - if (!roots) { + const readCachedProjectId = Effect.fnUntraced(function* (dir: string) { + return yield* fsys.readFileString(pathSvc.join(dir, "opencode")).pipe( + Effect.map((x) => x.trim()), + Effect.map(ProjectID.make), + Effect.catch(() => Effect.succeed(undefined)), + ) + }) + + const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) { + log.info("fromDirectory", { directory }) + + // Phase 1: discover git info + type DiscoveryResult = { id: ProjectID; worktree: string; sandbox: string; vcs: Info["vcs"] } + + const data: DiscoveryResult = yield* Effect.gen(function* () { + const dotgitMatches = yield* fsys.up({ targets: [".git"], start: directory }).pipe(Effect.orDie) + const dotgit = dotgitMatches[0] + + if (!dotgit) { return { id: ProjectID.global, - worktree: sandbox, - sandbox, - vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), + worktree: "/", + sandbox: "/", + vcs: fakeVcs, } } - id = roots[0] ? ProjectID.make(roots[0]) : undefined - if (id) { - // Write to common dir so the cache is shared across worktrees. - await Filesystem.write(path.join(worktree, ".git", "opencode"), id).catch(() => undefined) - } - } + let sandbox = pathSvc.dirname(dotgit) + const gitBinary = yield* Effect.sync(() => which("git")) + let id = yield* readCachedProjectId(dotgit) - if (!id) { - return { - id: ProjectID.global, - worktree: sandbox, - sandbox, - vcs: "git", + if (!gitBinary) { + return { + id: id ?? ProjectID.global, + worktree: sandbox, + sandbox, + vcs: fakeVcs, + } } - } - const top = await git(["rev-parse", "--show-toplevel"], { - cwd: sandbox, + const commonDir = yield* git(["rev-parse", "--git-common-dir"], { cwd: sandbox }) + if (commonDir.code !== 0) { + return { + id: id ?? ProjectID.global, + worktree: sandbox, + sandbox, + vcs: fakeVcs, + } + } + const worktree = (() => { + const common = resolveGitPath(sandbox, commonDir.text.trim()) + return common === sandbox ? sandbox : pathSvc.dirname(common) + })() + + if (id == null) { + id = yield* readCachedProjectId(pathSvc.join(worktree, ".git")) + } + + if (!id) { + const revList = yield* git(["rev-list", "--max-parents=0", "HEAD"], { cwd: sandbox }) + const roots = revList.text + .split("\n") + .filter(Boolean) + .map((x) => x.trim()) + .toSorted() + + id = roots[0] ? ProjectID.make(roots[0]) : undefined + if (id) { + yield* fsys.writeFileString(pathSvc.join(worktree, ".git", "opencode"), id).pipe(Effect.ignore) + } + } + + if (!id) { + return { id: ProjectID.global, worktree: sandbox, sandbox, vcs: "git" as const } + } + + const topLevel = yield* git(["rev-parse", "--show-toplevel"], { cwd: sandbox }) + if (topLevel.code !== 0) { + return { + id, + worktree: sandbox, + sandbox, + vcs: fakeVcs, + } + } + sandbox = resolveGitPath(sandbox, topLevel.text.trim()) + + return { id, sandbox, worktree, vcs: "git" as const } }) - .then(async (result) => gitpath(sandbox, await result.text())) - .catch(() => undefined) - if (!top) { - return { - id, - worktree: sandbox, - sandbox, - vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), - } - } + // Phase 2: upsert + const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get()) + const existing = row + ? fromRow(row) + : { + id: data.id, + worktree: data.worktree, + vcs: data.vcs, + sandboxes: [] as string[], + time: { created: Date.now(), updated: Date.now() }, + } - sandbox = top + if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) + yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope)) - return { - id, - sandbox, - worktree, - vcs: "git", - } - } - - return { - id: ProjectID.global, - worktree: "/", - sandbox: "/", - vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), - } - }) - - const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get()) - const existing = row - ? fromRow(row) - : { - id: data.id, + const result: Info = { + ...existing, worktree: data.worktree, - vcs: data.vcs as Info["vcs"], - sandboxes: [] as string[], - time: { - created: Date.now(), - updated: Date.now(), - }, + vcs: data.vcs, + time: { ...existing.time, updated: Date.now() }, + } + if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox)) + result.sandboxes.push(data.sandbox) + result.sandboxes = yield* Effect.forEach( + result.sandboxes, + (s) => + fsys.exists(s).pipe( + Effect.orDie, + Effect.map((exists) => (exists ? s : undefined)), + ), + { concurrency: "unbounded" }, + ).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined))) + + yield* db((d) => + d + .insert(ProjectTable) + .values({ + id: result.id, + worktree: result.worktree, + vcs: result.vcs ?? null, + name: result.name, + icon_url: result.icon?.url, + icon_color: result.icon?.color, + time_created: result.time.created, + time_updated: result.time.updated, + time_initialized: result.time.initialized, + sandboxes: result.sandboxes, + commands: result.commands, + }) + .onConflictDoUpdate({ + target: ProjectTable.id, + set: { + worktree: result.worktree, + vcs: result.vcs ?? null, + name: result.name, + icon_url: result.icon?.url, + icon_color: result.icon?.color, + time_updated: result.time.updated, + time_initialized: result.time.initialized, + sandboxes: result.sandboxes, + commands: result.commands, + }, + }) + .run(), + ) + + if (data.id !== ProjectID.global) { + yield* db((d) => + d + .update(SessionTable) + .set({ project_id: data.id }) + .where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree))) + .run(), + ) } - if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) discover(existing) + yield* emitUpdated(result) + return { project: result, sandbox: data.sandbox } + }) - const result: Info = { - ...existing, - worktree: data.worktree, - vcs: data.vcs as Info["vcs"], - time: { - ...existing.time, - updated: Date.now(), - }, - } - if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox)) - result.sandboxes.push(data.sandbox) - result.sandboxes = result.sandboxes.filter((x) => existsSync(x)) - const insert = { - id: result.id, - worktree: result.worktree, - vcs: result.vcs ?? null, - name: result.name, - icon_url: result.icon?.url, - icon_color: result.icon?.color, - time_created: result.time.created, - time_updated: result.time.updated, - time_initialized: result.time.initialized, - sandboxes: result.sandboxes, - commands: result.commands, - } - const updateSet = { - worktree: result.worktree, - vcs: result.vcs ?? null, - name: result.name, - icon_url: result.icon?.url, - icon_color: result.icon?.color, - time_updated: result.time.updated, - time_initialized: result.time.initialized, - sandboxes: result.sandboxes, - commands: result.commands, - } - Database.use((db) => - db.insert(ProjectTable).values(insert).onConflictDoUpdate({ target: ProjectTable.id, set: updateSet }).run(), - ) - // Runs after upsert so the target project row exists (FK constraint). - // Runs on every startup because sessions created before git init - // accumulate under "global" and need migrating whenever they appear. - if (data.id !== ProjectID.global) { - Database.use((db) => - db - .update(SessionTable) - .set({ project_id: data.id }) - .where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree))) - .run(), - ) - } - GlobalBus.emit("event", { - payload: { - type: Event.Updated.type, - properties: result, - }, - }) - return { project: result, sandbox: data.sandbox } + const discover = Effect.fn("Project.discover")(function* (input: Info) { + if (input.vcs !== "git") return + if (input.icon?.override) return + if (input.icon?.url) return + + const matches = yield* fsys + .glob("**/favicon.{ico,png,svg,jpg,jpeg,webp}", { + cwd: input.worktree, + absolute: true, + include: "file", + }) + .pipe(Effect.orDie) + const shortest = matches.sort((a, b) => a.length - b.length)[0] + if (!shortest) return + + const buffer = yield* fsys.readFile(shortest).pipe(Effect.orDie) + const base64 = Buffer.from(buffer).toString("base64") + const mime = AppFileSystem.mimeType(shortest) + const url = `data:${mime};base64,${base64}` + yield* update({ projectID: input.id, icon: { url } }) + }) + + const list = Effect.fn("Project.list")(function* () { + return yield* db((d) => d.select().from(ProjectTable).all().map(fromRow)) + }) + + const get = Effect.fn("Project.get")(function* (id: ProjectID) { + const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) + return row ? fromRow(row) : undefined + }) + + const update = Effect.fn("Project.update")(function* (input: UpdateInput) { + const result = yield* db((d) => + d + .update(ProjectTable) + .set({ + name: input.name, + icon_url: input.icon?.url, + icon_color: input.icon?.color, + commands: input.commands, + time_updated: Date.now(), + }) + .where(eq(ProjectTable.id, input.projectID)) + .returning() + .get(), + ) + if (!result) throw new Error(`Project not found: ${input.projectID}`) + const data = fromRow(result) + yield* emitUpdated(data) + return data + }) + + const initGit = Effect.fn("Project.initGit")(function* (input: { directory: string; project: Info }) { + if (input.project.vcs === "git") return input.project + if (!(yield* Effect.sync(() => which("git")))) throw new Error("Git is not installed") + const result = yield* git(["init", "--quiet"], { cwd: input.directory }) + if (result.code !== 0) { + throw new Error(result.stderr.trim() || result.text.trim() || "Failed to initialize git repository") + } + const { project } = yield* fromDirectory(input.directory) + return project + }) + + const setInitialized = Effect.fn("Project.setInitialized")(function* (id: ProjectID) { + yield* db((d) => + d.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(), + ) + }) + + const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectID) { + const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) + if (!row) return [] + const data = fromRow(row) + return yield* Effect.forEach( + data.sandboxes, + (dir) => fsys.isDir(dir).pipe(Effect.orDie, Effect.map((ok) => (ok ? dir : undefined))), + { concurrency: "unbounded" }, + ).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined))) + }) + + const addSandbox = Effect.fn("Project.addSandbox")(function* (id: ProjectID, directory: string) { + const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) + if (!row) throw new Error(`Project not found: ${id}`) + const sboxes = [...row.sandboxes] + if (!sboxes.includes(directory)) sboxes.push(directory) + const result = yield* db((d) => + d + .update(ProjectTable) + .set({ sandboxes: sboxes, time_updated: Date.now() }) + .where(eq(ProjectTable.id, id)) + .returning() + .get(), + ) + if (!result) throw new Error(`Project not found: ${id}`) + yield* emitUpdated(fromRow(result)) + }) + + const removeSandbox = Effect.fn("Project.removeSandbox")(function* (id: ProjectID, directory: string) { + const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) + if (!row) throw new Error(`Project not found: ${id}`) + const sboxes = row.sandboxes.filter((s) => s !== directory) + const result = yield* db((d) => + d + .update(ProjectTable) + .set({ sandboxes: sboxes, time_updated: Date.now() }) + .where(eq(ProjectTable.id, id)) + .returning() + .get(), + ) + if (!result) throw new Error(`Project not found: ${id}`) + yield* emitUpdated(fromRow(result)) + }) + + return Service.of({ + fromDirectory, + discover, + list, + get, + update, + initGit, + setInitialized, + sandboxes, + addSandbox, + removeSandbox, + }) + }), + ) + + export const defaultLayer = layer.pipe( + Layer.provide(CrossSpawnSpawner.layer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(NodeFileSystem.layer), + Layer.provide(NodePath.layer), + ) + const runPromise = makeRunPromise(Service, defaultLayer) + + // --------------------------------------------------------------------------- + // Promise-based API (delegates to Effect service via runPromise) + // --------------------------------------------------------------------------- + + export function fromDirectory(directory: string) { + return runPromise((svc) => svc.fromDirectory(directory)) } - export async function discover(input: Info) { - if (input.vcs !== "git") return - if (input.icon?.override) return - if (input.icon?.url) return - const matches = await Glob.scan("**/favicon.{ico,png,svg,jpg,jpeg,webp}", { - cwd: input.worktree, - absolute: true, - include: "file", - }) - const shortest = matches.sort((a, b) => a.length - b.length)[0] - if (!shortest) return - const buffer = await Filesystem.readBytes(shortest) - const base64 = buffer.toString("base64") - const mime = Filesystem.mimeType(shortest) || "image/png" - const url = `data:${mime};base64,${base64}` - await update({ - projectID: input.id, - icon: { - url, - }, - }) - return - } - - export function setInitialized(id: ProjectID) { - Database.use((db) => - db - .update(ProjectTable) - .set({ - time_initialized: Date.now(), - }) - .where(eq(ProjectTable.id, id)) - .run(), - ) + export function discover(input: Info) { + return runPromise((svc) => svc.discover(input)) } export function list() { @@ -345,112 +488,29 @@ export namespace Project { return fromRow(row) } - export async function initGit(input: { directory: string; project: Info }) { - if (input.project.vcs === "git") return input.project - if (!which("git")) throw new Error("Git is not installed") - - const result = await git(["init", "--quiet"], { - cwd: input.directory, - }) - if (result.exitCode !== 0) { - const text = result.stderr.toString().trim() || result.text().trim() - throw new Error(text || "Failed to initialize git repository") - } - - return (await fromDirectory(input.directory)).project - } - - export const update = fn( - z.object({ - projectID: ProjectID.zod, - name: z.string().optional(), - icon: Info.shape.icon.optional(), - commands: Info.shape.commands.optional(), - }), - async (input) => { - const id = ProjectID.make(input.projectID) - const result = Database.use((db) => - db - .update(ProjectTable) - .set({ - name: input.name, - icon_url: input.icon?.url, - icon_color: input.icon?.color, - commands: input.commands, - time_updated: Date.now(), - }) - .where(eq(ProjectTable.id, id)) - .returning() - .get(), - ) - if (!result) throw new Error(`Project not found: ${input.projectID}`) - const data = fromRow(result) - GlobalBus.emit("event", { - payload: { - type: Event.Updated.type, - properties: data, - }, - }) - return data - }, - ) - - export async function sandboxes(id: ProjectID) { - const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) - if (!row) return [] - const data = fromRow(row) - const valid: string[] = [] - for (const dir of data.sandboxes) { - const s = Filesystem.stat(dir) - if (s?.isDirectory()) valid.push(dir) - } - return valid - } - - export async function addSandbox(id: ProjectID, directory: string) { - const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) - if (!row) throw new Error(`Project not found: ${id}`) - const sandboxes = [...row.sandboxes] - if (!sandboxes.includes(directory)) sandboxes.push(directory) - const result = Database.use((db) => - db - .update(ProjectTable) - .set({ sandboxes, time_updated: Date.now() }) - .where(eq(ProjectTable.id, id)) - .returning() - .get(), + export function setInitialized(id: ProjectID) { + Database.use((db) => + db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(), ) - if (!result) throw new Error(`Project not found: ${id}`) - const data = fromRow(result) - GlobalBus.emit("event", { - payload: { - type: Event.Updated.type, - properties: data, - }, - }) - return data } - export async function removeSandbox(id: ProjectID, directory: string) { - const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) - if (!row) throw new Error(`Project not found: ${id}`) - const sandboxes = row.sandboxes.filter((s) => s !== directory) - const result = Database.use((db) => - db - .update(ProjectTable) - .set({ sandboxes, time_updated: Date.now() }) - .where(eq(ProjectTable.id, id)) - .returning() - .get(), - ) - if (!result) throw new Error(`Project not found: ${id}`) - const data = fromRow(result) - GlobalBus.emit("event", { - payload: { - type: Event.Updated.type, - properties: data, - }, - }) - return data + export function initGit(input: { directory: string; project: Info }) { + return runPromise((svc) => svc.initGit(input)) + } + + export function update(input: UpdateInput) { + return runPromise((svc) => svc.update(input)) + } + + export function sandboxes(id: ProjectID) { + return runPromise((svc) => svc.sandboxes(id)) + } + + export function addSandbox(id: ProjectID, directory: string) { + return runPromise((svc) => svc.addSandbox(id, directory)) + } + + export function removeSandbox(id: ProjectID, directory: string) { + return runPromise((svc) => svc.removeSandbox(id, directory)) } } diff --git a/packages/opencode/src/server/routes/project.ts b/packages/opencode/src/server/routes/project.ts index 6cd51ac958..e5dd5782d6 100644 --- a/packages/opencode/src/server/routes/project.ts +++ b/packages/opencode/src/server/routes/project.ts @@ -107,7 +107,7 @@ export const ProjectRoutes = lazy(() => }, }), validator("param", z.object({ projectID: ProjectID.zod })), - validator("json", Project.update.schema.omit({ projectID: true })), + validator("json", Project.UpdateInput.omit({ projectID: true })), async (c) => { const projectID = c.req.valid("param").projectID const body = c.req.valid("json") diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index a71fe0528f..523f0711fd 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -1,78 +1,69 @@ -import { describe, expect, mock, test } from "bun:test" +import { describe, expect, test } from "bun:test" import { Project } from "../../src/project/project" import { Log } from "../../src/util/log" import { $ } from "bun" import path from "path" import { tmpdir } from "../fixture/fixture" -import { Filesystem } from "../../src/util/filesystem" import { GlobalBus } from "../../src/bus/global" import { ProjectID } from "../../src/project/schema" +import { Effect, Layer, Stream } from "effect" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import { NodeFileSystem, NodePath } from "@effect/platform-node" +import { AppFileSystem } from "../../src/filesystem" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" Log.init({ print: false }) -const gitModule = await import("../../src/util/git") -const originalGit = gitModule.git +const encoder = new TextEncoder() -type Mode = "none" | "rev-list-fail" | "top-fail" | "common-dir-fail" -let mode: Mode = "none" - -mock.module("../../src/util/git", () => ({ - git: (args: string[], opts: { cwd: string; env?: Record }) => { - const cmd = ["git", ...args].join(" ") - if ( - mode === "rev-list-fail" && - cmd.includes("git rev-list") && - cmd.includes("--max-parents=0") && - cmd.includes("HEAD") - ) { - return Promise.resolve({ - exitCode: 128, - text: () => Promise.resolve(""), - stdout: Buffer.from(""), - stderr: Buffer.from("fatal"), - }) - } - if (mode === "top-fail" && cmd.includes("git rev-parse") && cmd.includes("--show-toplevel")) { - return Promise.resolve({ - exitCode: 128, - text: () => Promise.resolve(""), - stdout: Buffer.from(""), - stderr: Buffer.from("fatal"), - }) - } - if (mode === "common-dir-fail" && cmd.includes("git rev-parse") && cmd.includes("--git-common-dir")) { - return Promise.resolve({ - exitCode: 128, - text: () => Promise.resolve(""), - stdout: Buffer.from(""), - stderr: Buffer.from("fatal"), - }) - } - return originalGit(args, opts) - }, -})) - -async function withMode(next: Mode, run: () => Promise) { - const prev = mode - mode = next - try { - await run() - } finally { - mode = prev - } +/** + * Creates a mock ChildProcessSpawner layer that intercepts git subcommands + * matching `failArg` and returns exit code 128, while delegating everything + * else to the real CrossSpawnSpawner. + */ +function mockGitFailure(failArg: string) { + return Layer.effect( + ChildProcessSpawner.ChildProcessSpawner, + Effect.gen(function* () { + const real = yield* ChildProcessSpawner.ChildProcessSpawner + return ChildProcessSpawner.make( + Effect.fnUntraced(function* (command) { + const std = ChildProcess.isStandardCommand(command) ? command : undefined + if (std?.command === "git" && std.args.some((a) => a === failArg)) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(0), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(128)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any, + stdout: Stream.empty, + stderr: Stream.make(encoder.encode("fatal: simulated failure\n")), + all: Stream.empty, + getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any, + getOutputFd: () => Stream.empty, + }) + } + return yield* real.spawn(command) + }), + ) + }), + ).pipe(Layer.provide(CrossSpawnSpawner.layer), Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer)) } -async function loadProject() { - return (await import("../../src/project/project")).Project +function projectLayerWithFailure(failArg: string) { + return Project.layer.pipe( + Layer.provide(mockGitFailure(failArg)), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(NodePath.layer), + ) } describe("Project.fromDirectory", () => { test("should handle git repository with no commits", async () => { - const p = await loadProject() await using tmp = await tmpdir() await $`git init`.cwd(tmp.path).quiet() - const { project } = await p.fromDirectory(tmp.path) + const { project } = await Project.fromDirectory(tmp.path) expect(project).toBeDefined() expect(project.id).toBe(ProjectID.global) @@ -80,15 +71,13 @@ describe("Project.fromDirectory", () => { expect(project.worktree).toBe(tmp.path) const opencodeFile = path.join(tmp.path, ".git", "opencode") - const fileExists = await Filesystem.exists(opencodeFile) - expect(fileExists).toBe(false) + expect(await Bun.file(opencodeFile).exists()).toBe(false) }) test("should handle git repository with commits", async () => { - const p = await loadProject() await using tmp = await tmpdir({ git: true }) - const { project } = await p.fromDirectory(tmp.path) + const { project } = await Project.fromDirectory(tmp.path) expect(project).toBeDefined() expect(project.id).not.toBe(ProjectID.global) @@ -96,54 +85,63 @@ describe("Project.fromDirectory", () => { expect(project.worktree).toBe(tmp.path) const opencodeFile = path.join(tmp.path, ".git", "opencode") - const fileExists = await Filesystem.exists(opencodeFile) - expect(fileExists).toBe(true) + expect(await Bun.file(opencodeFile).exists()).toBe(true) }) - test("keeps git vcs when rev-list exits non-zero with empty output", async () => { - const p = await loadProject() + test("returns global for non-git directory", async () => { + await using tmp = await tmpdir() + const { project } = await Project.fromDirectory(tmp.path) + expect(project.id).toBe(ProjectID.global) + }) + + test("derives stable project ID from root commit", async () => { + await using tmp = await tmpdir({ git: true }) + const { project: a } = await Project.fromDirectory(tmp.path) + const { project: b } = await Project.fromDirectory(tmp.path) + expect(b.id).toBe(a.id) + }) +}) + +describe("Project.fromDirectory git failure paths", () => { + test("keeps vcs when rev-list exits non-zero (no commits)", async () => { await using tmp = await tmpdir() await $`git init`.cwd(tmp.path).quiet() - await withMode("rev-list-fail", async () => { - const { project } = await p.fromDirectory(tmp.path) - expect(project.vcs).toBe("git") - expect(project.id).toBe(ProjectID.global) - expect(project.worktree).toBe(tmp.path) - }) + // rev-list fails because HEAD doesn't exist yet — this is the natural scenario + const { project } = await Project.fromDirectory(tmp.path) + expect(project.vcs).toBe("git") + expect(project.id).toBe(ProjectID.global) + expect(project.worktree).toBe(tmp.path) }) - test("keeps git vcs when show-toplevel exits non-zero with empty output", async () => { - const p = await loadProject() + test("handles show-toplevel failure gracefully", async () => { await using tmp = await tmpdir({ git: true }) + const layer = projectLayerWithFailure("--show-toplevel") - await withMode("top-fail", async () => { - const { project, sandbox } = await p.fromDirectory(tmp.path) - expect(project.vcs).toBe("git") - expect(project.worktree).toBe(tmp.path) - expect(sandbox).toBe(tmp.path) - }) + const { project, sandbox } = await Effect.runPromise( + Project.Service.use((svc) => svc.fromDirectory(tmp.path)).pipe(Effect.provide(layer)), + ) + expect(project.worktree).toBe(tmp.path) + expect(sandbox).toBe(tmp.path) }) - test("keeps git vcs when git-common-dir exits non-zero with empty output", async () => { - const p = await loadProject() + test("handles git-common-dir failure gracefully", async () => { await using tmp = await tmpdir({ git: true }) + const layer = projectLayerWithFailure("--git-common-dir") - await withMode("common-dir-fail", async () => { - const { project, sandbox } = await p.fromDirectory(tmp.path) - expect(project.vcs).toBe("git") - expect(project.worktree).toBe(tmp.path) - expect(sandbox).toBe(tmp.path) - }) + const { project, sandbox } = await Effect.runPromise( + Project.Service.use((svc) => svc.fromDirectory(tmp.path)).pipe(Effect.provide(layer)), + ) + expect(project.worktree).toBe(tmp.path) + expect(sandbox).toBe(tmp.path) }) }) describe("Project.fromDirectory with worktrees", () => { test("should set worktree to root when called from root", async () => { - const p = await loadProject() await using tmp = await tmpdir({ git: true }) - const { project, sandbox } = await p.fromDirectory(tmp.path) + const { project, sandbox } = await Project.fromDirectory(tmp.path) expect(project.worktree).toBe(tmp.path) expect(sandbox).toBe(tmp.path) @@ -151,14 +149,13 @@ describe("Project.fromDirectory with worktrees", () => { }) test("should set worktree to root when called from a worktree", async () => { - const p = await loadProject() await using tmp = await tmpdir({ git: true }) const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-worktree") try { await $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp.path).quiet() - const { project, sandbox } = await p.fromDirectory(worktreePath) + const { project, sandbox } = await Project.fromDirectory(worktreePath) expect(project.worktree).toBe(tmp.path) expect(sandbox).toBe(worktreePath) @@ -173,22 +170,21 @@ describe("Project.fromDirectory with worktrees", () => { }) test("worktree should share project ID with main repo", async () => { - const p = await loadProject() await using tmp = await tmpdir({ git: true }) - const { project: main } = await p.fromDirectory(tmp.path) + const { project: main } = await Project.fromDirectory(tmp.path) const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt-shared") try { await $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp.path).quiet() - const { project: wt } = await p.fromDirectory(worktreePath) + const { project: wt } = await Project.fromDirectory(worktreePath) expect(wt.id).toBe(main.id) // Cache should live in the common .git dir, not the worktree's .git file const cache = path.join(tmp.path, ".git", "opencode") - const exists = await Filesystem.exists(cache) + const exists = await Bun.file(cache).exists() expect(exists).toBe(true) } finally { await $`git worktree remove ${worktreePath}` @@ -199,7 +195,6 @@ describe("Project.fromDirectory with worktrees", () => { }) test("separate clones of the same repo should share project ID", async () => { - const p = await loadProject() await using tmp = await tmpdir({ git: true }) // Create a bare remote, push, then clone into a second directory @@ -209,8 +204,8 @@ describe("Project.fromDirectory with worktrees", () => { await $`git clone --bare ${tmp.path} ${bare}`.quiet() await $`git clone ${bare} ${clone}`.quiet() - const { project: a } = await p.fromDirectory(tmp.path) - const { project: b } = await p.fromDirectory(clone) + const { project: a } = await Project.fromDirectory(tmp.path) + const { project: b } = await Project.fromDirectory(clone) expect(b.id).toBe(a.id) } finally { @@ -219,7 +214,6 @@ describe("Project.fromDirectory with worktrees", () => { }) test("should accumulate multiple worktrees in sandboxes", async () => { - const p = await loadProject() await using tmp = await tmpdir({ git: true }) const worktree1 = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt1") @@ -228,8 +222,8 @@ describe("Project.fromDirectory with worktrees", () => { await $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp.path).quiet() await $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp.path).quiet() - await p.fromDirectory(worktree1) - const { project } = await p.fromDirectory(worktree2) + await Project.fromDirectory(worktree1) + const { project } = await Project.fromDirectory(worktree2) expect(project.worktree).toBe(tmp.path) expect(project.sandboxes).toContain(worktree1) @@ -250,14 +244,13 @@ describe("Project.fromDirectory with worktrees", () => { describe("Project.discover", () => { test("should discover favicon.png in root", async () => { - const p = await loadProject() await using tmp = await tmpdir({ git: true }) - const { project } = await p.fromDirectory(tmp.path) + const { project } = await Project.fromDirectory(tmp.path) const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) await Bun.write(path.join(tmp.path, "favicon.png"), pngData) - await p.discover(project) + await Project.discover(project) const updated = Project.get(project.id) expect(updated).toBeDefined() @@ -268,13 +261,12 @@ describe("Project.discover", () => { }) test("should not discover non-image files", async () => { - const p = await loadProject() await using tmp = await tmpdir({ git: true }) - const { project } = await p.fromDirectory(tmp.path) + const { project } = await Project.fromDirectory(tmp.path) await Bun.write(path.join(tmp.path, "favicon.txt"), "not an image") - await p.discover(project) + await Project.discover(project) const updated = Project.get(project.id) expect(updated).toBeDefined() @@ -344,8 +336,6 @@ describe("Project.update", () => { }) test("should throw error when project not found", async () => { - await using tmp = await tmpdir({ git: true }) - await expect( Project.update({ projectID: ProjectID.make("nonexistent-project-id"), @@ -358,22 +348,22 @@ describe("Project.update", () => { await using tmp = await tmpdir({ git: true }) const { project } = await Project.fromDirectory(tmp.path) - let eventFired = false let eventPayload: any = null + const on = (data: any) => { eventPayload = data } + GlobalBus.on("event", on) - GlobalBus.on("event", (data) => { - eventFired = true - eventPayload = data - }) + try { + await Project.update({ + projectID: project.id, + name: "Updated Name", + }) - await Project.update({ - projectID: project.id, - name: "Updated Name", - }) - - expect(eventFired).toBe(true) - expect(eventPayload.payload.type).toBe("project.updated") - expect(eventPayload.payload.properties.name).toBe("Updated Name") + expect(eventPayload).not.toBeNull() + expect(eventPayload.payload.type).toBe("project.updated") + expect(eventPayload.payload.properties.name).toBe("Updated Name") + } finally { + GlobalBus.off("event", on) + } }) test("should update multiple fields at once", async () => { @@ -393,3 +383,75 @@ describe("Project.update", () => { expect(updated.commands?.start).toBe("make start") }) }) + +describe("Project.list and Project.get", () => { + test("list returns all projects", async () => { + await using tmp = await tmpdir({ git: true }) + const { project } = await Project.fromDirectory(tmp.path) + + const all = Project.list() + expect(all.length).toBeGreaterThan(0) + expect(all.find((p) => p.id === project.id)).toBeDefined() + }) + + test("get returns project by id", async () => { + await using tmp = await tmpdir({ git: true }) + const { project } = await Project.fromDirectory(tmp.path) + + const found = Project.get(project.id) + expect(found).toBeDefined() + expect(found!.id).toBe(project.id) + }) + + test("get returns undefined for unknown id", () => { + const found = Project.get(ProjectID.make("nonexistent")) + expect(found).toBeUndefined() + }) +}) + +describe("Project.setInitialized", () => { + test("sets time_initialized on project", async () => { + await using tmp = await tmpdir({ git: true }) + const { project } = await Project.fromDirectory(tmp.path) + + expect(project.time.initialized).toBeUndefined() + + Project.setInitialized(project.id) + + const updated = Project.get(project.id) + expect(updated?.time.initialized).toBeDefined() + }) +}) + +describe("Project.addSandbox and Project.removeSandbox", () => { + test("addSandbox adds directory and removeSandbox removes it", async () => { + await using tmp = await tmpdir({ git: true }) + const { project } = await Project.fromDirectory(tmp.path) + const sandboxDir = path.join(tmp.path, "sandbox-test") + + await Project.addSandbox(project.id, sandboxDir) + + let found = Project.get(project.id) + expect(found?.sandboxes).toContain(sandboxDir) + + await Project.removeSandbox(project.id, sandboxDir) + + found = Project.get(project.id) + expect(found?.sandboxes).not.toContain(sandboxDir) + }) + + test("addSandbox emits GlobalBus event", async () => { + await using tmp = await tmpdir({ git: true }) + const { project } = await Project.fromDirectory(tmp.path) + const sandboxDir = path.join(tmp.path, "sandbox-event") + + const events: any[] = [] + const on = (evt: any) => events.push(evt) + GlobalBus.on("event", on) + + await Project.addSandbox(project.id, sandboxDir) + + GlobalBus.off("event", on) + expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true) + }) +}) From 42a773481e4d50a59784d514d81257330de38ca9 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:03:55 -0500 Subject: [PATCH 19/48] fix(app): sidebar truncation --- .../app/src/pages/layout/sidebar-items.tsx | 179 +++++++++--------- 1 file changed, 92 insertions(+), 87 deletions(-) diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index a9627c5dbc..75dada05f0 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -104,7 +104,7 @@ const SessionRow = (props: { }): JSX.Element => ( -
-
- }> - - - - -
- - -
- - 0}> -
- - -
- - {props.session.title} - +
+ }> + + + + +
+ + +
+ + 0}> +
+ +
+ {props.session.title} ) @@ -167,7 +163,11 @@ const SessionHoverPreview = (props: { placement="right-start" gutter={16} shift={-2} - trigger={
{props.trigger}
} + trigger={ +
+ {props.trigger} +
+ } open={props.hoverSession() === props.session.id} onOpenChange={(open) => { if (!open) { @@ -309,62 +309,71 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { return (
- - {item} - - } - > - { - if (!isActive()) - layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id) +
+
+ + {item} + + } + > + { + if (!isActive()) + layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id) - navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`) + navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`) + }} + trigger={item} + /> + +
+ +
- - -
- - { - event.preventDefault() - event.stopPropagation() - void props.archiveSession(props.session) - }} - /> - + > + + { + event.preventDefault() + event.stopPropagation() + void props.archiveSession(props.session) + }} + /> + +
) @@ -386,30 +395,26 @@ export const NewSessionItem = (props: { { props.setHoverSession(undefined) if (layout.sidebar.opened()) return props.clearHoverProjectSoon() }} > -
-
- -
- - {label} - +
+
+ {label}
) return ( -
+
+ {item} } From 8994cbfc0f57aede5a34a202f778d3a4385908af Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 24 Mar 2026 18:05:43 +0000 Subject: [PATCH 20/48] chore: generate --- packages/opencode/src/project/project.ts | 6 +++++- packages/opencode/test/project/project.test.ts | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 3d20f58d45..256be36959 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -399,7 +399,11 @@ export namespace Project { const data = fromRow(row) return yield* Effect.forEach( data.sandboxes, - (dir) => fsys.isDir(dir).pipe(Effect.orDie, Effect.map((ok) => (ok ? dir : undefined))), + (dir) => + fsys.isDir(dir).pipe( + Effect.orDie, + Effect.map((ok) => (ok ? dir : undefined)), + ), { concurrency: "unbounded" }, ).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined))) }) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 523f0711fd..b030e6cbcd 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -349,7 +349,9 @@ describe("Project.update", () => { const { project } = await Project.fromDirectory(tmp.path) let eventPayload: any = null - const on = (data: any) => { eventPayload = data } + const on = (data: any) => { + eventPayload = data + } GlobalBus.on("event", on) try { From 2c1d8a90d567d65ac044b2feaf2ee886318247ec Mon Sep 17 00:00:00 2001 From: Caleb Norton Date: Tue, 24 Mar 2026 13:06:46 -0500 Subject: [PATCH 21/48] fix: nix hash update parsing... again (#18989) --- .github/workflows/nix-hashes.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/nix-hashes.yml b/.github/workflows/nix-hashes.yml index 47385d20c6..9d94682f11 100644 --- a/.github/workflows/nix-hashes.yml +++ b/.github/workflows/nix-hashes.yml @@ -56,7 +56,7 @@ jobs: nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true # Extract hash from build log with portability - HASH="$(grep -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)" + HASH="$(nix run --inputs-from . nixpkgs#gnugrep -- -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)" if [ -z "$HASH" ]; then echo "::error::Failed to compute hash for ${SYSTEM}" From 5e684c6e80d30a77ba02db013c61b8ecfe420f7f Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:15:23 -0500 Subject: [PATCH 22/48] chore: effectify agent.ts (#18971) Co-authored-by: Kit Langton --- packages/opencode/src/agent/agent.ts | 609 +++++++++++++++------------ 1 file changed, 340 insertions(+), 269 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 30d0986144..72b2869641 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -3,7 +3,6 @@ import z from "zod" import { Provider } from "../provider/provider" import { ModelID, ProviderID } from "../provider/schema" import { generateObject, streamObject, type ModelMessage } from "ai" -import { SystemPrompt } from "../session/system" import { Instance } from "../project/instance" import { Truncate } from "../tool/truncate" import { Auth } from "../auth" @@ -20,6 +19,9 @@ import { Global } from "@/global" import path from "path" import { Plugin } from "@/plugin" import { Skill } from "../skill" +import { Effect, ServiceMap, Layer } from "effect" +import { InstanceState } from "@/effect/instance-state" +import { makeRunPromise } from "@/effect/run-service" export namespace Agent { export const Info = z @@ -49,295 +51,364 @@ export namespace Agent { }) export type Info = z.infer - const state = Instance.state(async () => { - const cfg = await Config.get() + export interface Interface { + readonly get: (agent: string) => Effect.Effect + readonly list: () => Effect.Effect + readonly defaultAgent: () => Effect.Effect + readonly generate: (input: { + description: string + model?: { providerID: ProviderID; modelID: ModelID } + }) => Effect.Effect<{ + identifier: string + whenToUse: string + systemPrompt: string + }> + } - const skillDirs = await Skill.dirs() - const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))] - const defaults = Permission.fromConfig({ - "*": "allow", - doom_loop: "ask", - external_directory: { - "*": "ask", - ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), - }, - question: "deny", - plan_enter: "deny", - plan_exit: "deny", - // mirrors github.com/github/gitignore Node.gitignore pattern for .env files - read: { - "*": "allow", - "*.env": "ask", - "*.env.*": "ask", - "*.env.example": "allow", - }, - }) - const user = Permission.fromConfig(cfg.permission ?? {}) + type State = Omit - const result: Record = { - build: { - name: "build", - description: "The default agent. Executes tools based on configured permissions.", - options: {}, - permission: Permission.merge( - defaults, - Permission.fromConfig({ - question: "allow", - plan_enter: "allow", - }), - user, - ), - mode: "primary", - native: true, - }, - plan: { - name: "plan", - description: "Plan mode. Disallows all edit tools.", - options: {}, - permission: Permission.merge( - defaults, - Permission.fromConfig({ - question: "allow", - plan_exit: "allow", - external_directory: { - [path.join(Global.Path.data, "plans", "*")]: "allow", - }, - edit: { - "*": "deny", - [path.join(".opencode", "plans", "*.md")]: "allow", - [path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow", - }, - }), - user, - ), - mode: "primary", - native: true, - }, - general: { - name: "general", - description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`, - permission: Permission.merge( - defaults, - Permission.fromConfig({ - todoread: "deny", - todowrite: "deny", - }), - user, - ), - options: {}, - mode: "subagent", - native: true, - }, - explore: { - name: "explore", - permission: Permission.merge( - defaults, - Permission.fromConfig({ - "*": "deny", - grep: "allow", - glob: "allow", - list: "allow", - bash: "allow", - webfetch: "allow", - websearch: "allow", - codesearch: "allow", - read: "allow", + export class Service extends ServiceMap.Service()("@opencode/Agent") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = () => Effect.promise(() => Config.get()) + const auth = yield* Auth.Service + + const state = yield* InstanceState.make( + Effect.fn("Agent.state")(function* (ctx) { + const cfg = yield* config() + const skillDirs = yield* Effect.promise(() => Skill.dirs()) + const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))] + + const defaults = Permission.fromConfig({ + "*": "allow", + doom_loop: "ask", external_directory: { "*": "ask", ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), }, - }), - user, - ), - description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`, - prompt: PROMPT_EXPLORE, - options: {}, - mode: "subagent", - native: true, - }, - compaction: { - name: "compaction", - mode: "primary", - native: true, - hidden: true, - prompt: PROMPT_COMPACTION, - permission: Permission.merge( - defaults, - Permission.fromConfig({ - "*": "deny", - }), - user, - ), - options: {}, - }, - title: { - name: "title", - mode: "primary", - options: {}, - native: true, - hidden: true, - temperature: 0.5, - permission: Permission.merge( - defaults, - Permission.fromConfig({ - "*": "deny", - }), - user, - ), - prompt: PROMPT_TITLE, - }, - summary: { - name: "summary", - mode: "primary", - options: {}, - native: true, - hidden: true, - permission: Permission.merge( - defaults, - Permission.fromConfig({ - "*": "deny", - }), - user, - ), - prompt: PROMPT_SUMMARY, - }, - } + question: "deny", + plan_enter: "deny", + plan_exit: "deny", + // mirrors github.com/github/gitignore Node.gitignore pattern for .env files + read: { + "*": "allow", + "*.env": "ask", + "*.env.*": "ask", + "*.env.example": "allow", + }, + }) - for (const [key, value] of Object.entries(cfg.agent ?? {})) { - if (value.disable) { - delete result[key] - continue - } - let item = result[key] - if (!item) - item = result[key] = { - name: key, - mode: "all", - permission: Permission.merge(defaults, user), - options: {}, - native: false, - } - if (value.model) item.model = Provider.parseModel(value.model) - item.variant = value.variant ?? item.variant - item.prompt = value.prompt ?? item.prompt - item.description = value.description ?? item.description - item.temperature = value.temperature ?? item.temperature - item.topP = value.top_p ?? item.topP - item.mode = value.mode ?? item.mode - item.color = value.color ?? item.color - item.hidden = value.hidden ?? item.hidden - item.name = value.name ?? item.name - item.steps = value.steps ?? item.steps - item.options = mergeDeep(item.options, value.options ?? {}) - item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {})) - } + const user = Permission.fromConfig(cfg.permission ?? {}) - // Ensure Truncate.GLOB is allowed unless explicitly configured - for (const name in result) { - const agent = result[name] - const explicit = agent.permission.some((r) => { - if (r.permission !== "external_directory") return false - if (r.action !== "deny") return false - return r.pattern === Truncate.GLOB - }) - if (explicit) continue + const agents: Record = { + build: { + name: "build", + description: "The default agent. Executes tools based on configured permissions.", + options: {}, + permission: Permission.merge( + defaults, + Permission.fromConfig({ + question: "allow", + plan_enter: "allow", + }), + user, + ), + mode: "primary", + native: true, + }, + plan: { + name: "plan", + description: "Plan mode. Disallows all edit tools.", + options: {}, + permission: Permission.merge( + defaults, + Permission.fromConfig({ + question: "allow", + plan_exit: "allow", + external_directory: { + [path.join(Global.Path.data, "plans", "*")]: "allow", + }, + edit: { + "*": "deny", + [path.join(".opencode", "plans", "*.md")]: "allow", + [path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: + "allow", + }, + }), + user, + ), + mode: "primary", + native: true, + }, + general: { + name: "general", + description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`, + permission: Permission.merge( + defaults, + Permission.fromConfig({ + todoread: "deny", + todowrite: "deny", + }), + user, + ), + options: {}, + mode: "subagent", + native: true, + }, + explore: { + name: "explore", + permission: Permission.merge( + defaults, + Permission.fromConfig({ + "*": "deny", + grep: "allow", + glob: "allow", + list: "allow", + bash: "allow", + webfetch: "allow", + websearch: "allow", + codesearch: "allow", + read: "allow", + external_directory: { + "*": "ask", + ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), + }, + }), + user, + ), + description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`, + prompt: PROMPT_EXPLORE, + options: {}, + mode: "subagent", + native: true, + }, + compaction: { + name: "compaction", + mode: "primary", + native: true, + hidden: true, + prompt: PROMPT_COMPACTION, + permission: Permission.merge( + defaults, + Permission.fromConfig({ + "*": "deny", + }), + user, + ), + options: {}, + }, + title: { + name: "title", + mode: "primary", + options: {}, + native: true, + hidden: true, + temperature: 0.5, + permission: Permission.merge( + defaults, + Permission.fromConfig({ + "*": "deny", + }), + user, + ), + prompt: PROMPT_TITLE, + }, + summary: { + name: "summary", + mode: "primary", + options: {}, + native: true, + hidden: true, + permission: Permission.merge( + defaults, + Permission.fromConfig({ + "*": "deny", + }), + user, + ), + prompt: PROMPT_SUMMARY, + }, + } - result[name].permission = Permission.merge( - result[name].permission, - Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }), + for (const [key, value] of Object.entries(cfg.agent ?? {})) { + if (value.disable) { + delete agents[key] + continue + } + let item = agents[key] + if (!item) + item = agents[key] = { + name: key, + mode: "all", + permission: Permission.merge(defaults, user), + options: {}, + native: false, + } + if (value.model) item.model = Provider.parseModel(value.model) + item.variant = value.variant ?? item.variant + item.prompt = value.prompt ?? item.prompt + item.description = value.description ?? item.description + item.temperature = value.temperature ?? item.temperature + item.topP = value.top_p ?? item.topP + item.mode = value.mode ?? item.mode + item.color = value.color ?? item.color + item.hidden = value.hidden ?? item.hidden + item.name = value.name ?? item.name + item.steps = value.steps ?? item.steps + item.options = mergeDeep(item.options, value.options ?? {}) + item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {})) + } + + // Ensure Truncate.GLOB is allowed unless explicitly configured + for (const name in agents) { + const agent = agents[name] + const explicit = agent.permission.some((r) => { + if (r.permission !== "external_directory") return false + if (r.action !== "deny") return false + return r.pattern === Truncate.GLOB + }) + if (explicit) continue + + agents[name].permission = Permission.merge( + agents[name].permission, + Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }), + ) + } + + const get = Effect.fnUntraced(function* (agent: string) { + return agents[agent] + }) + + const list = Effect.fnUntraced(function* () { + const cfg = yield* config() + return pipe( + agents, + values(), + sortBy( + [(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"], + [(x) => x.name, "asc"], + ), + ) + }) + + const defaultAgent = Effect.fnUntraced(function* () { + const c = yield* config() + if (c.default_agent) { + const agent = agents[c.default_agent] + if (!agent) throw new Error(`default agent "${c.default_agent}" not found`) + if (agent.mode === "subagent") throw new Error(`default agent "${c.default_agent}" is a subagent`) + if (agent.hidden === true) throw new Error(`default agent "${c.default_agent}" is hidden`) + return agent.name + } + const visible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true) + if (!visible) throw new Error("no primary visible agent found") + return visible.name + }) + + return { + get, + list, + defaultAgent, + } satisfies State + }), ) - } - return result - }) + return Service.of({ + get: Effect.fn("Agent.get")(function* (agent: string) { + return yield* InstanceState.useEffect(state, (s) => s.get(agent)) + }), + list: Effect.fn("Agent.list")(function* () { + return yield* InstanceState.useEffect(state, (s) => s.list()) + }), + defaultAgent: Effect.fn("Agent.defaultAgent")(function* () { + return yield* InstanceState.useEffect(state, (s) => s.defaultAgent()) + }), + generate: Effect.fn("Agent.generate")(function* (input: { + description: string + model?: { providerID: ProviderID; modelID: ModelID } + }) { + const cfg = yield* config() + const model = input.model ?? (yield* Effect.promise(() => Provider.defaultModel())) + const resolved = yield* Effect.promise(() => Provider.getModel(model.providerID, model.modelID)) + const language = yield* Effect.promise(() => Provider.getLanguage(resolved)) + + const system = [PROMPT_GENERATE] + yield* Effect.promise(() => + Plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system }), + ) + const existing = yield* InstanceState.useEffect(state, (s) => s.list()) + + const params = { + experimental_telemetry: { + isEnabled: cfg.experimental?.openTelemetry, + metadata: { + userId: cfg.username ?? "unknown", + }, + }, + temperature: 0.3, + messages: [ + ...system.map( + (item): ModelMessage => ({ + role: "system", + content: item, + }), + ), + { + role: "user", + content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`, + }, + ], + model: language, + schema: z.object({ + identifier: z.string(), + whenToUse: z.string(), + systemPrompt: z.string(), + }), + } satisfies Parameters[0] + + // TODO: clean this up so provider specific logic doesnt bleed over + const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie) + if (model.providerID === "openai" && authInfo?.type === "oauth") { + return yield* Effect.promise(async () => { + const result = streamObject({ + ...params, + providerOptions: ProviderTransform.providerOptions(resolved, { + store: false, + }), + onError: () => {}, + }) + for await (const part of result.fullStream) { + if (part.type === "error") throw part.error + } + return result.object + }) + } + + return yield* Effect.promise(() => generateObject(params).then((r) => r.object)) + }), + }) + }), + ) + + export const defaultLayer = layer.pipe(Layer.provide(Auth.layer)) + + const runPromise = makeRunPromise(Service, defaultLayer) export async function get(agent: string) { - return state().then((x) => x[agent]) + return runPromise((svc) => svc.get(agent)) } export async function list() { - const cfg = await Config.get() - return pipe( - await state(), - values(), - sortBy( - [(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"], - [(x) => x.name, "asc"], - ), - ) + return runPromise((svc) => svc.list()) } export async function defaultAgent() { - const cfg = await Config.get() - const agents = await state() - - if (cfg.default_agent) { - const agent = agents[cfg.default_agent] - if (!agent) throw new Error(`default agent "${cfg.default_agent}" not found`) - if (agent.mode === "subagent") throw new Error(`default agent "${cfg.default_agent}" is a subagent`) - if (agent.hidden === true) throw new Error(`default agent "${cfg.default_agent}" is hidden`) - return agent.name - } - - const primaryVisible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true) - if (!primaryVisible) throw new Error("no primary visible agent found") - return primaryVisible.name + return runPromise((svc) => svc.defaultAgent()) } export async function generate(input: { description: string; model?: { providerID: ProviderID; modelID: ModelID } }) { - const cfg = await Config.get() - const defaultModel = input.model ?? (await Provider.defaultModel()) - const model = await Provider.getModel(defaultModel.providerID, defaultModel.modelID) - const language = await Provider.getLanguage(model) - - const system = [PROMPT_GENERATE] - await Plugin.trigger("experimental.chat.system.transform", { model }, { system }) - const existing = await list() - - const params = { - experimental_telemetry: { - isEnabled: cfg.experimental?.openTelemetry, - metadata: { - userId: cfg.username ?? "unknown", - }, - }, - temperature: 0.3, - messages: [ - ...system.map( - (item): ModelMessage => ({ - role: "system", - content: item, - }), - ), - { - role: "user", - content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`, - }, - ], - model: language, - schema: z.object({ - identifier: z.string(), - whenToUse: z.string(), - systemPrompt: z.string(), - }), - } satisfies Parameters[0] - - // TODO: clean this up so provider specific logic doesnt bleed over - if (defaultModel.providerID === "openai" && (await Auth.get(defaultModel.providerID))?.type === "oauth") { - const result = streamObject({ - ...params, - providerOptions: ProviderTransform.providerOptions(model, { - store: false, - }), - onError: () => {}, - }) - for await (const part of result.fullStream) { - if (part.type === "error") throw part.error - } - return result.object - } - - const result = await generateObject(params) - return result.object + return runPromise((svc) => svc.generate(input)) } } From 98b3340ceeb6928d0d57898d02665d763ef1ea9c Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:23:41 -0500 Subject: [PATCH 23/48] fix(app): more startup efficiency (#18985) --- packages/app/src/components/prompt-input.tsx | 1 + .../app/src/context/global-sync/bootstrap.ts | 308 +++++++++++------- packages/app/src/context/settings.tsx | 7 +- packages/app/src/context/sync.tsx | 7 +- packages/app/src/pages/home.tsx | 8 + packages/app/src/pages/session.tsx | 7 +- .../pages/session/use-session-hash-scroll.ts | 18 + packages/app/vite.js | 12 + 8 files changed, 240 insertions(+), 128 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index f523671ec9..ee98e68cd5 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -572,6 +572,7 @@ export const PromptInput: Component = (props) => { const open = recent() const seen = new Set(open) const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) + if (!query.trim()) return [...agents, ...pinned] const paths = await files.searchFilesAndDirectories(query) const fileOptions: AtOption[] = paths .filter((path) => !seen.has(path)) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index c795ab471c..47be3abcb3 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -31,6 +31,47 @@ type GlobalStore = { reload: undefined | "pending" | "complete" } +function waitForPaint() { + return new Promise((resolve) => { + let done = false + const finish = () => { + if (done) return + done = true + resolve() + } + const timer = setTimeout(finish, 50) + if (typeof requestAnimationFrame !== "function") return + requestAnimationFrame(() => { + clearTimeout(timer) + finish() + }) + }) +} + +function errors(list: PromiseSettledResult[]) { + return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason) +} + +function runAll(list: Array<() => Promise>) { + return Promise.allSettled(list.map((item) => item())) +} + +function showErrors(input: { + errors: unknown[] + title: string + translate: (key: string, vars?: Record) => string + formatMoreCount: (count: number) => string +}) { + if (input.errors.length === 0) return + const message = formatServerError(input.errors[0], input.translate) + const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : "" + showToast({ + variant: "error", + title: input.title, + description: message + more, + }) +} + export async function bootstrapGlobal(input: { globalSDK: OpencodeClient requestFailedTitle: string @@ -38,45 +79,54 @@ export async function bootstrapGlobal(input: { formatMoreCount: (count: number) => string setGlobalStore: SetStoreFunction }) { - const tasks = [ - retry(() => - input.globalSDK.path.get().then((x) => { - input.setGlobalStore("path", x.data!) - }), - ), - retry(() => - input.globalSDK.global.config.get().then((x) => { - input.setGlobalStore("config", x.data!) - }), - ), - retry(() => - input.globalSDK.project.list().then((x) => { - const projects = (x.data ?? []) - .filter((p) => !!p?.id) - .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) - .slice() - .sort((a, b) => cmp(a.id, b.id)) - input.setGlobalStore("project", projects) - }), - ), - retry(() => - input.globalSDK.provider.list().then((x) => { - input.setGlobalStore("provider", normalizeProviderList(x.data!)) - }), - ), + const fast = [ + () => + retry(() => + input.globalSDK.path.get().then((x) => { + input.setGlobalStore("path", x.data!) + }), + ), + () => + retry(() => + input.globalSDK.global.config.get().then((x) => { + input.setGlobalStore("config", x.data!) + }), + ), + () => + retry(() => + input.globalSDK.provider.list().then((x) => { + input.setGlobalStore("provider", normalizeProviderList(x.data!)) + }), + ), ] - const results = await Promise.allSettled(tasks) - const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason) - if (errors.length) { - const message = formatServerError(errors[0], input.translate) - const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : "" - showToast({ - variant: "error", - title: input.requestFailedTitle, - description: message + more, - }) - } + const slow = [ + () => + retry(() => + input.globalSDK.project.list().then((x) => { + const projects = (x.data ?? []) + .filter((p) => !!p?.id) + .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) + .slice() + .sort((a, b) => cmp(a.id, b.id)) + input.setGlobalStore("project", projects) + }), + ), + ] + + showErrors({ + errors: errors(await runAll(fast)), + title: input.requestFailedTitle, + translate: input.translate, + formatMoreCount: input.formatMoreCount, + }) + await waitForPaint() + showErrors({ + errors: errors(await runAll(slow)), + title: input.requestFailedTitle, + translate: input.translate, + formatMoreCount: input.formatMoreCount, + }) input.setGlobalStore("ready", true) } @@ -119,95 +169,113 @@ export async function bootstrapDirectory(input: { } if (loading) input.setStore("status", "partial") - const results = await Promise.allSettled([ - seededProject - ? Promise.resolve() - : retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)), - retry(() => - input.sdk.provider.list().then((x) => { - input.setStore("provider", normalizeProviderList(x.data!)) - }), - ), - retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? []))), - retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))), - retry(() => - input.sdk.path.get().then((x) => { - input.setStore("path", x.data!) - const next = projectID(x.data?.directory ?? input.directory, input.global.project) - if (next) input.setStore("project", next) - }), - ), - retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))), - retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))), - input.loadSessions(input.directory), - retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))), - retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))), - retry(() => - input.sdk.vcs.get().then((x) => { - const next = x.data ?? input.store.vcs - input.setStore("vcs", next) - if (next?.branch) input.vcsCache.setStore("value", next) - }), - ), - retry(() => - input.sdk.permission.list().then((x) => { - const grouped = groupBySession( - (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), - ) - batch(() => { - for (const sessionID of Object.keys(input.store.permission)) { - if (grouped[sessionID]) continue - input.setStore("permission", sessionID, []) - } - for (const [sessionID, permissions] of Object.entries(grouped)) { - input.setStore( - "permission", - sessionID, - reconcile( - permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ), - ) - } - }) - }), - ), - retry(() => - input.sdk.question.list().then((x) => { - const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) - batch(() => { - for (const sessionID of Object.keys(input.store.question)) { - if (grouped[sessionID]) continue - input.setStore("question", sessionID, []) - } - for (const [sessionID, questions] of Object.entries(grouped)) { - input.setStore( - "question", - sessionID, - reconcile( - questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ), - ) - } - }) - }), - ), - ]) + const fast = [ + () => + seededProject + ? Promise.resolve() + : retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)), + () => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? []))), + () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))), + () => + retry(() => + input.sdk.path.get().then((x) => { + input.setStore("path", x.data!) + const next = projectID(x.data?.directory ?? input.directory, input.global.project) + if (next) input.setStore("project", next) + }), + ), + () => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))), + () => + retry(() => + input.sdk.vcs.get().then((x) => { + const next = x.data ?? input.store.vcs + input.setStore("vcs", next) + if (next?.branch) input.vcsCache.setStore("value", next) + }), + ), + () => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))), + () => + retry(() => + input.sdk.permission.list().then((x) => { + const grouped = groupBySession( + (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), + ) + batch(() => { + for (const sessionID of Object.keys(input.store.permission)) { + if (grouped[sessionID]) continue + input.setStore("permission", sessionID, []) + } + for (const [sessionID, permissions] of Object.entries(grouped)) { + input.setStore( + "permission", + sessionID, + reconcile( + permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ), + ) + } + }) + }), + ), + () => + retry(() => + input.sdk.question.list().then((x) => { + const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) + batch(() => { + for (const sessionID of Object.keys(input.store.question)) { + if (grouped[sessionID]) continue + input.setStore("question", sessionID, []) + } + for (const [sessionID, questions] of Object.entries(grouped)) { + input.setStore( + "question", + sessionID, + reconcile( + questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ), + ) + } + }) + }), + ), + ] - const errors = results - .filter((item): item is PromiseRejectedResult => item.status === "rejected") - .map((item) => item.reason) - if (errors.length > 0) { - console.error("Failed to bootstrap instance", errors[0]) + const slow = [ + () => + retry(() => + input.sdk.provider.list().then((x) => { + input.setStore("provider", normalizeProviderList(x.data!)) + }), + ), + () => Promise.resolve(input.loadSessions(input.directory)), + () => retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))), + () => retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))), + ] + + 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(errors[0], input.translate), + description: formatServerError(errs[0], input.translate), }) - return } - if (loading) input.setStore("status", "complete") + await waitForPaint() + const slowErrs = errors(await runAll(slow)) + if (slowErrs.length > 0) { + console.error("Failed to finish bootstrap instance", slowErrs[0]) + const project = getFilename(input.directory) + showToast({ + variant: "error", + title: input.translate("toast.project.reloadFailed.title", { project }), + description: formatServerError(slowErrs[0], input.translate), + }) + } + + if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete") } diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 247d36dd36..eddd752eb4 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -118,8 +118,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont createEffect(() => { if (typeof document === "undefined") return - void loadFont().then((x) => x.ensureMonoFont(store.appearance?.font)) - document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font)) + const id = store.appearance?.font ?? defaultSettings.appearance.font + if (id !== defaultSettings.appearance.font) { + void loadFont().then((x) => x.ensureMonoFont(id)) + } + document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(id)) }) return { diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 66b889e2ad..bbf4fc5ec4 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -180,7 +180,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ return globalSync.child(directory) } const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") - const messagePageSize = 200 + const initialMessagePageSize = 80 + const historyMessagePageSize = 200 const inflight = new Map>() const inflightDiff = new Map>() const inflightTodo = new Map>() @@ -463,7 +464,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined if (cached && hasSession && !opts?.force) return - const limit = meta.limit[key] ?? messagePageSize + const limit = meta.limit[key] ?? initialMessagePageSize const sessionReq = hasSession && !opts?.force ? Promise.resolve() @@ -560,7 +561,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const [, setStore] = globalSync.child(directory) touch(directory, setStore, sessionID) const key = keyFor(directory, sessionID) - const step = count ?? messagePageSize + const step = count ?? historyMessagePageSize if (meta.loading[key]) return if (meta.complete[key]) return const before = meta.cursor[key] diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index ba3a2b9427..4c795b9683 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -113,6 +113,14 @@ export default function Home() {
+ +
+
{language.t("common.loading")}
+ +
+
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 19dcba58ee..722a688bba 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1179,8 +1179,6 @@ export default function Page() { on( () => sdk.directory, () => { - void file.tree.list("") - const tab = activeFileTab() if (!tab) return const path = file.pathFromTab(tab) @@ -1635,6 +1633,9 @@ export default function Page() { sessionID: () => params.id, messagesReady, visibleUserMessages, + historyMore, + historyLoading, + loadMore: (sessionID) => sync.session.history.loadMore(sessionID), turnStart: historyWindow.turnStart, currentMessageId: () => store.messageId, pendingMessage: () => ui.pendingMessage, @@ -1706,7 +1707,7 @@ export default function Page() {
- + string | undefined messagesReady: () => boolean visibleUserMessages: () => UserMessage[] + historyMore: () => boolean + historyLoading: () => boolean + loadMore: (sessionID: string) => Promise turnStart: () => number currentMessageId: () => string | undefined pendingMessage: () => string | undefined @@ -181,6 +184,21 @@ export const useSessionHashScroll = (input: { queue(() => scrollToMessage(msg, "auto")) }) + createEffect(() => { + const sessionID = input.sessionID() + if (!sessionID || !input.messagesReady()) return + + visibleUserMessages() + + let targetId = input.pendingMessage() + if (!targetId && !clearing) targetId = messageIdFromHash(location.hash) + if (!targetId) return + if (messageById().has(targetId)) return + if (!input.historyMore() || input.historyLoading()) return + + void input.loadMore(sessionID) + }) + onMount(() => { if (typeof window !== "undefined" && "scrollRestoration" in window.history) { window.history.scrollRestoration = "manual" diff --git a/packages/app/vite.js b/packages/app/vite.js index 6b8fd61376..f65a68a1cb 100644 --- a/packages/app/vite.js +++ b/packages/app/vite.js @@ -1,7 +1,10 @@ +import { readFileSync } from "node:fs" import solidPlugin from "vite-plugin-solid" import tailwindcss from "@tailwindcss/vite" import { fileURLToPath } from "url" +const theme = fileURLToPath(new URL("./public/oc-theme-preload.js", import.meta.url)) + /** * @type {import("vite").PluginOption} */ @@ -21,6 +24,15 @@ export default [ } }, }, + { + name: "opencode-desktop:theme-preload", + transformIndexHtml(html) { + return html.replace( + '', + ``, + ) + }, + }, tailwindcss(), solidPlugin(), ] From 9838f56a6f8598ae5d9b587067e4de20adfb303d Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:35:15 -0500 Subject: [PATCH 24/48] fix(app): sidebar ux --- packages/app/src/pages/layout.tsx | 54 ++++++++++++++++++------------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index d01c7d3ceb..731f0fe5b2 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1818,6 +1818,9 @@ export default function Layout(props: ParentProps) { document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`) }) + const side = createMemo(() => Math.max(layout.sidebar.width(), 244)) + const panel = createMemo(() => Math.max(side() - 64, 0)) + const loadedSessionDirs = new Set() createEffect( @@ -2094,7 +2097,7 @@ export default function Layout(props: ParentProps) { "max-w-full overflow-hidden": panelProps.mobile, }} style={{ - width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`, + width: panelProps.mobile ? undefined : `${panel()}px`, }} > @@ -2384,7 +2389,7 @@ export default function Layout(props: ParentProps) { "absolute inset-y-0 left-0": true, "z-10": true, }} - style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }} + style={{ width: `${side()}px` }} ref={(el) => { setState("nav", el) }} @@ -2399,24 +2404,29 @@ export default function Layout(props: ParentProps) { }} >
{sidebarContent()}
- -
setState("sizing", true)}> - { - setState("sizing", true) - if (sizet !== undefined) clearTimeout(sizet) - sizet = window.setTimeout(() => setState("sizing", false), 120) - layout.sidebar.resize(w) - }} - /> -
-
+ + + +