Compare commits

..

16 Commits

Author SHA1 Message Date
Kit Langton 76d814e747 refactor(server): unify instance httpapi middleware routing
Unify declared instance HTTP API endpoints under typed middleware routing, including event streaming and PTY WebSocket connect handling.\n\nPreserve PTY connect compatibility by checking missing PTYs before parsing optional cursor and ticket query fields, with regression coverage.
2026-05-27 08:45:11 -04:00
Adam 9941e70d29 fix: better dockerfile 2026-05-27 06:55:44 -05:00
Adam 2afb812620 fix: stats dockerfile 2026-05-27 06:39:39 -05:00
Adam ad2fc1ce4b chore: reestablish datalake 2026-05-27 06:17:16 -05:00
Victor Navarro 5acc368ef4 perf: use redis for api key rate limit (#29242) 2026-05-27 11:27:06 +02:00
Brendan Allan e2dc89c6f3 fix(ui): migrate to --v2-font-family-sans variable and improve home empty state (#29552) 2026-05-27 09:18:52 +00:00
Brendan Allan e1581183ff feat(app): allow toggling tabs layout (#29526) 2026-05-27 15:42:21 +08:00
Brendan Allan f195c952fc fix(app): show tab close button properly (#29523) 2026-05-27 06:36:04 +00:00
opencode-agent[bot] 34b1045a01 chore: generate 2026-05-27 04:58:46 +00:00
OpeOginni aa9956d46d fix(tui): handle non-git project paths when opening editor (#29180) 2026-05-26 23:57:24 -05:00
opencode cec5a752bb sync release versions for v1.15.11 2026-05-27 04:01:11 +00:00
opencode-agent[bot] 0f0a4be253 fix(project): restore remote project identity (#29495) 2026-05-27 03:09:20 +00:00
opencode-agent[bot] 0e88dd99e7 chore: generate 2026-05-27 02:24:30 +00:00
Aiden Cline f965db9e13 feat: add headerTimeout cfg option, default it on only for openai w/ default of 10s (#29484) 2026-05-26 21:22:24 -05:00
Aiden Cline 519d344470 feat(plugin): add dispose hook (#29493) 2026-05-26 21:21:53 -05:00
Aiden Cline a78605f8ea fix(plugin): use codex session-id header (#29471) 2026-05-26 19:16:07 -05:00
110 changed files with 1257 additions and 747 deletions
+22 -22
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -85,7 +85,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -121,7 +121,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -148,7 +148,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -170,7 +170,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -194,7 +194,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "0.0.0",
"version": "1.15.11",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@@ -214,7 +214,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.15.10",
"version": "1.15.11",
"bin": {
"opencode": "./bin/opencode",
},
@@ -275,7 +275,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"drizzle-orm": "catalog:",
@@ -330,7 +330,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -344,7 +344,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -374,7 +374,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -390,7 +390,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
@@ -403,7 +403,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -421,7 +421,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.15.10",
"version": "1.15.11",
"bin": {
"opencode": "./bin/opencode",
},
@@ -559,7 +559,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -597,7 +597,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -612,7 +612,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -625,7 +625,7 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.14.50",
"version": "1.15.11",
"dependencies": {
"@opencode-ai/stats-core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -648,7 +648,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.14.50",
"version": "1.15.11",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -667,7 +667,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.14.50",
"version": "1.15.11",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -707,7 +707,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -756,7 +756,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
+1 -3
View File
@@ -5,10 +5,8 @@ export const domain = (() => {
})()
export const zoneID = "430ba34c138cfb5360826c4909f99be8"
// Dev owns the shared AWS lake/stats infra for all non-production stages.
export const awsStage = $app.stage === "production" ? "production" : "dev"
// Temporarily omit AWS infra so SST removes the lake/stats resources.
export const deployAws = false
export const deployAws = $app.stage === awsStage
new cloudflare.RegionalHostname("RegionalHostname", {
hostname: domain,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.15.10",
"version": "1.15.11",
"description": "",
"type": "module",
"exports": {
+19 -6
View File
@@ -14,6 +14,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect"
import {
type Component,
createEffect,
createMemo,
createResource,
createSignal,
@@ -40,7 +41,7 @@ import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission"
import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
import { SettingsProvider } from "@/context/settings"
import { SettingsProvider, useSettings } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal"
import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout"
@@ -48,11 +49,6 @@ import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
import { ServersProvider } from "./context/servers"
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "prod") {
document.body.classList.remove("text-12-regular")
document.body.classList.add("font-(family-name:--font-family-text)", "text-[13px]", "font-[440]")
}
const HomeRoute = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
@@ -97,9 +93,26 @@ function QueryProvider(props: ParentProps) {
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
}
function BodyDesignClass() {
const settings = useSettings()
createEffect(() => {
if (typeof document === "undefined") return
const enabled = settings.general.newLayoutDesigns()
document.body.classList.toggle("text-12-regular", !enabled)
document.body.classList.toggle("font-(family-name:--font-family-text)", enabled)
document.body.classList.toggle("text-[13px]", enabled)
document.body.classList.toggle("font-[440]", enabled)
})
return null
}
function AppShellProviders(props: ParentProps) {
return (
<SettingsProvider>
<BodyDesignClass />
<PermissionProvider>
<LayoutProvider>
<NotificationProvider>
+2 -4
View File
@@ -79,8 +79,6 @@ import { pathKey } from "@/utils/path-key"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { displayName } from "@/pages/layout/helpers"
const USE_V2_INPUT = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
interface PromptInputProps {
class?: string
variant?: "dock" | "new-session"
@@ -1456,7 +1454,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/>
<Switch>
<Match when={USE_V2_INPUT}>
<Match when={settings.general.newLayoutDesigns()}>
<div class="flex flex-col gap-3">
<DockShellForm
data-component={newSession() ? "session-new-composer" : "session-composer"}
@@ -1528,7 +1526,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onKeyDown={handleKeyDown}
classList={{
"select-text": true,
"min-h-[52px] w-full px-4 pt-4 pb-2 focus:outline-none whitespace-pre-wrap leading-5 text-[13px] font-[440] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)]": true,
"min-h-[52px] w-full px-4 pt-4 pb-2 focus:outline-none whitespace-pre-wrap leading-5 text-[13px] font-[440] text-v2-text-text-base": true,
"[&_[data-type=file]]:text-syntax-property": true,
"[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell",
@@ -45,8 +45,6 @@ const OPEN_APPS = [
"sublime-text",
] as const
const USE_V2_TITLEBAR = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
type OpenApp = (typeof OPEN_APPS)[number]
type OS = "macos" | "windows" | "linux" | "unknown"
@@ -157,11 +155,11 @@ export function SessionHeader() {
})
const hotkey = createMemo(() => command.keybind("file.open"))
const os = createMemo(() => detectOS(platform))
const isDesktopV2 = platform.platform === "desktop" && USE_V2_TITLEBAR
const search = createMemo(() => (isDesktopV2 ? settings.general.showSearch() : true))
const tree = createMemo(() => (isDesktopV2 ? settings.general.showFileTree() : true))
const term = createMemo(() => (isDesktopV2 ? settings.general.showTerminal() : true))
const status = createMemo(() => (isDesktopV2 ? settings.general.showStatus() : true))
const isDesktopV2 = createMemo(() => platform.platform === "desktop" && settings.general.newLayoutDesigns())
const search = createMemo(() => (isDesktopV2() ? settings.general.showSearch() : true))
const tree = createMemo(() => (isDesktopV2() ? settings.general.showFileTree() : true))
const term = createMemo(() => (isDesktopV2() ? settings.general.showTerminal() : true))
const status = createMemo(() => (isDesktopV2() ? settings.general.showStatus() : true))
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
finder: true,
@@ -399,6 +399,18 @@ export const SettingsGeneral: Component = () => {
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.newLayoutDesigns.title")}
description={language.t("settings.general.row.newLayoutDesigns.description")}
>
<div data-action="settings-new-layout-designs">
<Switch
checked={settings.general.newLayoutDesigns()}
onChange={(checked) => settings.general.setNewLayoutDesigns(checked)}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
@@ -802,7 +814,7 @@ export const SettingsGeneral: Component = () => {
<DisplaySection />
<Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
<Show when={desktop()}>
<AdvancedSection />
</Show>
</div>
+9 -11
View File
@@ -55,7 +55,6 @@ const legacyTitlebarHeight = 40
const v2TitlebarHeight = 44
const minTitlebarZoom = 0.25
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
const USE_V2_TITLEBAR = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const makeSessionHref = (b64Dir: string, sessionId: string) => `/${b64Dir}/session/${sessionId}`
@@ -75,6 +74,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const navigate = useNavigate()
const location = useLocation()
const params = useParams()
const useV2Titlebar = createMemo(() => settings.general.newLayoutDesigns())
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
@@ -85,7 +85,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
const minHeight = () => {
const height = USE_V2_TITLEBAR ? v2TitlebarHeight : legacyTitlebarHeight
const height = useV2Titlebar() ? v2TitlebarHeight : legacyTitlebarHeight
if (mac()) return `${height / zoom()}px`
if (windows()) return `${height / Math.min(titlebarZoom(), 1)}px`
return undefined
@@ -119,7 +119,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const canBack = createMemo(() => history.index > 0)
const canForward = createMemo(() => history.index < history.stack.length - 1)
const hasProjects = createMemo(() => layout.projects.list().length > 0)
const nav = createMemo(() => (USE_V2_TITLEBAR ? settings.general.showNavigation() : true))
const nav = createMemo(() => (useV2Titlebar() ? settings.general.showNavigation() : true))
const updateState = createMemo<TitlebarUpdatePillState>(() => {
const version = props.update?.version()
return {
@@ -222,8 +222,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<header
classList={{
"shrink-0 relative overflow-hidden flex flex-row": true,
"h-11 bg-v2-background-bg-deep": USE_V2_TITLEBAR,
"h-10 bg-background-base": !USE_V2_TITLEBAR,
"h-11 bg-v2-background-bg-deep": useV2Titlebar(),
"h-10 bg-background-base": !useV2Titlebar(),
}}
style={{
"min-height": minHeight(),
@@ -239,7 +239,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
onDblClick={maximize}
>
<Switch>
<Match when={USE_V2_TITLEBAR}>
<Match when={useV2Titlebar()}>
{(_) => {
const serverSync = useServerSync()
const navigate = useNavigate()
@@ -490,7 +490,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
project={projectForSession(tab.info, projects(), projectByID())}
directory={tab.dir}
onClose={() => tabsStoreActions.removeTab(tab.href)}
hideClose={tabsEnriched().length < 2}
/>
</>
)}
@@ -737,14 +736,13 @@ function TabNavItem(props: {
title: string
project?: LocalProject
directory: string
hideClose?: boolean
onClose: () => void
}) {
const match = useMatch(() => props.href)
const isActive = () => !!match()
return (
<div
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] pl-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
data-active={isActive()}
>
<a
@@ -755,7 +753,7 @@ function TabNavItem(props: {
<span class="text-clip leading-5">{props.title}</span>
</a>
<div class="absolute right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
<div class="absolute not-group-hover:not-group-data-[active=true]:left-52 group-hover:right-0 group-data-[active=true]:right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
<div
class="absolute inset-0 bg-(image:--inactive-bg) group-hover:bg-(image:--active-bg) group-data-[active=true]:bg-(image:--active-bg)"
style={{
@@ -766,7 +764,7 @@ function TabNavItem(props: {
<IconButtonV2
size="small"
variant="ghost-muted"
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100"
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100 z-10"
onClick={props.onClose}
icon={<IconV2 name="xmark-small" />}
/>
+6
View File
@@ -33,6 +33,7 @@ export interface Settings {
editToolPartsExpanded: boolean
showSessionProgressBar: boolean
showCustomAgents: boolean
newLayoutDesigns?: boolean
}
updates: {
startup: boolean
@@ -54,6 +55,7 @@ export interface Settings {
export const monoDefault = "System Mono"
export const sansDefault = "System Sans"
export const terminalDefault = "JetBrainsMono Nerd Font Mono"
export const newLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const monoFallback =
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
@@ -242,6 +244,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setShowCustomAgents(value: boolean) {
setStore("general", "showCustomAgents", value)
},
newLayoutDesigns: withFallback(() => store.general?.newLayoutDesigns, newLayoutDesignsDefault),
setNewLayoutDesigns(value: boolean) {
setStore("general", "newLayoutDesigns", value)
},
},
updates: {
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
+2
View File
@@ -791,6 +791,8 @@ export const dict = {
"settings.general.row.showSessionProgressBar.title": "Show session progress bar",
"settings.general.row.showSessionProgressBar.description":
"Display the animated progress bar at the top of the session when the agent is working",
"settings.general.row.newLayoutDesigns.title": "New layout and designs",
"settings.general.row.newLayoutDesigns.description": "Enable the redesigned layout, home, composer, and session UI",
"settings.general.row.pinchZoom.title": "Pinch to zoom",
"settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom",
+109 -104
View File
@@ -31,8 +31,8 @@ import { messageAgentColor } from "@/utils/agent"
import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
import { ServerHealthIndicator } from "@/components/server/server-row"
import { useServers } from "@/context/servers"
import { useSettings } from "@/context/settings"
const USE_HOME_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const HOME_SESSION_LIMIT = 15
const HOME_ROW =
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] border-0 bg-transparent text-left text-v2-text-text-muted transition-colors duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
@@ -52,8 +52,12 @@ type HomeSessionGroup = {
}
export default function Home() {
if (USE_HOME_DESIGN) return <HomeDesign />
return <LegacyHome />
const settings = useSettings()
return (
<Show when={settings.general.newLayoutDesigns()} fallback={<LegacyHome />}>
<HomeDesign />
</Show>
)
}
function HomeDesign() {
@@ -68,13 +72,11 @@ function HomeDesign() {
const [state, setState] = createStore({ search: "", project: undefined as string | undefined })
const projects = createMemo(() => layout.projects.list())
const selectedProject = createMemo(
() => projects().find((project) => project.worktree === state.project) ?? projects()[0],
)
const selectedProject = createMemo(() => projects().find((project) => project.worktree === state.project))
const directories = (project: LocalProject) => [project.worktree, ...(project.sandboxes ?? [])]
const projectDirectories = createMemo(() => {
const project = selectedProject()
if (!project) return []
if (!project) return [...projects().flatMap((project) => directories(project))]
return directories(project)
})
const search = createMemo(() => state.search.trim())
@@ -89,8 +91,8 @@ function HomeDesign() {
const projectByID = createMemo(
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
)
const records = createMemo(() =>
[
const records = createMemo(() => {
return [
...new Map(
projectDirectories()
.flatMap((directory) => sortedRootSessions(sync.child(directory, { bootstrap: false })[0], Date.now()))
@@ -112,8 +114,8 @@ function HomeDesign() {
if (!value) return true
return `${record.session.title} ${record.projectName}`.toLowerCase().includes(value)
})
.slice(0, HOME_SESSION_LIMIT),
)
.slice(0, HOME_SESSION_LIMIT)
})
const groups = createMemo(() => groupSessions(records(), language))
function selectProject(directory: string) {
@@ -220,7 +222,7 @@ function HomeDesign() {
aria-label={language.t("sidebar.project.recentSessions")}
>
<Show
when={selectedProject()}
when={projectDirectories().length > 0}
fallback={
<HomeEmptyState
icon="folder-add-left"
@@ -298,8 +300,8 @@ function HomeProjectColumn(props: {
const layout = useLayout()
const projects = createMemo(() => layout.projects.list())
return (
<aside class="flex min-w-0 flex-col lg:pt-[52px]" aria-label={props.language.t("home.projects")}>
<div class="flex h-7 min-w-0 items-center justify-between pl-3">
<aside class="flex min-w-0 flex-col lg:pt-[52px] gap-4" aria-label={props.language.t("home.projects")}>
<div class="flex h-7 min-w-0 items-center justify-between pl-1.5">
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
<IconButtonV2
data-action="home-add-project"
@@ -311,8 +313,8 @@ function HomeProjectColumn(props: {
aria-label={props.language.t("home.project.add")}
/>
</div>
<For
each={servers.list()}
<Show
when={servers.list().length > 1}
fallback={
<ProjectList
projects={projects()}
@@ -328,61 +330,63 @@ function HomeProjectColumn(props: {
/>
}
>
{(server) => {
const key = ServerConnection.key(server)
const healthy = () => !!servers.health[key]?.healthy
const [open, setOpen] = createSignal(true)
<For each={servers.list()}>
{(server) => {
const key = ServerConnection.key(server)
const healthy = () => !!servers.health[key]?.healthy
const [open, setOpen] = createSignal(true)
return (
<div class="mt-4 max-h-[min(572px,calc(100vh_-_300px))] min-w-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div class="relative h-7 group">
<button
class="w-full h-full px-1.5 gap-2 flex flex-row items-center hover:not-disabled:bg-v2-overlay-simple-overlay-hover rounded-[4px]"
disabled={!healthy()}
onClick={() => setOpen((o) => !o)}
>
<div class="size-4 flex items-center justify-center">
<ServerHealthIndicator health={servers.health[key]} />
</div>
<div class="flex flex-row items-center gap-1">
<span>{server.displayName ?? new URL(server.http.url).host}</span>
<Show when={healthy()}>
<IconV2
name="outline-chevron-down"
class="text-v2-icon-icon-muted data-[open=false]:-rotate-90"
data-open={open()}
/>
</Show>
</div>
</button>
<IconButtonV2
class="absolute right-1 inset-y-1 opacity-0 group-hover:opacity-100"
name="out"
variant="ghost-muted"
size="small"
icon={<IconV2 name="outline-dots" class="text-v2-icon-icon-muted" />}
/>
return (
<div class="max-h-[min(572px,calc(100vh_-_300px))] min-w-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div class="relative h-7 group">
<button
class="w-full h-full px-1.5 gap-2 flex flex-row items-center hover:not-disabled:bg-v2-overlay-simple-overlay-hover rounded-[4px]"
disabled={!healthy()}
onClick={() => setOpen((o) => !o)}
>
<div class="size-4 flex items-center justify-center">
<ServerHealthIndicator health={servers.health[key]} />
</div>
<div class="flex flex-row items-center gap-1">
<span>{server.displayName ?? new URL(server.http.url).host}</span>
<Show when={healthy()}>
<IconV2
name="outline-chevron-down"
class="text-v2-icon-icon-muted data-[open=false]:-rotate-90"
data-open={open()}
/>
</Show>
</div>
</button>
<IconButtonV2
class="absolute right-1 inset-y-1 opacity-0 group-hover:opacity-100"
name="out"
variant="ghost-muted"
size="small"
icon={<IconV2 name="outline-dots" class="text-v2-icon-icon-muted" />}
/>
</div>
<Show when={healthy() && open()}>
<div class="h-px bg-v2-border-border-base mx-3 my-1" />
<ProjectList
projects={projects()}
selectedProject={props.selectedProject}
onSelectedProjectChange={props.selectProject}
onChooseProject={props.chooseProject}
openNewSession={props.openNewSession}
editProject={props.editProject}
closeProject={props.closeProject}
clearNotifications={props.clearNotifications}
unseenCount={props.unseenCount}
language={props.language}
/>
</Show>
</div>
<Show when={healthy() && open()}>
<div class="h-px bg-v2-border-border-base mx-3 my-1" />
<ProjectList
projects={projects()}
selectedProject={props.selectedProject}
onSelectedProjectChange={props.selectProject}
onChooseProject={props.chooseProject}
openNewSession={props.openNewSession}
editProject={props.editProject}
closeProject={props.closeProject}
clearNotifications={props.clearNotifications}
unseenCount={props.unseenCount}
language={props.language}
/>
</Show>
</div>
)
}}
</For>
<div class="mt-4 flex min-w-0 flex-col gap-1">
)
}}
</For>
</Show>
<div class="flex min-w-0 flex-col gap-1">
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
@@ -416,12 +420,14 @@ function HomeProjectRow(props: {
language: ReturnType<typeof useLanguage>
}) {
const name = createMemo(() => displayName(props.project))
const [menuOpen, setMenuOpen] = createSignal(false)
return (
<div class="group/project relative flex h-8 min-w-0 items-center rounded-[6px] hover:bg-v2-overlay-simple-overlay-hover focus-within:bg-v2-overlay-simple-overlay-hover">
<div class="group/project relative flex h-8 min-w-0 items-center rounded-[6px]">
<button
type="button"
data-component="home-project-row"
class={`${HOME_PROJECT_NAV_ROW} pr-16`}
class={`${HOME_PROJECT_NAV_ROW} pr-16 peer`}
classList={{ "bg-v2-overlay-simple-overlay-hover": props.selected }}
data-selected={props.selected ? "" : undefined}
aria-current={props.selected ? "page" : undefined}
@@ -430,7 +436,37 @@ function HomeProjectRow(props: {
<HomeProjectAvatar project={props.project} />
<span>{name()}</span>
</button>
<div class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/project:opacity-100 group-focus-within/project:opacity-100">
<div
class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/project:opacity-100 peer-focus-visible:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
data-menu={menuOpen()}
>
<MenuV2 gutter={4} modal={false} placement="bottom-end" open={menuOpen()} onOpenChange={setMenuOpen}>
<MenuV2.Trigger
as={IconButtonV2}
data-action="home-project-menu"
variant="ghost-muted"
size="small"
icon={<IconV2 name="outline-dots" />}
aria-label={props.language.t("common.moreOptions")}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={() => props.openNewSession(props.project.worktree)}>
{props.language.t("command.session.new")}
</MenuV2.Item>
<MenuV2.Item onSelect={() => props.editProject(props.project)}>
{props.language.t("common.edit")}
</MenuV2.Item>
<MenuV2.Item disabled={props.unseenCount === 0} onSelect={() => props.clearNotifications(props.project)}>
{props.language.t("sidebar.project.clearNotifications")}
</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => props.closeProject(props.project.worktree)}>
{props.language.t("common.close")}
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
<IconButtonV2
data-action="home-project-new-session"
variant="ghost-muted"
@@ -442,37 +478,6 @@ function HomeProjectRow(props: {
props.openNewSession(props.project.worktree)
}}
/>
<MenuV2 gutter={4} modal={false} placement="bottom-end">
<MenuV2.Trigger
as={IconButtonV2}
data-action="home-project-menu"
variant="ghost-muted"
size="small"
icon={<IconV2 name="menu" />}
aria-label={props.language.t("common.moreOptions")}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={() => props.openNewSession(props.project.worktree)}>
<Icon name="new-session" size="small" />
{props.language.t("command.session.new")}
</MenuV2.Item>
<MenuV2.Item onSelect={() => props.editProject(props.project)}>
<Icon name="edit" size="small" />
{props.language.t("common.edit")}
</MenuV2.Item>
<MenuV2.Item disabled={props.unseenCount === 0} onSelect={() => props.clearNotifications(props.project)}>
<Icon name="circle-check" size="small" />
{props.language.t("sidebar.project.clearNotifications")}
</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => props.closeProject(props.project.worktree)}>
<Icon name="close" size="small" />
{props.language.t("common.close")}
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</div>
</div>
)
@@ -561,7 +566,7 @@ function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => voi
variant="ghost"
size="normal"
icon="edit"
class="h-7 px-2 text-v2-text-text-muted [font-weight:530]"
class="h-7 px-2 text-v2-text-text-muted"
onClick={onNewSession()}
>
{language.t("command.session.new")}
+160 -160
View File
@@ -89,8 +89,6 @@ import {
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
import { SidebarContent } from "./layout/sidebar-shell"
const USE_NEW_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
export default function Layout(props: ParentProps) {
const [store, setStore, , ready] = persisted(
Persist.global("layout.page", ["layout.page.v1"]),
@@ -129,6 +127,7 @@ export default function Layout(props: ParentProps) {
const command = useCommand()
const theme = useTheme()
const language = useLanguage()
const newDesign = createMemo(() => settings.general.newLayoutDesigns())
const initialDirectory = decode64(params.dir)
const location = useLocation()
const route = createMemo(() => {
@@ -154,7 +153,7 @@ export default function Layout(props: ParentProps) {
const currentDir = createMemo(() => route().dir)
const [state, setState] = createStore({
autoselect: !initialDirectory && !USE_NEW_DESIGN,
autoselect: !initialDirectory && !newDesign(),
busyWorkspaces: {} as Record<string, boolean>,
hoverProject: undefined as string | undefined,
scrollSessionKey: undefined as string | undefined,
@@ -1142,7 +1141,7 @@ export default function Layout(props: ParentProps) {
},
]
if (!USE_NEW_DESIGN)
if (!newDesign())
Array.from({ length: 9 }, (_, i) => {
const index = i
const number = index + 1
@@ -1821,7 +1820,7 @@ export default function Layout(props: ParentProps) {
createEffect(() => {
document.documentElement.style.setProperty(
"--dialog-left-margin",
USE_NEW_DESIGN ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
newDesign() ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
)
})
@@ -2363,179 +2362,180 @@ export default function Layout(props: ParentProps) {
/>
)
if (USE_NEW_DESIGN) {
return (
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
return (
<Show
when={!newDesign()}
fallback={
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
{autoselecting() ?? ""}
<Titlebar update={titlebarUpdate} />
<main
class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict bg-v2-background-bg-base"
classList={{
"m-2 mt-0 rounded-[10px] shadow-[var(--v2-elevation-raised)] overflow-hidden": !!params.id || !params.dir,
}}
>
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>
{import.meta.env.DEV && <DebugBar />}
<Toast.Region />
</div>
}
>
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
{autoselecting() ?? ""}
<Titlebar update={titlebarUpdate} />
<main
class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict bg-v2-background-bg-base"
classList={{
"m-2 mt-0 rounded-[10px] shadow-[var(--v2-elevation-raised)] overflow-hidden": !!params.id || !params.dir,
}}
>
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>
{import.meta.env.DEV && <DebugBar />}
<Toast.Region />
</div>
)
}
return (
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
{autoselecting() ?? ""}
<Titlebar update={titlebarUpdate} />
<Show when={updateVersion() !== undefined}>
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
</Show>
<div class="flex-1 min-h-0 min-w-0 flex">
<div class="flex-1 min-h-0 relative">
<div class="size-full relative overflow-x-hidden">
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-desktop"
classList={{
"hidden xl:block": true,
"absolute inset-y-0 left-0": true,
"z-10": true,
}}
style={{ width: `${side()}px` }}
ref={(el) => {
setState("nav", el)
}}
onMouseEnter={() => {
disarm()
}}
onMouseLeave={() => {
aim.reset()
if (!sidebarHovering()) return
arm()
}}
>
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
</nav>
<Show when={layout.sidebar.opened()}>
<div
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
style={{ left: `${side()}px` }}
onPointerDown={() => setState("sizing", true)}
>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
onResize={(w) => {
setState("sizing", true)
if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w)
}}
/>
</div>
</Show>
<div
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
style={{ left: "calc(4rem + 12px)" }}
/>
<div class="xl:hidden">
<div
classList={{
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
}}
onClick={(e) => {
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
}}
/>
<Show when={updateVersion() !== undefined}>
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
</Show>
<div class="flex-1 min-h-0 min-w-0 flex">
<div class="flex-1 min-h-0 relative">
<div class="size-full relative overflow-x-hidden">
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-mobile"
data-component="sidebar-nav-desktop"
classList={{
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
"translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(),
"hidden xl:block": true,
"absolute inset-y-0 left-0": true,
"z-10": true,
}}
style={{ width: `${side()}px` }}
ref={(el) => {
setState("nav", el)
}}
onMouseEnter={() => {
disarm()
}}
onMouseLeave={() => {
aim.reset()
if (!sidebarHovering()) return
arm()
}}
onClick={(e) => e.stopPropagation()}
>
{sidebarContent(true)}
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
</nav>
</div>
<div
classList={{
"absolute inset-0": true,
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
"z-20": true,
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
!state.sizing,
}}
style={{
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
}}
>
<main
<Show when={layout.sidebar.opened()}>
<div
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
style={{ left: `${side()}px` }}
onPointerDown={() => setState("sizing", true)}
>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
onResize={(w) => {
setState("sizing", true)
if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w)
}}
/>
</div>
</Show>
<div
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
style={{ left: "calc(4rem + 12px)" }}
/>
<div class="xl:hidden">
<div
classList={{
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
}}
onClick={(e) => {
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
}}
/>
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-mobile"
classList={{
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
"translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(),
}}
onClick={(e) => e.stopPropagation()}
>
{sidebarContent(true)}
</nav>
</div>
<div
classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
"absolute inset-0": true,
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
"z-20": true,
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
!state.sizing,
}}
style={{
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
}}
>
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children}
<main
classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
}}
>
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>
</div>
<div
classList={{
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
"opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
onMouseMove={disarm}
onMouseEnter={() => {
disarm()
aim.reset()
}}
onPointerDown={disarm}
onMouseLeave={() => {
arm()
}}
>
<Show when={peekProject()}>
<SidebarPanel project={peekProject} merged={false} />
</Show>
</main>
</div>
</div>
<div
classList={{
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
"opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
onMouseMove={disarm}
onMouseEnter={() => {
disarm()
aim.reset()
}}
onPointerDown={disarm}
onMouseLeave={() => {
arm()
}}
>
<Show when={peekProject()}>
<SidebarPanel project={peekProject} merged={false} />
</Show>
</div>
<div
classList={{
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
style={{ left: `calc(4rem + ${panel()}px)` }}
>
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
<div
classList={{
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
style={{ left: `calc(4rem + ${panel()}px)` }}
>
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
</div>
</div>
</div>
{import.meta.env.DEV && <DebugBar />}
</div>
{import.meta.env.DEV && <DebugBar />}
<Toast.Region />
</div>
<Toast.Region />
</div>
</Show>
)
}
+4 -4
View File
@@ -75,7 +75,6 @@ const emptyFollowups: FollowupItem[] = []
type ChangeMode = "git" | "branch" | "turn"
type VcsMode = "git" | "branch"
const USE_NEW_SESSION_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
type SessionHistoryWindowInput = {
sessionID: () => string | undefined
@@ -198,6 +197,7 @@ export default function Page() {
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
const location = useLocation()
const { params, sessionKey, tabs, view } = useSessionLayout()
const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns())
createEffect(() => {
if (!prompt.ready()) return
@@ -265,7 +265,7 @@ export default function Page() {
const isDesktop = createMediaQuery("(min-width: 768px)")
const size = createSizing()
const isV2NewSessionPage = () =>
shouldUseV2NewSessionPage({ channel: import.meta.env.VITE_OPENCODE_CHANNEL, sessionID: params.id })
shouldUseV2NewSessionPage({ newLayoutDesigns: newSessionDesign(), sessionID: params.id })
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened() && !isV2NewSessionPage())
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened() && !isV2NewSessionPage())
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
@@ -1798,14 +1798,14 @@ export default function Page() {
</Show>
</Match>
<Match when={true}>
<Show when={USE_NEW_SESSION_DESIGN} fallback={<NewSessionView worktree={newSessionWorktree()} />}>
<Show when={newSessionDesign()} fallback={<NewSessionView worktree={newSessionWorktree()} />}>
<NewSessionDesignView>{composerRegion("inline")}</NewSessionDesignView>
</Show>
</Match>
</Switch>
</div>
<Show when={params.id || !USE_NEW_SESSION_DESIGN}>{composerRegion("dock")}</Show>
<Show when={params.id || !newSessionDesign()}>{composerRegion("dock")}</Show>
<Show when={desktopReviewOpen()}>
<div onPointerDown={() => size.start()}>
@@ -2,13 +2,13 @@ import { describe, expect, test } from "bun:test"
import { shouldUseV2NewSessionPage } from "./new-session-layout"
describe("shouldUseV2NewSessionPage", () => {
test("keeps prod session pages on the legacy layout", () => {
expect(shouldUseV2NewSessionPage({ channel: "prod", sessionID: "ses_123" })).toBe(false)
expect(shouldUseV2NewSessionPage({ channel: "prod" })).toBe(false)
test("keeps disabled pages on the legacy layout", () => {
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: false, sessionID: "ses_123" })).toBe(false)
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: false })).toBe(false)
})
test("uses the v2 layout only for non-prod new-session pages", () => {
expect(shouldUseV2NewSessionPage({ channel: "dev" })).toBe(true)
expect(shouldUseV2NewSessionPage({ channel: "dev", sessionID: "ses_123" })).toBe(false)
test("uses the v2 layout only for enabled new-session pages", () => {
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: true })).toBe(true)
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: true, sessionID: "ses_123" })).toBe(false)
})
})
@@ -1,3 +1,3 @@
export function shouldUseV2NewSessionPage(input: { channel?: "dev" | "beta" | "prod"; sessionID?: string }) {
return input.channel !== "prod" && !input.sessionID
export function shouldUseV2NewSessionPage(input: { newLayoutDesigns: boolean; sessionID?: string }) {
return input.newLayoutDesigns && !input.sessionID
}
@@ -28,8 +28,6 @@ import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type S
import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
const USE_DESKTOP_V2 = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
@@ -60,7 +58,7 @@ export function SessionSidePanel(props: {
const { sessionKey, tabs, view, params } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
const desktopV2 = () => platform.platform === "desktop" && USE_DESKTOP_V2
const desktopV2 = () => platform.platform === "desktop" && settings.general.newLayoutDesigns()
const shown = createMemo(() => (desktopV2() ? settings.general.showFileTree() : true))
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
@@ -205,7 +203,7 @@ export function SessionSidePanel(props: {
})
return (
<Show when={isDesktop() && !(import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && !params.id)}>
<Show when={isDesktop() && !(settings.general.newLayoutDesigns() && !params.id)}>
<aside
id="review-panel"
aria-label={language.t("session.panel.reviewAndFiles")}
@@ -20,8 +20,6 @@ import { extractPromptFromParts } from "@/utils/prompt"
import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSessionLayout } from "@/pages/session/session-layout"
const USE_DESKTOP_V2 = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
export type SessionCommandContext = {
navigateMessageByOffset: (offset: number) => void
setActiveMessage: (message: UserMessage | undefined) => void
@@ -72,7 +70,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
})
const activeFileTab = tabState.activeFileTab
const closableTab = tabState.closableTab
const desktopV2 = () => platform.platform === "desktop" && USE_DESKTOP_V2
const desktopV2 = () => platform.platform === "desktop" && settings.general.newLayoutDesigns()
const shown = () => (desktopV2() ? settings.general.showFileTree() : true)
const messages = () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.15.10",
"version": "1.15.11",
"type": "module",
"license": "MIT",
"scripts": {
@@ -1,5 +1,3 @@
import { Database, eq, and, sql, inArray } from "@opencode-ai/console-core/drizzle/index.js"
import { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
import { FreeUsageLimitError } from "./error"
import { logger } from "./logger"
import { buildRateLimitKey, getRedis } from "./redis"
@@ -22,7 +20,6 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
const ip = !rawIp.length ? "unknown" : rawIp
const now = Date.now()
const lifetimeInterval = ""
const dailyInterval = rateLimit ? `${buildYYYYMMDD(now)}${modelId.substring(0, 2)}` : buildYYYYMMDD(now)
const retryAfter = getRetryAfterDay(now)
const redis = getRedis()
@@ -32,33 +29,12 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
return {
check: async () => {
const [counts, rows] = await Promise.all([
redis.mget<(string | number | null)[]>(isDefaultModel ? [lifetimeKey, dailyKey] : [dailyKey]).catch(() => []),
Database.use((tx) =>
tx
.select({ interval: IpRateLimitTable.interval, count: IpRateLimitTable.count })
.from(IpRateLimitTable)
.where(
and(
eq(IpRateLimitTable.ip, ip),
isDefaultModel
? inArray(IpRateLimitTable.interval, [lifetimeInterval, dailyInterval])
: inArray(IpRateLimitTable.interval, [dailyInterval]),
),
),
),
])
const redisLifetimeCount = isDefaultModel ? Number(counts[0] ?? 0) : 0
const redisDailyCount = Number(counts[isDefaultModel ? 1 : 0] ?? 0)
const databaseLifetimeCount = rows.find((r) => r.interval === lifetimeInterval)?.count ?? 0
const databaseDailyCount = rows.find((r) => r.interval === dailyInterval)?.count ?? 0
const lifetimeCount = Math.max(redisLifetimeCount, databaseLifetimeCount)
const dailyCount = Math.max(redisDailyCount, databaseDailyCount)
const counts = await redis.mget<(string | number | null)[]>(isDefaultModel ? [lifetimeKey, dailyKey] : [dailyKey])
const lifetimeCount = isDefaultModel ? Number(counts[0] ?? 0) : 0
const dailyCount = Number(counts[isDefaultModel ? 1 : 0] ?? 0)
logger.debug(`rate limit lifetime: ${lifetimeCount}, daily: ${dailyCount}`)
isNew = isDefaultModel && lifetimeCount < dailyLimit * 7
if (isDefaultModel && databaseLifetimeCount > redisLifetimeCount)
await redis.set(lifetimeKey, databaseLifetimeCount).catch(() => {})
if ((isNew && dailyCount >= dailyLimit * 2) || (!isNew && dailyCount >= dailyLimit))
throw new FreeUsageLimitError(dict["zen.api.error.rateLimitExceeded"], retryAfter)
@@ -68,18 +44,7 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
pipeline.incr(dailyKey)
pipeline.expire(dailyKey, retryAfter)
if (isNew) pipeline.incr(lifetimeKey)
await Promise.all([
pipeline.exec().catch(() => {}),
Database.use((tx) =>
tx
.insert(IpRateLimitTable)
.values([
{ ip, interval: dailyInterval, count: 1 },
...(isNew ? [{ ip, interval: lifetimeInterval, count: 1 }] : []),
])
.onDuplicateKeyUpdate({ set: { count: sql`${IpRateLimitTable.count} + 1` } }),
),
])
await pipeline.exec()
},
}
}
@@ -1,6 +1,5 @@
import { Database, eq, and, sql } from "@opencode-ai/console-core/drizzle/index.js"
import { KeyRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
import { RateLimitError } from "./error"
import { buildRateLimitKey, getRedis } from "./redis"
import { i18n } from "~/i18n"
import { localeFromRequest } from "~/lib/language"
@@ -19,26 +18,20 @@ export function createRateLimiter(
.replace(/[^0-9]/g, "")
.substring(0, 12)
const interval = `${modelId.substring(0, 27)}-${yyyyMMddHHmm}`
const redis = getRedis()
const key = buildRateLimitKey("key", zenApiKey, interval)
return {
check: async () => {
const rows = await Database.use((tx) =>
tx
.select({ interval: KeyRateLimitTable.interval, count: KeyRateLimitTable.count })
.from(KeyRateLimitTable)
.where(and(eq(KeyRateLimitTable.key, zenApiKey), eq(KeyRateLimitTable.interval, interval))),
).then((rows) => rows[0])
const count = rows?.count ?? 0
const count = Number((await redis.mget<(string | number | null)[]>([key]))[0] ?? 0)
if (count >= LIMIT) throw new RateLimitError(dict["zen.api.error.rateLimitExceeded"], 60)
},
track: async () => {
await Database.use((tx) =>
tx
.insert(KeyRateLimitTable)
.values({ key: zenApiKey, interval, count: 1 })
.onDuplicateKeyUpdate({ set: { count: sql`${KeyRateLimitTable.count} + 1` } }),
)
const pipeline = redis.pipeline()
pipeline.incr(key)
pipeline.expire(key, 60)
await pipeline.exec()
},
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.15.10",
"version": "1.15.11",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.15.10",
"version": "1.15.11",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.15.10",
"version": "1.15.11",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-support",
"version": "0.0.0",
"version": "1.15.11",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.10",
"version": "1.15.11",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -108,7 +108,7 @@ export const layer = Layer.effect(
if (!repo) return { id: ID.global, directory: input, vcs: undefined }
const previous = yield* cached(repo.store)
const id = previous ?? (yield* root(repo))
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
return {
previous,
+34 -6
View File
@@ -5,11 +5,16 @@ import path from "path"
import { Effect } from "effect"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Hash } from "@opencode-ai/core/util/hash"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(Project.defaultLayer)
function remoteID(remote: string) {
return Project.ID.make(Hash.fast(`git-remote:${remote}`))
}
function abs(value: string) {
return AbsolutePath.make(value)
}
@@ -86,7 +91,7 @@ describe("ProjectV2.resolve", () => {
}),
)
it.live("uses root commit when origin exists", () =>
it.live("prefers normalized origin over root commit", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -97,13 +102,36 @@ describe("ProjectV2.resolve", () => {
const result = yield* project.resolve(abs(tmp.path))
expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
expect(result.id).toBe(remoteID("github.com/Acme/App"))
expect(result.id).not.toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
expect(result.directory).toBe(yield* real(tmp.path))
expect(result.vcs?.type).toBe("git")
}),
)
it.live("uses root commit when local remote exists", () =>
it.live("normalizes ssh and https remotes to the same id", () =>
Effect.gen(function* () {
const ssh = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const https = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(ssh.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
yield* Effect.promise(() => initRepo(https.path, { commit: true, remote: "https://github.com/owner/repo.git" }))
const project = yield* Project.Service
const a = yield* project.resolve(abs(ssh.path))
const b = yield* project.resolve(abs(https.path))
expect(a.id).toBe(remoteID("github.com/owner/repo"))
expect(b.id).toBe(a.id)
}),
)
it.live("ignores file remotes and falls back to root commit", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -118,7 +146,7 @@ describe("ProjectV2.resolve", () => {
}),
)
it.live("prefers previous cached id over origin", () =>
it.live("returns previous cached id from common dir", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -131,7 +159,7 @@ describe("ProjectV2.resolve", () => {
const result = yield* project.resolve(abs(tmp.path))
expect(result.previous).toBe(Project.ID.make("old-id"))
expect(result.id).toBe(Project.ID.make("old-id"))
expect(result.id).toBe(remoteID("github.com/owner/repo"))
}),
)
@@ -185,7 +213,7 @@ describe("ProjectV2.resolve", () => {
expect(result.directory).toBe(yield* real(worktree))
expect(result.previous).toBe(Project.ID.make("old-id"))
expect(result.id).toBe(Project.ID.make("old-id"))
expect(result.id).toBe(remoteID("github.com/owner/repo"))
expect(result.vcs?.type).toBe("git")
}),
)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.15.10",
"version": "1.15.11",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.10",
"version": "1.15.11",
"name": "@opencode-ai/effect-drizzle-sqlite",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.15.10",
"version": "1.15.11",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.15.10"
version = "1.15.11"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.15.10",
"version": "1.15.11",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.10",
"version": "1.15.11",
"name": "@opencode-ai/http-recorder",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.10",
"version": "1.15.11",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.10",
"version": "1.15.11",
"name": "opencode",
"type": "module",
"license": "MIT",
+1 -3
View File
@@ -50,7 +50,7 @@ import { FrecencyProvider } from "./component/prompt/frecency"
import { PromptStashProvider } from "./component/prompt/stash"
import { DialogAlert } from "./ui/dialog-alert"
import { DialogConfirm } from "./ui/dialog-confirm"
import { Toast, ToastProvider, useToast } from "./ui/toast"
import { ToastProvider, useToast } from "./ui/toast"
import { createExit, ExitProvider, useExit, type Exit } from "./context/exit"
import { Session as SessionApi } from "@/session/session"
import { TuiEvent } from "./event"
@@ -1072,8 +1072,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
<TuiPluginRuntime.Slot name="app_bottom" />
</box>
<TuiPluginRuntime.Slot name="app" />
{/* One Toast for the whole app, not per-route, so toasts survive route.navigate */}
<Toast />
</Show>
<StartupLoading ready={ready} />
</box>
@@ -512,7 +512,10 @@ export function Prompt(props: PromptProps) {
const content = await Editor.open({
value,
renderer,
cwd: project.instance.path().worktree || project.instance.directory() || process.cwd(),
cwd:
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
project.instance.directory() ||
process.cwd(),
})
if (!content) return
@@ -2,6 +2,7 @@ import { Prompt, type PromptRef } from "@tui/component/prompt"
import { createEffect, createMemo, createSignal, onMount } from "solid-js"
import { Logo } from "../component/logo"
import { useSync } from "../context/sync"
import { Toast } from "../ui/toast"
import { useArgs } from "../context/args"
import { useRouteData } from "@tui/context/route"
import { usePromptRef } from "../context/prompt"
@@ -82,6 +83,7 @@ export function Home() {
</box>
<TuiPluginRuntime.Slot name="home_bottom" />
<box flexGrow={1} minHeight={0} />
<Toast />
</box>
<box width="100%" flexShrink={0}>
<TuiPluginRuntime.Slot name="home_footer" mode="single_winner" />
@@ -5,8 +5,6 @@ import type { TextPart } from "@opencode-ai/sdk/v2"
import { Locale } from "@/util/locale"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { useToast } from "../../ui/toast"
import { Spinner } from "../../component/spinner"
import { useDialog, type DialogContext } from "../../ui/dialog"
import type { PromptInfo } from "@tui/component/prompt/history"
import { strip } from "@tui/component/prompt/part"
@@ -16,37 +14,24 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
const dialog = useDialog()
const sdk = useSDK()
const route = useRoute()
const toast = useToast()
onMount(() => {
dialog.setSize("large")
})
// Forking a large session can take a moment, so swap the dialog to a progress view instead of
// leaving it open (which looks frozen), then navigate to the fork and confirm with a toast.
const fork = async (dialog: DialogContext, messageID?: string, prompt?: PromptInfo) => {
dialog.replace(() => (
<box paddingLeft={2} paddingRight={2} paddingBottom={1}>
<Spinner>Forking session…</Spinner>
</box>
))
const forked = await sdk.client.session.fork({ sessionID: props.sessionID, messageID })
if (!forked.data) {
toast.show({ variant: "error", message: "Failed to fork session" })
dialog.clear()
return
}
route.navigate({ sessionID: forked.data.id, type: "session", prompt })
dialog.clear()
toast.show({ variant: "success", message: "Forked session", duration: 4000 })
}
const options = createMemo((): DialogSelectOption<string | undefined>[] => {
const messages = sync.data.message[props.sessionID] ?? []
const fullSession = {
title: "Full session",
value: undefined,
onSelect: fork,
onSelect: async (dialog: DialogContext) => {
const forked = await sdk.client.session.fork({ sessionID: props.sessionID })
route.navigate({
sessionID: forked.data!.id,
type: "session",
})
dialog.clear()
},
} satisfies DialogSelectOption<string | undefined>
const result = [] as DialogSelectOption<string | undefined>[]
for (const message of messages) {
@@ -59,8 +44,13 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
title: part.text.replace(/\n/g, " "),
value: message.id,
footer: Locale.time(message.time.created),
onSelect: (dialog) => {
const prompt = (sync.data.part[message.id] ?? []).reduce(
onSelect: async (dialog) => {
const forked = await sdk.client.session.fork({
sessionID: props.sessionID,
messageID: message.id,
})
const parts = sync.data.part[message.id] ?? []
const prompt = parts.reduce(
(agg, part) => {
if (part.type === "text") {
if (!part.synthetic) agg.input += part.text
@@ -70,7 +60,12 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
},
{ input: "", parts: [] as PromptInfo["parts"] },
)
return fork(dialog, message.id, prompt)
route.navigate({
sessionID: forked.data!.id,
type: "session",
prompt,
})
dialog.clear()
},
})
}
@@ -67,7 +67,7 @@ import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import parsers from "../../../../../../parsers-config.ts"
import * as Clipboard from "../../util/clipboard"
import { errorMessage } from "@/util/error"
import { useToast } from "../../ui/toast"
import { Toast, useToast } from "../../ui/toast"
import { useKV } from "../../context/kv.tsx"
import * as Editor from "../../util/editor"
import stripAnsi from "strip-ansi"
@@ -968,7 +968,10 @@ export function Session() {
await Editor.open({
value: transcript,
renderer,
cwd: project.instance.path().worktree || project.instance.directory() || process.cwd(),
cwd:
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
project.instance.directory() ||
process.cwd(),
})
} else {
const exportDir = process.cwd()
@@ -981,7 +984,10 @@ export function Session() {
const result = await Editor.open({
value: transcript,
renderer,
cwd: project.instance.path().worktree || project.instance.directory() || process.cwd(),
cwd:
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
project.instance.directory() ||
process.cwd(),
})
if (result !== undefined) {
await Filesystem.write(filepath, result)
@@ -1263,6 +1269,7 @@ export function Session() {
</Show>
</box>
</Show>
<Toast />
</box>
<Show when={sidebarVisible()}>
<Switch>
+9 -2
View File
@@ -90,13 +90,20 @@ export const Info = Schema.Struct({
description: "Enable promptCacheKey for this provider (default false)",
}),
timeout: Schema.optional(
Schema.Union([PositiveInt, Schema.Literal(false)]).annotate({
description: "Timeout in milliseconds for full requests to this provider. Set to false to disable timeout.",
}),
).annotate({
description: "Timeout in milliseconds for full requests to this provider. Set to false to disable timeout.",
}),
headerTimeout: Schema.optional(
Schema.Union([PositiveInt, Schema.Literal(false)]).annotate({
description:
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
"Timeout in milliseconds to wait for response headers. Provider integrations may set defaults. Set to false to disable timeout.",
}),
).annotate({
description:
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
"Timeout in milliseconds to wait for response headers. Provider integrations may set defaults. Set to false to disable timeout.",
}),
chunkTimeout: Schema.optional(PositiveInt).annotate({
description:
+1 -1
View File
@@ -638,7 +638,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
if (input.model.providerID !== "openai") return
output.headers.originator = "opencode"
output.headers["User-Agent"] = `opencode/${InstallationVersion} (${os.platform()} ${os.release()}; ${os.arch()})`
output.headers.session_id = input.sessionID
output.headers["session-id"] = input.sessionID
},
"chat.params": async (input, output) => {
if (input.model.providerID !== "openai") return
+14
View File
@@ -244,6 +244,20 @@ export const layer = Layer.effect(
}).pipe(Effect.ignore)
}
yield* Effect.addFinalizer(() =>
Effect.forEach(
hooks,
(hook) =>
Effect.tryPromise({
try: () => Promise.resolve(hook.dispose?.()),
catch: (error) => {
log.error("plugin dispose hook failed", { error })
},
}).pipe(Effect.ignore),
{ discard: true },
),
)
// Subscribe to bus events, fiber interrupted when scope closes
yield* (yield* bus.subscribeAll()).pipe(
Stream.runForEach((input) =>
+8
View File
@@ -3,6 +3,14 @@ import { STATUS_CODES } from "http"
import { iife } from "@/util/iife"
import type { ProviderID } from "./schema"
export class HeaderTimeoutError extends Error {
public override readonly name = "ProviderHeaderTimeoutError"
constructor(public readonly ms: number) {
super(`Provider response headers timed out after ${ms}ms`)
}
}
// Adapted from overflow detection patterns in:
// https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/utils/overflow.ts
const OVERFLOW_PATTERNS = [
+18 -3
View File
@@ -28,9 +28,10 @@ import * as ProviderTransform from "./transform"
import { ModelID, ProviderID } from "./schema"
import { ModelStatus } from "./model-status"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderError } from "./error"
const log = Log.create({ service: "provider" })
const OPENAI_HEADER_TIMEOUT_DEFAULT = 10_000
function shouldUseCopilotResponsesApi(modelID: string): boolean {
const match = /^gpt-(\d+)/.exec(modelID)
if (!match) return false
@@ -85,6 +86,15 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
})
}
function timeoutController(ms: number) {
const ctl = new AbortController()
const id = setTimeout(() => ctl.abort(new ProviderError.HeaderTimeoutError(ms)), ms)
return {
signal: ctl.signal,
clear: () => clearTimeout(id),
}
}
function googleVertexAnthropicBaseURL(project: string | undefined, location: string | undefined) {
if (!project) return
if (location !== "eu" && location !== "us") return
@@ -194,7 +204,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
return sdk.responses(modelID)
},
options: {},
options: { headerTimeout: OPENAI_HEADER_TIMEOUT_DEFAULT },
}),
xai: () =>
Effect.succeed({
@@ -1601,16 +1611,21 @@ export const layer = Layer.effect(
const customFetch = options["fetch"]
const chunkTimeout = options["chunkTimeout"]
const headerTimeout = options["headerTimeout"]
delete options["chunkTimeout"]
delete options["headerTimeout"]
options["fetch"] = async (input: any, init?: BunFetchRequestInit) => {
const fetchFn = customFetch ?? fetch
const opts = init ?? {}
const chunkAbortCtl = typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined
const headerTimeoutMs = headerTimeout === false ? undefined : headerTimeout
const headerTimeoutCtl = typeof headerTimeoutMs === "number" ? timeoutController(headerTimeoutMs) : undefined
const signals: AbortSignal[] = []
if (opts.signal) signals.push(opts.signal)
if (chunkAbortCtl) signals.push(chunkAbortCtl.signal)
if (headerTimeoutCtl) signals.push(headerTimeoutCtl.signal)
if (options["timeout"] !== undefined && options["timeout"] !== null && options["timeout"] !== false)
signals.push(AbortSignal.timeout(options["timeout"]))
@@ -1639,7 +1654,7 @@ export const layer = Layer.effect(
...opts,
// @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682
timeout: false,
})
}).finally(() => headerTimeoutCtl?.clear())
if (!chunkAbortCtl) return res
return wrapSSE(res, chunkTimeout, chunkAbortCtl)
@@ -14,18 +14,20 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
For SSE endpoints, stay in `HttpApiBuilder.group(...)` and return `HttpServerResponse.stream(...)` from the handler. Annotate the endpoint success schema with `HttpApiSchema.asText({ contentType: "text/event-stream" })` so OpenAPI documents the stream content type.
Use raw `HttpRouter.use(...)` only for routes that do not fit the request/response HttpApi model, such as WebSocket upgrade routes or catch-all fallback routes. Yield stable services at route-layer construction and close over them in `router.add(...)` callbacks.
Use `HttpApiBuilder.group(...)` with `handleRaw(...)` for declared endpoints that need the raw request or response, including WebSocket upgrade routes. This keeps endpoint middleware, routing context, and OpenAPI metadata on one typed route tree.
```ts
export const rawRoute = HttpRouter.use((router) =>
export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-connect", (handlers) =>
Effect.gen(function* () {
const pty = yield* Pty.Service
yield* router.add("GET", PtyPaths.connect, (request) => connectPty(request, pty))
return handlers.handleRaw("connect", (ctx) => connectPty(ctx.request, pty))
}),
)
```
Use raw `HttpRouter.use(...)` only for routes outside the declared API surface, such as a catch-all UI fallback.
Avoid `Effect.provide(SomeLayer)` inside request handlers or raw route callbacks. Stable layers should be provided once at the application/layer boundary, not rebuilt or scoped per request.
Avoid `HttpRouter.provideRequest(...)` unless the dependency is intentionally request-level. Prefer `HttpRouter.use(...)` for stable app services.
@@ -34,4 +36,4 @@ Use `Effect.provideService(...)` in middleware only for request-derived context,
Public JSON errors should be explicit `Schema.ErrorClass` contracts declared on each endpoint. Use built-in `HttpApiError.*` classes only when their empty/tagged body is the intended wire shape; for SDK-visible errors with messages, define an API error schema such as `ApiNotFoundError` and fail with that exact declared error. Keep domain and storage services free of HttpApi types, and translate expected domain errors at the handler boundary.
When adding middleware, compose it at the layer boundary and keep the route tree explicit in `server.ts`. Shared router middleware such as auth, workspace routing, and instance context should stay visible where routes are assembled.
When adding middleware, declare endpoint-contract middleware on the owning `HttpApiGroup` and provide its implementation layer at the assembly boundary in `server.ts`. Keep router middleware for truly raw fallback routes or global transport policy.
@@ -1,6 +1,8 @@
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
export const EventPaths = {
event: "/event",
@@ -20,5 +22,8 @@ export const EventApi = HttpApi.make("event").add(
}),
),
)
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware)
.middleware(Authorization)
.annotateMerge(OpenApi.annotations({ title: "event", description: "Instance event stream route." })),
)
@@ -1,9 +1,10 @@
import { Pty } from "@/pty"
import { PtyTicket } from "@/pty/ticket"
import { PtyID } from "@/pty/schema"
import { PTY_CONNECT_TICKET_QUERY } from "@/server/shared/pty-ticket"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { Authorization, PtyConnectAuthorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import {
WorkspaceRoutingMiddleware,
@@ -138,9 +139,10 @@ export const PtyApi = HttpApi.make("pty")
export const PtyConnectApi = HttpApi.make("pty-connect").add(
HttpApiGroup.make("pty-connect")
.add(
// Decode PTY connection query fields in the raw handler after checking
// existence, preserving the established empty-404 response ordering.
HttpApiEndpoint.get("connect", PtyPaths.connect, {
params: Params,
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Connected session"),
error: [HttpApiError.Forbidden, HttpApiError.NotFound],
}).annotateMerge(
@@ -149,8 +151,22 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add(
summary: "Connect to PTY session",
description:
"Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.",
transform: (operation) => ({
...operation,
parameters: [
...(operation.parameters ?? []),
...["directory", "workspace", "cursor", PTY_CONNECT_TICKET_QUERY].map((name) => ({
in: "query",
name,
schema: { type: "string" },
})),
],
}),
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." })),
.annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." }))
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware)
.middleware(PtyConnectAuthorization),
)
@@ -10,13 +10,13 @@ import {
PTY_CONNECT_TOKEN_HEADER,
PTY_CONNECT_TOKEN_HEADER_VALUE,
} from "@/server/shared/pty-ticket"
import { Effect } from "effect"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { Effect, Option, Schema } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket"
import { InstanceHttpApi } from "../api"
import * as ApiError from "../errors"
import { CursorQuery, Params, PtyPaths } from "../groups/pty"
import { CursorQuery, PtyConnectApi } from "../groups/pty"
import { WebSocketTracker } from "../websocket-tracker"
function validOrigin(request: HttpServerRequest.HttpServerRequest, opts: CorsOptions | undefined) {
@@ -121,37 +121,39 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
}),
)
export const ptyConnectRoute = HttpRouter.use((router) =>
export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-connect", (handlers) =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const tickets = yield* PtyTicket.Service
const cors = yield* CorsConfig
yield* router.add(
"GET",
PtyPaths.connect,
Effect.gen(function* () {
const params = yield* HttpRouter.schemaPathParams(Params)
const exists = yield* pty.get(params.ptyID).pipe(
return handlers.handleRaw(
"connect",
Effect.fn("PtyHttpApi.connect")(function* (ctx: {
params: { ptyID: PtyID }
request: HttpServerRequest.HttpServerRequest
}) {
const exists = yield* pty.get(ctx.params.ptyID).pipe(
Effect.as(true),
Effect.catchTag("Pty.NotFoundError", () => Effect.succeed(false)),
)
if (!exists) return HttpServerResponse.empty({ status: 404 })
const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery)
const request = yield* HttpServerRequest.HttpServerRequest
const ticket = new URL(request.url, "http://localhost").searchParams.get(PTY_CONNECT_TICKET_QUERY)
const query = Schema.decodeUnknownOption(CursorQuery)(yield* HttpServerRequest.ParsedSearchParams)
if (Option.isNone(query)) return HttpServerResponse.empty({ status: 400 })
const ticket = new URL(ctx.request.url, "http://localhost").searchParams.get(PTY_CONNECT_TICKET_QUERY)
if (ticket) {
const valid = validOrigin(request, cors)
? yield* tickets.consume({ ticket, ptyID: params.ptyID, ...(yield* PtyTicket.scope) })
const valid = validOrigin(ctx.request, cors)
? yield* tickets.consume({ ticket, ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) })
: false
if (!valid) return HttpServerResponse.empty({ status: 403 })
}
const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor)
const parsedCursor = query.value.cursor === undefined ? undefined : Number(query.value.cursor)
const cursor =
parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1
? parsedCursor
: undefined
const socket = yield* Effect.orDie(request.upgrade)
const socket = yield* Effect.orDie(ctx.request.upgrade)
const write = yield* socket.writer
const closeAccepted = (event: Socket.CloseEvent) =>
socket
@@ -186,7 +188,7 @@ export const ptyConnectRoute = HttpRouter.use((router) =>
},
}
const handler = yield* pty
.connect(params.ptyID, adapter, cursor)
.connect(ctx.params.ptyID, adapter, cursor)
.pipe(
Effect.catchTag("Pty.NotFoundError", () =>
closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)),
@@ -194,12 +196,8 @@ export const ptyConnectRoute = HttpRouter.use((router) =>
)
if (!handler) return HttpServerResponse.empty()
// No `pending[]`-style early-frame buffer (the legacy handler had one).
// `request.upgrade` returns a Socket without running the WS handshake; the
// handshake fires inside `socket.runRaw` below, AFTER `pty.connect` resolves
// and the message callback is registered. The client therefore can't fire
// `open` and start sending until the listener is already wired. Don't move
// `runRaw` ahead of `pty.connect` without re-introducing a buffer.
// The handshake runs inside `socket.runRaw`, after the input callback is
// registered, so the client cannot send frames before PTY input is wired.
yield* socket
.runRaw((message) => handlePtyInput(handler, message))
.pipe(
@@ -27,6 +27,13 @@ export class V2Authorization extends HttpApiMiddleware.Service<V2Authorization>(
},
) {}
export class PtyConnectAuthorization extends HttpApiMiddleware.Service<PtyConnectAuthorization>()(
"@opencode/ExperimentalHttpApiPtyConnectAuthorization",
{
error: HttpApiError.UnauthorizedNoContent,
},
) {}
function emptyCredential() {
return {
username: "",
@@ -105,7 +112,6 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()(
const request = yield* HttpServerRequest.HttpServerRequest
const url = new URL(request.url, "http://localhost")
if (isPublicUIPath(request.method, url.pathname)) return yield* effect
if (hasPtyConnectTicketURL(url)) return yield* effect
return yield* credentialFromURL(url, request).pipe(
Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
)
@@ -129,6 +135,24 @@ export const authorizationLayer = Layer.effect(
}),
)
export const ptyConnectAuthorizationLayer = Layer.effect(
PtyConnectAuthorization,
Effect.gen(function* () {
const config = yield* ServerAuth.Config
if (!ServerAuth.required(config)) return PtyConnectAuthorization.of((effect) => effect)
return PtyConnectAuthorization.of((effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const url = new URL(request.url, "http://localhost")
if (hasPtyConnectTicketURL(url)) return yield* effect
return yield* credentialFromURL(url, request).pipe(
Effect.flatMap((credential) => validateCredential(effect, credential, config)),
)
}),
)
}),
)
export const v2AuthorizationLayer = Layer.effect(
V2Authorization,
Effect.gen(function* () {
@@ -1,7 +1,7 @@
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { InstanceStore } from "@/project/instance-store"
import { Effect, Layer } from "effect"
import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
import { HttpServerResponse } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import { WorkspaceRouteContext } from "./workspace-routing"
@@ -41,10 +41,3 @@ export const instanceContextLayer = Layer.effect(
return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store))
}),
)
export const instanceRouterMiddleware = HttpRouter.middleware()(
Effect.gen(function* () {
const store = yield* InstanceStore.Service
return (effect) => provideInstanceContext(effect, store)
}),
)
@@ -9,7 +9,7 @@ import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL }
import { NotFoundError } from "@/storage/storage"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Context, Data, Effect, Layer, Option, Schema } from "effect"
import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpClient, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket"
import { InvalidRequestError } from "../errors"
@@ -219,7 +219,10 @@ function routeHttpApiWorkspace<E>(
const sessionID = getWorkspaceRouteSessionID(requestURL(request))
const session = sessionID
? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe(
Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed(undefined)),
Effect.catchIf(
(error): error is NotFoundError => NotFoundError.isInstance(error),
() => Effect.succeed(undefined),
),
Effect.catchDefect(() => Effect.succeed(undefined)),
)
: undefined
@@ -242,20 +245,3 @@ export const workspaceRoutingLayer = Layer.effect(
)
}),
)
export const workspaceRouterMiddleware = HttpRouter.middleware<{ provides: WorkspaceRouteContext }>()(
Effect.gen(function* () {
const makeWebSocket = yield* Socket.WebSocketConstructor
const workspace = yield* Workspace.Service
const client = yield* HttpClient.HttpClient
return (effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const plan = yield* planRequest(request)
return yield* routeWorkspace(client, effect, plan)
}).pipe(
Effect.provideService(Socket.WebSocketConstructor, makeWebSocket),
Effect.provideService(Workspace.Service, workspace),
)
}),
)
@@ -59,8 +59,14 @@ import { serveUIEffect } from "@/server/shared/ui"
import { ServerAuth } from "@/server/auth"
import { InstanceHttpApi, RootHttpApi } from "./api"
import { PublicApi } from "./public"
import { authorizationLayer, authorizationRouterMiddleware, v2AuthorizationLayer } from "./middleware/authorization"
import {
authorizationLayer,
authorizationRouterMiddleware,
ptyConnectAuthorizationLayer,
v2AuthorizationLayer,
} from "./middleware/authorization"
import { EventApi } from "./groups/event"
import { PtyConnectApi } from "./groups/pty"
import { eventHandlers } from "./handlers/event"
import { configHandlers } from "./handlers/config"
import { controlHandlers } from "./handlers/control"
@@ -72,15 +78,15 @@ import { mcpHandlers } from "./handlers/mcp"
import { permissionHandlers } from "./handlers/permission"
import { projectHandlers } from "./handlers/project"
import { providerHandlers } from "./handlers/provider"
import { ptyConnectRoute, ptyHandlers } from "./handlers/pty"
import { ptyConnectHandlers, ptyHandlers } from "./handlers/pty"
import { questionHandlers } from "./handlers/question"
import { sessionHandlers } from "./handlers/session"
import { syncHandlers } from "./handlers/sync"
import { tuiHandlers } from "./handlers/tui"
import { v2Handlers } from "./handlers/v2"
import { workspaceHandlers } from "./handlers/workspace"
import { instanceContextLayer, instanceRouterMiddleware } from "./middleware/instance-context"
import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/workspace-routing"
import { instanceContextLayer } from "./middleware/instance-context"
import { workspaceRoutingLayer } from "./middleware/workspace-routing"
import { disposeMiddleware } from "./lifecycle"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
import { compressionLayer } from "./middleware/compression"
@@ -102,24 +108,27 @@ const cors = (corsOptions?: CorsOptions) =>
// Route tree:
// - rootApiRoutes: typed /global/* and control routes; auth is declared by RootHttpApi.
// - eventApiRoutes/rawInstanceRoutes: raw instance routes; auth and workspace routing happen as router middleware.
// - instanceApiRoutes: schema routes; auth is declared on each group and workspace context is provided below.
// - eventApiRoutes: typed SSE route with instance routing context and its existing API contract.
// - ptyConnectApiRoutes: typed WebSocket upgrade route with ticket-aware auth.
// - instanceApiRoutes: remaining typed instance routes.
// - uiRoute: raw catch-all fallback; auth is router middleware so public static assets can bypass it.
const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
const ptyConnectHttpApiAuthLayer = ptyConnectAuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
const v2HttpApiAuthLayer = v2AuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
const workspaceRoutingLive = workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(
Layer.provide([controlHandlers, globalHandlers]),
Layer.provide(schemaErrorLayer),
Layer.provide(httpApiAuthLayer),
)
const instanceRouterLayer = authorizationRouterMiddleware
.combine(instanceRouterMiddleware)
.combine(workspaceRouterMiddleware)
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal), Layer.provide(ServerAuth.Config.defaultLayer))
const eventApiRoutes = HttpApiBuilder.layer(EventApi).pipe(
Layer.provide(eventHandlers),
Layer.provide(instanceRouterLayer),
Layer.provide([httpApiAuthLayer, workspaceRoutingLive, instanceContextLayer]),
)
const ptyConnectApiRoutes = HttpApiBuilder.layer(PtyConnectApi).pipe(
Layer.provide(ptyConnectHandlers),
Layer.provide([ptyConnectHttpApiAuthLayer, workspaceRoutingLive, instanceContextLayer]),
)
const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
Layer.provide([
@@ -141,15 +150,8 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
]),
)
const rawInstanceRoutes = Layer.mergeAll(ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer))
const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe(
Layer.provide([
httpApiAuthLayer,
v2HttpApiAuthLayer,
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
instanceContextLayer,
schemaErrorLayer,
]),
const instanceRoutes = instanceApiRoutes.pipe(
Layer.provide([httpApiAuthLayer, v2HttpApiAuthLayer, workspaceRoutingLive, instanceContextLayer, schemaErrorLayer]),
)
// `OpenApi.fromApi` is non-trivial; defer until /doc is actually hit so
@@ -184,7 +186,7 @@ type RouteRequirements =
export function createRoutes(
corsOptions?: CorsOptions,
): Layer.Layer<never, EffectConfig.ConfigError, RouteRequirements> {
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, docRoute, uiRoute).pipe(
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, ptyConnectApiRoutes, instanceRoutes, docRoute, uiRoute).pipe(
Layer.provide([
errorLayer,
compressionLayer,
@@ -1143,6 +1143,18 @@ export function fromError(
},
{ cause: e },
).toObject()
case e instanceof ProviderError.HeaderTimeoutError:
return new APIError(
{
message: e.message,
isRetryable: true,
metadata: {
code: e.name,
timeoutMs: String(e.ms),
},
},
{ cause: e },
).toObject()
case APICallError.isInstance(e):
const parsed = ProviderError.parseAPICallError({
providerID: ctx.providerID,
+81 -5
View File
@@ -7,6 +7,15 @@ import path from "path"
import { tmpdirScoped } from "../fixture/fixture"
import { GlobalBus } from "../../src/bus/global"
import { ProjectID } from "../../src/project/schema"
import { Database } from "@/storage/db"
import { ProjectTable } from "@/project/project.sql"
import { SessionTable } from "@/session/session.sql"
import { PermissionTable } from "@/session/session.sql"
import { WorkspaceTable } from "@/control-plane/workspace.sql"
import { eq } from "drizzle-orm"
import { Hash } from "@opencode-ai/core/util/hash"
import { SessionID } from "@/session/schema"
import { WorkspaceID } from "@/control-plane/schema"
import { Cause, Effect, Exit, Layer, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodePath } from "@effect/platform-node"
@@ -31,6 +40,10 @@ function run<A, E>(fn: (svc: Project.Interface) => Effect.Effect<A, E>) {
})
}
function remoteProjectID(remote: string) {
return ProjectID.make(Hash.fast(`git-remote:${remote}`))
}
/**
* Creates a mock ChildProcessSpawner layer that intercepts git subcommands
* matching `failArg` and returns exit code 128, while delegating everything
@@ -154,28 +167,91 @@ describe("Project.fromDirectory", () => {
}),
)
it.live("keeps root commit identity when origin exists", () =>
it.live("prefers normalized origin remote over root commit", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
yield* Effect.promise(() => $`git remote add origin git@github.com:Test-Org/Test-Repo.git`.cwd(tmp).quiet())
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const root = (yield* Effect.promise(() => $`git rev-list --max-parents=0 HEAD`.cwd(tmp).text())).trim()
expect(project.id).toBe(ProjectID.make(root))
expect(project.id).toBe(remoteProjectID("github.com/Test-Org/Test-Repo"))
}),
)
it.live("keeps cached project identity when origin becomes available", () =>
it.live("normalizes equivalent origin URL forms to the same project ID", () =>
Effect.gen(function* () {
const ssh = yield* tmpdirScoped({ git: true })
const https = yield* tmpdirScoped({ git: true })
yield* Effect.promise(() => $`git remote add origin git@github.com:owner/repo.git`.cwd(ssh).quiet())
yield* Effect.promise(() => $`git remote add origin https://github.com/owner/repo.git`.cwd(https).quiet())
const { project: a } = yield* run((svc) => svc.fromDirectory(ssh))
const { project: b } = yield* run((svc) => svc.fromDirectory(https))
expect(a.id).toBe(remoteProjectID("github.com/owner/repo"))
expect(b.id).toBe(a.id)
}),
)
it.live("migrates cached root project data when origin becomes available", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const projects = yield* Project.Service
const { project: rootProject } = yield* projects.fromDirectory(tmp)
const remoteID = remoteProjectID("github.com/acme/app")
const sessionID = crypto.randomUUID() as SessionID
const workspaceID = WorkspaceID.ascending()
yield* Effect.sync(() => {
Database.use((db) => {
db.insert(SessionTable)
.values({
id: sessionID,
project_id: rootProject.id,
slug: sessionID,
directory: tmp,
title: "test",
version: "0.0.0-test",
time_created: Date.now(),
time_updated: Date.now(),
})
.run()
db.insert(PermissionTable)
.values({
project_id: rootProject.id,
data: [{ permission: "edit", pattern: "*", action: "allow" }],
time_created: Date.now(),
time_updated: Date.now(),
})
.run()
db.insert(WorkspaceTable)
.values({
id: workspaceID,
type: "local",
name: "test",
project_id: rootProject.id,
})
.run()
})
})
yield* Effect.promise(() => $`git remote add origin git@github.com:acme/app.git`.cwd(tmp).quiet())
const { project } = yield* projects.fromDirectory(tmp)
expect(project.id).toBe(rootProject.id)
expect(project.id).toBe(remoteID)
expect(
Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, rootProject.id)).get()),
).toBeUndefined()
expect(
Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get())?.project_id,
).toBe(remoteID)
expect(
Database.use((db) => db.select().from(PermissionTable).where(eq(PermissionTable.project_id, remoteID)).get()),
).toBeDefined()
expect(
Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get())
?.project_id,
).toBe(remoteID)
}),
)
})
@@ -0,0 +1,198 @@
import { afterEach, expect } from "bun:test"
import { createServer, type Server } from "node:http"
import { streamText } from "ai"
import { Effect, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { disposeAllInstances, provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { reply, TestLLMServer } from "../lib/llm-server"
import { testProviderConfig } from "../lib/test-provider"
import { Env } from "@/env"
import { Plugin } from "@/plugin"
import { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"
afterEach(async () => {
await disposeAllInstances()
})
const it = testEffect(
Layer.mergeAll(
Provider.defaultLayer,
Env.defaultLayer,
Plugin.defaultLayer,
TestLLMServer.layer,
CrossSpawnSpawner.defaultLayer,
),
)
it.live("headerTimeout does not abort delayed SSE body after headers arrive", () =>
provideTmpdirServer(
({ llm }) =>
Effect.gen(function* () {
yield* llm.push(reply().wait(Bun.sleep(250)).text("late").stop())
const provider = yield* Provider.Service
const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model"))
const result = streamText({
model: yield* provider.getLanguage(model),
messages: [{ role: "user", content: "hello" }],
})
expect(yield* Effect.promise(() => result.text)).toBe("late")
}),
{
config: (url) => {
const config = testProviderConfig(url)
return {
...config,
provider: {
test: {
...config.provider.test,
options: { ...config.provider.test.options, headerTimeout: 50 },
},
},
}
},
},
),
)
it.live("headerTimeout aborts when response headers do not arrive", () =>
Effect.gen(function* () {
const server = yield* Effect.acquireRelease(
Effect.promise(() => delayedHeaderServer(250)),
(server) => Effect.sync(() => server.server.close()),
)
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const provider = yield* Provider.Service
const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model"))
const result = streamText({
model: yield* provider.getLanguage(model),
onError() {},
messages: [{ role: "user", content: "hello" }],
})
const errors = yield* Effect.promise(async () => {
const errors: string[] = []
for await (const part of result.fullStream) {
if (part.type === "error") errors.push(String(part.error))
}
return errors
})
expect(errors.join("\n")).toContain("response headers timed out")
}),
{ config: providerConfig(server.url, { headerTimeout: 50 }) },
)
}),
)
it.live("headerTimeout is opt-in for non-OpenAI providers", () =>
Effect.gen(function* () {
const server = yield* Effect.acquireRelease(
Effect.promise(() => delayedHeaderServer(100)),
(server) => Effect.sync(() => server.server.close()),
)
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const provider = yield* Provider.Service
const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model"))
const result = streamText({
model: yield* provider.getLanguage(model),
messages: [{ role: "user", content: "hello" }],
})
expect(yield* Effect.promise(() => result.text)).toBe("ok")
}),
{ config: providerConfig(server.url) },
)
}),
)
it.live("OpenAI Codex headerTimeout default can be disabled by config", () =>
Effect.gen(function* () {
yield* withAuthContent(
Effect.gen(function* () {
yield* provideTmpdirInstance(
() =>
Effect.gen(function* () {
const provider = yield* Provider.Service
const openai = yield* provider.getProvider(ProviderID.openai)
expect(openai.options.headerTimeout).toBe(false)
}),
{ config: { provider: { openai: { options: { headerTimeout: false } } } } },
)
}),
)
}),
)
it.live("OpenAI API auth gets default headerTimeout", () =>
Effect.gen(function* () {
yield* withAuthContent(
Effect.gen(function* () {
yield* provideTmpdirInstance(() =>
Effect.gen(function* () {
const provider = yield* Provider.Service
const openai = yield* provider.getProvider(ProviderID.openai)
expect(openai.options.headerTimeout).toBe(10_000)
}),
)
}),
{ openai: { type: "api", key: "sk-test" } },
)
}),
)
function providerConfig(url: string, options: Record<string, unknown> = {}) {
const config = testProviderConfig(url)
return {
...config,
provider: {
test: {
...config.provider.test,
options: { ...config.provider.test.options, ...options },
},
},
}
}
async function delayedHeaderServer(delay: number): Promise<{ server: Server; url: string }> {
const server = createServer((_, res) => {
setTimeout(() => {
res.writeHead(200, { "content-type": "text/event-stream" })
res.end('data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n')
}, delay)
})
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
const address = server.address()
if (!address || typeof address === "string") throw new Error("server did not bind to a TCP port")
return { server, url: `http://127.0.0.1:${address.port}` }
}
function withAuthContent<A, E, R>(self: Effect.Effect<A, E, R>, value: Record<string, unknown> = defaultAuthContent()) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = process.env.OPENCODE_AUTH_CONTENT
process.env.OPENCODE_AUTH_CONTENT = JSON.stringify(value)
return previous
}),
() => self,
(previous) =>
Effect.sync(() => {
if (previous === undefined) delete process.env.OPENCODE_AUTH_CONTENT
else process.env.OPENCODE_AUTH_CONTENT = previous
}),
)
}
function defaultAuthContent() {
return {
openai: { type: "oauth", refresh: "refresh", access: "access", expires: Date.now() + 60_000 },
}
}
@@ -282,9 +282,10 @@ it.instance(
expect(providers[ProviderID.anthropic]).toBeDefined()
// Config options should be merged
expect(providers[ProviderID.anthropic].options.timeout).toBe(60000)
expect(providers[ProviderID.anthropic].options.headerTimeout).toBe(10000)
expect(providers[ProviderID.anthropic].options.chunkTimeout).toBe(15000)
}),
{ config: { provider: { anthropic: { options: { timeout: 60000, chunkTimeout: 15000 } } } } },
{ config: { provider: { anthropic: { options: { timeout: 60000, headerTimeout: 10000, chunkTimeout: 15000 } } } } },
)
it.instance("getModel returns model for valid provider/model", () =>
+2 -2
View File
@@ -4,8 +4,8 @@ Use these patterns for server and HttpApi middleware tests in this directory.
- Prefer focused middleware tests with tiny fake routes over full API route trees when testing routing, context, proxying, or middleware policy.
- Use `testEffect(...)` with `NodeHttpServer.layerTest` for the primary in-test server and make relative `HttpClient` requests against it.
- Use `HttpRouter.add(...)` probe routes that expose the context under test, such as `WorkspaceRouteContext`, `InstanceRef`, or `WorkspaceRef`.
- Compose middleware in the same order as production when testing interactions, for example `instanceRouterMiddleware.combine(workspaceRouterMiddleware)`.
- Use tiny `HttpApiBuilder` probe groups that declare the typed middleware under test and expose context such as `WorkspaceRouteContext`, `InstanceRef`, or `WorkspaceRef`.
- Declare middleware in the same order as production when testing interactions, for example `InstanceContextMiddleware` followed by `WorkspaceRoutingMiddleware`.
- For secondary upstream servers, build Effect `NodeHttpServer.layer(...)` into the current test scope with `Layer.build(...)` so the listener stays alive until the test scope exits.
- Avoid `Bun.serve` when testing Effect HTTP middleware. Keep the test in the Effect HTTP stack unless the production path being tested is Bun-specific.
- For WebSocket paths, use `Socket.makeWebSocket(...)` from the test client and assert protocol forwarding or frame relay when relevant.
@@ -1,7 +1,8 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/unstable/http"
import { Effect, Fiber, Layer, Schema } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket"
import { mkdir } from "node:fs/promises"
import path from "node:path"
@@ -12,9 +13,17 @@ import { Workspace } from "../../src/control-plane/workspace"
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
import { InstanceLayer } from "../../src/project/instance-layer"
import { Project } from "../../src/project/project"
import { Session } from "../../src/session/session"
import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle"
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import {
InstanceContextMiddleware,
instanceContextLayer,
} from "../../src/server/routes/instance/httpapi/middleware/instance-context"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
workspaceRoutingLayer,
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
import { withFixedWorkspaceID } from "../fixture/flag"
@@ -47,9 +56,10 @@ const it = testEffect(
),
)
const instanceContextTestLayer = instanceRouterMiddleware
.combine(workspaceRouterMiddleware)
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
const instanceContextTestLayer = Layer.mergeAll(
instanceContextLayer,
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
)
const localAdapter = (directory: string): WorkspaceAdapter => ({
name: "Local Test",
@@ -80,20 +90,57 @@ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: stri
const probeInstanceContext = Effect.gen(function* () {
const instance = yield* InstanceRef
const workspaceID = yield* WorkspaceRef
return yield* HttpServerResponse.json({
return {
directory: instance?.directory,
worktree: instance?.worktree,
projectID: instance?.project.id,
workspaceID,
})
}
})
const serveProbe = (probePath: HttpRouter.PathInput = "/probe") =>
HttpRouter.add("GET", probePath, probeInstanceContext).pipe(
Layer.provide(instanceContextTestLayer),
HttpRouter.serve,
Layer.build,
)
const ProbeResult = Schema.Struct({
directory: Schema.optional(Schema.String),
worktree: Schema.optional(Schema.String),
projectID: Schema.optional(Schema.String),
workspaceID: Schema.optional(Schema.String),
})
const ProbeApi = HttpApi.make("instance-context-probe").add(
HttpApiGroup.make("probe")
.add(
HttpApiEndpoint.get("get", "/probe", { query: WorkspaceRoutingQuery, success: ProbeResult }),
HttpApiEndpoint.get("session", "/session", { query: WorkspaceRoutingQuery, success: ProbeResult }),
HttpApiEndpoint.post("dispose", "/dispose-probe", {
query: WorkspaceRoutingQuery,
success: Schema.Boolean,
}),
)
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware),
)
const probeHandlers = HttpApiBuilder.group(ProbeApi, "probe", (handlers) =>
handlers
.handle("get", () => probeInstanceContext)
.handle("session", () => probeInstanceContext)
.handle(
"dispose",
Effect.fn("InstanceContextProbe.dispose")(function* () {
const instance = yield* InstanceRef
if (!instance) return false
yield* markInstanceForDisposal(instance)
return true
}),
),
)
const probeRoutes = HttpApiBuilder.layer(ProbeApi).pipe(
Layer.provide(probeHandlers),
Layer.provide(instanceContextTestLayer),
Layer.provide(Layer.mock(Session.Service)({})),
)
const serveProbe = () => probeRoutes.pipe(HttpRouter.serve, Layer.build)
const waitDisposedEvent = waitGlobalBusEvent({
message: "timed out waiting for instance disposal",
@@ -101,19 +148,9 @@ const waitDisposedEvent = waitGlobalBusEvent({
}).pipe(Effect.map((event) => ({ directory: event.directory, workspace: event.workspace })))
const serveDisposeProbe = () =>
HttpRouter.serve(
HttpRouter.add(
"POST",
"/dispose-probe",
Effect.gen(function* () {
const instance = yield* InstanceRef
if (!instance) return HttpServerResponse.empty({ status: 500 })
yield* markInstanceForDisposal(instance)
return yield* HttpServerResponse.json(true)
}),
).pipe(Layer.provide(instanceContextTestLayer)),
{ middleware: disposeMiddleware, disableListenLog: true, disableLogger: true },
).pipe(Layer.build)
HttpRouter.serve(probeRoutes, { middleware: disposeMiddleware, disableListenLog: true, disableLogger: true }).pipe(
Layer.build,
)
describe("HttpApi instance context middleware", () => {
it.live("provides instance context from the routed directory", () =>
@@ -129,6 +166,7 @@ describe("HttpApi instance context middleware", () => {
directory: dir,
worktree: dir,
projectID: project.project.id,
workspaceID: null,
})
}),
)
@@ -156,7 +194,7 @@ describe("HttpApi instance context middleware", () => {
type: "instance-context-workspace-ref",
directory: workspaceDir,
})
yield* serveProbe("/session")
yield* serveProbe()
const response = yield* HttpClientRequest.get(`/session?workspace=${workspace.id}`).pipe(
HttpClientRequest.setHeader("x-opencode-directory", dir),
@@ -269,7 +307,7 @@ describe("HttpApi instance context middleware", () => {
// is true. Combined with the env override, the route must stay Local with
// the configured workspace id (not divert to the requested workspace's
// local directory).
yield* serveProbe("/session")
yield* serveProbe()
const response = yield* HttpClientRequest.get(`/session?workspace=${workspace.id}`).pipe(
HttpClientRequest.setHeader("x-opencode-directory", dir),
@@ -4,6 +4,7 @@ import { HttpRouter } from "effect/unstable/http"
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import { ServerAuth } from "../../src/server/auth"
import { PtyID } from "../../src/pty/schema"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
@@ -35,7 +36,7 @@ function app(input: { password?: string; username?: string }) {
}
function basic(username: string, password: string) {
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
return ServerAuth.header({ username, password }) ?? ""
}
async function cancelBody(response: Response) {
@@ -47,8 +48,8 @@ afterEach(async () => {
await resetDatabase()
})
describe("HttpApi raw route authorization", () => {
test("requires configured auth before opening the raw instance event stream", async () => {
describe("HttpApi instance route authorization", () => {
test("requires configured auth before opening the instance event stream", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const server = app({ password: "secret" })
const headers = { "x-opencode-directory": tmp.path }
@@ -64,7 +65,7 @@ describe("HttpApi raw route authorization", () => {
expect(authed.status).toBe(200)
})
test("requires configured auth before resolving the raw PTY websocket route", async () => {
test("requires configured auth before resolving the PTY websocket route", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const server = app({ password: "secret" })
const route = PtyPaths.connect.replace(":ptyID", PtyID.ascending())
@@ -9,10 +9,11 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Layer, Scope } from "effect"
import { Deferred, Effect, Layer, Schema, Scope } from "effect"
import * as Stream from "effect/Stream"
import { HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
import { mkdir } from "node:fs/promises"
import { registerAdapter } from "../../src/control-plane/adapters"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
@@ -20,8 +21,16 @@ import { Workspace } from "../../src/control-plane/workspace"
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
import { InstanceLayer } from "../../src/project/instance-layer"
import { Project } from "../../src/project/project"
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import { Session } from "../../src/session/session"
import {
InstanceContextMiddleware,
instanceContextLayer,
} from "../../src/server/routes/instance/httpapi/middleware/instance-context"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
workspaceRoutingLayer,
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
import { workspaceLayerWithRuntimeFlags } from "../fixture/workspace"
@@ -52,9 +61,10 @@ const it = testEffect(
),
)
const instanceContextTestLayer = instanceRouterMiddleware
.combine(workspaceRouterMiddleware)
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
const instanceContextTestLayer = Layer.mergeAll(
instanceContextLayer,
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
)
const localAdapter = (directory: string): WorkspaceAdapter => ({
name: "Local Test",
@@ -87,6 +97,46 @@ const captureInstance = Effect.gen(function* () {
return { directory: instance?.directory, workspaceID } satisfies Capture
})
const ProbeApi = HttpApi.make("handler-context-probe").add(
HttpApiGroup.make("probe")
.add(
HttpApiEndpoint.post("fork", "/fork-probe", { query: WorkspaceRoutingQuery, success: Schema.Boolean }),
HttpApiEndpoint.post("streamWithout", "/stream-probe-without", {
query: WorkspaceRoutingQuery,
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "application/json" })),
}),
HttpApiEndpoint.post("streamWith", "/stream-probe-with", {
query: WorkspaceRoutingQuery,
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "application/json" })),
}),
)
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware),
)
const serveProbes = (input: {
fork?: Effect.Effect<boolean, never, Scope.Scope>
streamWithout?: Effect.Effect<HttpServerResponse.HttpServerResponse>
streamWith?: Effect.Effect<HttpServerResponse.HttpServerResponse>
}) =>
HttpApiBuilder.layer(ProbeApi).pipe(
Layer.provide(
HttpApiBuilder.group(ProbeApi, "probe", (handlers) =>
handlers
.handle("fork", () => input.fork ?? Effect.succeed(false))
.handleRaw(
"streamWithout",
() => input.streamWithout ?? Effect.succeed(HttpServerResponse.empty({ status: 404 })),
)
.handleRaw("streamWith", () => input.streamWith ?? Effect.succeed(HttpServerResponse.empty({ status: 404 }))),
),
),
Layer.provide(instanceContextTestLayer),
Layer.provide(Layer.mock(Session.Service)({})),
HttpRouter.serve,
Layer.build,
)
describe("HttpApi handler context inheritance", () => {
// Mirrors handlers/session.ts:281 promptAsync. The forked fiber inherits
// the request's Context — including InstanceRef and WorkspaceRef provided
@@ -96,22 +146,20 @@ describe("HttpApi handler context inheritance", () => {
const { dir, workspace } = yield* setupWorkspace("local-fork")
const capture = yield* Deferred.make<Capture>()
yield* HttpRouter.add(
"POST",
"/fork-probe",
Effect.gen(function* () {
yield* serveProbes({
fork: Effect.gen(function* () {
const scope = yield* Scope.Scope
yield* Effect.gen(function* () {
yield* Deferred.succeed(capture, yield* captureInstance)
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
return HttpServerResponse.empty({ status: 204 })
return true
}),
).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build)
})
const response = yield* HttpClient.post(
`/fork-probe?directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`,
)
expect(response.status).toBe(204)
expect(response.status).toBe(200)
const observed = yield* Deferred.await(capture).pipe(Effect.timeout("2 seconds"))
expect(observed.directory).toBe(dir)
@@ -129,10 +177,8 @@ describe("HttpApi handler context inheritance", () => {
const withoutCapture = yield* Deferred.make<Capture>()
const withCapture = yield* Deferred.make<Capture>()
yield* HttpRouter.add(
"POST",
"/stream-probe-without",
Effect.gen(function* () {
yield* serveProbes({
streamWithout: Effect.gen(function* () {
return HttpServerResponse.stream(
Stream.fromEffect(
Effect.gen(function* () {
@@ -143,12 +189,7 @@ describe("HttpApi handler context inheritance", () => {
{ contentType: "application/json" },
)
}),
).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build)
yield* HttpRouter.add(
"POST",
"/stream-probe-with",
Effect.gen(function* () {
streamWith: Effect.gen(function* () {
const instance = yield* InstanceRef
const workspaceID = yield* WorkspaceRef
return HttpServerResponse.stream(
@@ -161,7 +202,7 @@ describe("HttpApi handler context inheritance", () => {
{ contentType: "application/json" },
)
}),
).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build)
})
const queryString = `directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`
const responseWithout = yield* HttpClient.post(`/stream-probe-without?${queryString}`)
@@ -147,6 +147,14 @@ describe("pty HttpApi bridge", () => {
expect(response.status).toBe(404)
})
test("returns 404 for missing PTY websocket before decoding cursor query", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const response = await app().request(`${PtyPaths.connect.replace(":ptyID", PtyID.ascending())}?cursor=a&cursor=b`, {
headers: { "x-opencode-directory": tmp.path },
})
expect(response.status).toBe(404)
})
test("returns typed not found errors for missing PTY HTTP resources", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-opencode-directory": tmp.path }
@@ -9,6 +9,7 @@ type OpenApiResponse = {
readonly content?: Record<string, { readonly schema?: OpenApiSchema }>
}
type OpenApiOperation = {
readonly parameters?: ReadonlyArray<{ readonly name: string; readonly in: string }>
readonly responses?: Record<string, OpenApiResponse>
readonly security?: unknown
}
@@ -207,6 +208,11 @@ describe("PublicApi OpenAPI v2 errors", () => {
expect(componentName(responseRef(spec.paths["/pty/{ptyID}/connect-token"]?.post?.responses?.["403"]) ?? "")).toBe(
"PtyForbiddenError",
)
expect(
spec.paths["/pty/{ptyID}/connect"]?.get?.parameters
?.filter((parameter) => parameter.in === "query")
.map((parameter) => parameter.name),
).toEqual(["directory", "workspace", "cursor", "ticket"])
})
test("documents project not-found errors", () => {
@@ -1,6 +1,6 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { describe, expect } from "bun:test"
import { Context, Effect, Layer, Queue, Ref } from "effect"
import { Context, Effect, Layer, Queue, Ref, Schema } from "effect"
import {
FetchHttpClient,
HttpClient,
@@ -11,6 +11,7 @@ import {
HttpServerResponse,
} from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
import Http from "node:http"
import { mkdir } from "node:fs/promises"
import path from "node:path"
@@ -20,10 +21,13 @@ import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
import { Project } from "../../src/project/project"
import { Session } from "../../src/session/session"
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
WorkspaceRouteContext,
workspaceRouterMiddleware,
workspaceRoutingLayer,
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import { HEADER as FenceHeader } from "../../src/server/shared/fence"
import { Database } from "../../src/storage/db"
@@ -66,7 +70,7 @@ type TestHandler<E, R> = (
request: HttpServerRequest.HttpServerRequest,
) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>
const workspaceRoutingTestLayer = workspaceRouterMiddleware.layer.pipe(
const workspaceRoutingTestLayer = workspaceRoutingLayer.pipe(
Layer.provide([Socket.layerWebSocketConstructorGlobal, FetchHttpClient.layer]),
)
@@ -203,16 +207,45 @@ const echoWebSocket = (request: HttpServerRequest.HttpServerRequest) =>
return HttpServerResponse.empty()
})
const serveRouteContextProbe = HttpRouter.add(
"GET",
"/probe",
Effect.gen(function* () {
// The fake route exposes the context installed by the middleware, so tests
// can assert routing decisions without pulling in the production API tree.
const route = yield* WorkspaceRouteContext
return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID })
}),
).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build)
const ProbeResult = Schema.Struct({
directory: Schema.String,
workspaceID: Schema.optional(Schema.String),
})
const ProbeApi = HttpApi.make("workspace-routing-probe").add(
HttpApiGroup.make("probe")
.add(
HttpApiEndpoint.get("get", "/probe", { query: WorkspaceRoutingQuery, success: ProbeResult }),
HttpApiEndpoint.patch("patch", "/probe", { query: WorkspaceRoutingQuery, success: Schema.Boolean }),
HttpApiEndpoint.get("session", "/session", { query: WorkspaceRoutingQuery, success: ProbeResult }),
HttpApiEndpoint.get("workspace", WorkspacePaths.list, {
query: WorkspaceRoutingQuery,
success: ProbeResult,
}),
)
.middleware(WorkspaceRoutingMiddleware),
)
const routeContextResponse = Effect.gen(function* () {
const route = yield* WorkspaceRouteContext
return { directory: route.directory, workspaceID: route.workspaceID }
})
const probeHandlers = HttpApiBuilder.group(ProbeApi, "probe", (handlers) =>
handlers
.handle("get", () => routeContextResponse)
.handle("patch", () => Effect.succeed(false))
.handle("session", () => routeContextResponse)
.handle("workspace", () => routeContextResponse),
)
const serveProbe = HttpApiBuilder.layer(ProbeApi).pipe(
Layer.provide(probeHandlers),
Layer.provide(workspaceRoutingTestLayer),
Layer.provide(Layer.mock(Session.Service)({})),
HttpRouter.serve,
Layer.build,
)
describe("HttpApi workspace routing middleware", () => {
it.live("proxies remote workspace HTTP requests through the selected workspace target", () =>
@@ -250,11 +283,7 @@ describe("HttpApi workspace routing middleware", () => {
// The local /probe handler should not run. Selecting a remote workspace
// should make the middleware call HttpApiProxy.http instead.
yield* HttpRouter.add("PATCH", "/probe", HttpServerResponse.text("route called")).pipe(
Layer.provide(workspaceRoutingTestLayer),
HttpRouter.serve,
Layer.build,
)
yield* serveProbe
const response = yield* HttpClientRequest.patch(`/probe?workspace=${workspace.id}&keep=yes`).pipe(
HttpClientRequest.setHeaders({
@@ -325,9 +354,11 @@ describe("HttpApi workspace routing middleware", () => {
startWorkspaceSyncing: () => Effect.die("unused"),
})
yield* HttpRouter.add("PATCH", "/probe", HttpServerResponse.text("route called")).pipe(
yield* HttpApiBuilder.layer(ProbeApi).pipe(
Layer.provide(probeHandlers),
Layer.provide(workspaceRoutingTestLayer),
Layer.provide(Layer.succeed(Workspace.Service, workspace)),
Layer.provide(Layer.mock(Session.Service)({})),
HttpRouter.serve,
Layer.build,
)
@@ -351,11 +382,7 @@ describe("HttpApi workspace routing middleware", () => {
url: "http://127.0.0.1:1/base",
})
yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
Layer.provide(workspaceRoutingTestLayer),
HttpRouter.serve,
Layer.build,
)
yield* serveProbe
const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`)
@@ -378,11 +405,7 @@ describe("HttpApi workspace routing middleware", () => {
// The client connects to the local test server. The middleware should
// detect the WebSocket upgrade and proxy it to the remote /base/probe.
yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
Layer.provide(workspaceRoutingTestLayer),
HttpRouter.serve,
Layer.build,
)
yield* serveProbe
const socket = yield* Socket.makeWebSocket(
`${(yield* serverUrl).replace(/^http/, "ws")}/probe?workspace=${workspace.id}`,
@@ -406,11 +429,7 @@ describe("HttpApi workspace routing middleware", () => {
const workspaceID = WorkspaceID.ascending("wrk_missing")
// If the middleware resolves the workspace first, this handler is never
// reached and the response should be the middleware error response.
yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
Layer.provide(workspaceRoutingTestLayer),
HttpRouter.serve,
Layer.build,
)
yield* serveProbe
const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`)
@@ -433,14 +452,7 @@ describe("HttpApi workspace routing middleware", () => {
// GET /session is a control-plane route: it lists sessions for the main
// process and should not be redirected into the selected workspace target.
yield* HttpRouter.add(
"GET",
"/session",
Effect.gen(function* () {
const route = yield* WorkspaceRouteContext
return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID })
}),
).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build)
yield* serveProbe
const response = yield* HttpClient.get(`/session?workspace=${workspace.id}`)
@@ -463,14 +475,7 @@ describe("HttpApi workspace routing middleware", () => {
// Workspace CRUD/status routes manage the control plane itself. Selecting
// a workspace should preserve the selected id for handlers, but must not
// swap the route context to the workspace target directory.
yield* HttpRouter.add(
"GET",
WorkspacePaths.list,
Effect.gen(function* () {
const route = yield* WorkspaceRouteContext
return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID })
}),
).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build)
yield* serveProbe
const response = yield* HttpClient.get(`${WorkspacePaths.list}?workspace=${workspace.id}`)
@@ -484,7 +489,7 @@ describe("HttpApi workspace routing middleware", () => {
const dir = yield* tmpdirScoped()
const queryDir = path.join(dir, "query-target")
const headerDir = path.join(dir, "header-target")
yield* serveRouteContextProbe
yield* serveProbe
// Without a selected workspace, the middleware falls back to request
// directory hints before using the process cwd.
@@ -495,9 +500,9 @@ describe("HttpApi workspace routing middleware", () => {
)
expect(queryResponse.status).toBe(200)
expect(yield* queryResponse.json).toEqual({ directory: queryDir })
expect(yield* queryResponse.json).toEqual({ directory: queryDir, workspaceID: null })
expect(headerResponse.status).toBe(200)
expect(yield* headerResponse.json).toEqual({ directory: headerDir })
expect(yield* headerResponse.json).toEqual({ directory: headerDir, workspaceID: null })
}),
)
@@ -513,7 +518,7 @@ describe("HttpApi workspace routing middleware", () => {
directory: workspaceDir,
})
yield* serveRouteContextProbe
yield* serveProbe
// /probe is not a control-plane route, so selecting a local workspace
// should swap the route context to the workspace target directory.
@@ -9,6 +9,7 @@ import { WorkspaceID } from "../../src/control-plane/schema"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
import { Session } from "@/session/session"
import * as Log from "@opencode-ai/core/util/log"
import { Server } from "../../src/server/server"
@@ -344,6 +345,7 @@ describe("workspace HttpApi", () => {
proxied.push(request)
const url = new URL(request.url)
if (url.pathname === "/base/global/event") return eventStreamResponse()
if (url.pathname === "/base/event") return eventStreamResponse()
if (url.pathname === "/base/sync/history") return Response.json([])
return new Response(
JSON.stringify({
@@ -413,6 +415,18 @@ describe("workspace HttpApi", () => {
])
expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-directory")
expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-workspace")
const eventURL = new URL(`http://localhost${EventPaths.event}`)
eventURL.searchParams.set("workspace", workspace.id)
const eventResponse = yield* request(eventURL.toString(), dir)
expect(eventResponse.status).toBe(200)
expect(eventResponse.headers.get("content-type")).toContain("text/event-stream")
if (!eventResponse.body) throw new Error("missing proxied event response body")
const eventReader = eventResponse.body.getReader()
const event = yield* Effect.promise(() => eventReader.read())
yield* Effect.promise(() => eventReader.cancel())
expect(new TextDecoder().decode(event.value)).toContain("server.connected")
expect(proxied.some((item) => new URL(item.url).pathname === "/base/event")).toBe(true)
} finally {
void remote.stop(true)
yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
@@ -7,6 +7,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { SessionRetry } from "../../src/session/retry"
import { MessageV2 } from "../../src/session/message-v2"
import { ProviderID } from "../../src/provider/schema"
import { ProviderError } from "../../src/provider/error"
import { SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status"
import { provideTmpdirInstance } from "../fixture/fixture"
@@ -163,6 +164,14 @@ describe("session.retry.retryable", () => {
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg })
})
test("retries transport timeout errors", () => {
const request = MessageV2.fromError(new ProviderError.HeaderTimeoutError(10000), { providerID })
expect(MessageV2.APIError.isInstance(request)).toBe(true)
expect(SessionRetry.retryable(request, retryProvider)).toEqual({
message: "Provider response headers timed out after 10000ms",
})
})
test("does not retry context overflow errors", () => {
const error = new MessageV2.ContextOverflowError({
message: "Input exceeds context window of this model",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.15.10",
"version": "1.15.11",
"type": "module",
"license": "MIT",
"scripts": {
+1
View File
@@ -220,6 +220,7 @@ export type ProviderHook = {
export type AuthOuathResult = AuthOAuthResult
export interface Hooks {
dispose?: () => Promise<void>
event?: (input: { event: Event }) => Promise<void>
config?: (input: Config) => Promise<void>
tool?: {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.15.10",
"version": "1.15.11",
"type": "module",
"license": "MIT",
"scripts": {
+4
View File
@@ -2633,6 +2633,8 @@ export class Pty extends HeyApiClient {
ptyID: string
directory?: string
workspace?: string
cursor?: string
ticket?: string
},
options?: Options<never, ThrowOnError>,
) {
@@ -2644,6 +2646,8 @@ export class Pty extends HeyApiClient {
{ in: "path", key: "ptyID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "query", key: "cursor" },
{ in: "query", key: "ticket" },
],
},
],
+8 -2
View File
@@ -1049,11 +1049,15 @@ export type ProviderConfig = {
enterpriseUrl?: string
setCacheKey?: boolean
/**
* Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.
* Timeout in milliseconds for full requests to this provider. Set to false to disable timeout.
*/
timeout?: number | false
/**
* Timeout in milliseconds to wait for response headers. Provider integrations may set defaults. Set to false to disable timeout.
*/
headerTimeout?: number | false
chunkTimeout?: number
[key: string]: unknown | string | boolean | number | false | number | undefined
[key: string]: unknown | string | boolean | number | false | number | false | number | undefined
}
models?: {
[key: string]: {
@@ -8196,6 +8200,8 @@ export type PtyConnectData = {
query?: {
directory?: string
workspace?: string
cursor?: string
ticket?: string
}
url: "/pty/{ptyID}/connect"
}
+14 -1
View File
@@ -13588,7 +13588,20 @@
"enum": [false]
}
],
"description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout."
"description": "Timeout in milliseconds for full requests to this provider. Set to false to disable timeout."
},
"headerTimeout": {
"anyOf": [
{
"type": "integer",
"exclusiveMinimum": 0
},
{
"type": "boolean",
"enum": [false]
}
],
"description": "Timeout in milliseconds to wait for response headers. Provider integrations may set defaults. Set to false to disable timeout."
},
"chunkTimeout": {
"type": "integer",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.15.10",
"version": "1.15.11",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-app",
"version": "1.14.50",
"version": "1.15.11",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-core",
"version": "1.14.50",
"version": "1.15.11",
"private": true,
"type": "module",
"license": "MIT",
+17 -28
View File
@@ -1,40 +1,29 @@
FROM oven/bun:1.3.14-alpine
FROM oven/bun:1.3.14-alpine AS base
WORKDIR /app
ENV NODE_ENV=production
ENV BUN_RUNTIME_TRANSPILER_CACHE_PATH=0
COPY package.json bun.lock ./
COPY patches ./patches
COPY packages/app/package.json ./packages/app/package.json
COPY packages/console/app/package.json ./packages/console/app/package.json
COPY packages/console/core/package.json ./packages/console/core/package.json
COPY packages/console/function/package.json ./packages/console/function/package.json
COPY packages/console/mail/package.json ./packages/console/mail/package.json
COPY packages/console/resource/package.json ./packages/console/resource/package.json
COPY packages/core/package.json ./packages/core/package.json
COPY packages/desktop/package.json ./packages/desktop/package.json
COPY packages/effect-drizzle-sqlite/package.json ./packages/effect-drizzle-sqlite/package.json
COPY packages/enterprise/package.json ./packages/enterprise/package.json
COPY packages/function/package.json ./packages/function/package.json
COPY packages/http-recorder/package.json ./packages/http-recorder/package.json
COPY packages/llm/package.json ./packages/llm/package.json
COPY packages/opencode/package.json ./packages/opencode/package.json
COPY packages/plugin/package.json ./packages/plugin/package.json
COPY packages/script/package.json ./packages/script/package.json
COPY packages/sdk/js/package.json ./packages/sdk/js/package.json
COPY packages/slack/package.json ./packages/slack/package.json
COPY packages/stats/app/package.json ./packages/stats/app/package.json
COPY packages/stats/core/package.json ./packages/stats/core/package.json
COPY packages/stats/server/package.json ./packages/stats/server/package.json
COPY packages/storybook/package.json ./packages/storybook/package.json
COPY packages/ui/package.json ./packages/ui/package.json
COPY packages/web/package.json ./packages/web/package.json
FROM base AS pruner
COPY . .
RUN bunx turbo@2.8.13 prune @opencode-ai/stats-server --docker --no-update-notifier --no-color
FROM base AS installer
COPY --from=pruner /app/out/json/ ./
# Bun 1.3.x needs the pruned workspace globs and lockfile metadata refreshed before the frozen production install.
RUN bun -e 'const packageJson = await Bun.file("package.json").json(); packageJson.workspaces.packages = Array.from(new Bun.Glob("packages/**/package.json").scanSync(".")).map((file) => file.slice(0, -"/package.json".length)).sort(); await Bun.write("package.json", JSON.stringify(packageJson, null, 2) + "\n")'
RUN rm -f bun.lock && bun install --lockfile-only --ignore-scripts
RUN bun install --frozen-lockfile --production --ignore-scripts
COPY packages ./packages
FROM base AS runner
COPY --from=installer /app/node_modules ./node_modules
COPY --from=pruner /app/out/full/ ./
WORKDIR /app/packages/stats/server
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-server",
"version": "1.14.50",
"version": "1.15.11",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.15.10",
"version": "1.15.11",
"type": "module",
"license": "MIT",
"exports": {
@@ -15,7 +15,7 @@
box-shadow: 0 0 0 0.5px var(--accordion-v2-border);
overflow: hidden;
font-family: var(--font-family-sans), "Inter", system-ui, sans-serif;
font-family: var(--v2-font-family-sans), "Inter", system-ui, sans-serif;
color: var(--accordion-v2-fg);
-webkit-font-smoothing: antialiased;
@@ -32,7 +32,7 @@ export default {
},
}
const frame = { width: "346px", "font-family": "var(--font-family-sans)", "font-size": "13px" } as const
const frame = { width: "346px", "font-family": "var(--v2-font-family-sans)", "font-size": "13px" } as const
export const Basic = {
render: () => (
+1 -1
View File
@@ -14,7 +14,7 @@
height: 28px;
border-radius: var(--avatar-radius);
border: 0.5px solid var(--v2-border-border-base);
font-family: var(--font-family-sans);
font-family: var(--v2-font-family-sans);
font-weight: 530;
font-size: var(--avatar-font-size);
line-height: 1;
+1 -1
View File
@@ -13,7 +13,7 @@
border: 0.5px solid var(--border-border-base);
background: var(--background-bg-layer-02);
font-family: var(--font-family-sans);
font-family: var(--v2-font-family-sans);
font-style: normal;
font-weight: 530;
font-size: 11px;
@@ -21,7 +21,7 @@
gap: 8px;
min-width: 0;
width: 100%;
font-family: var(--font-family-sans), var(--sans), system-ui, sans-serif;
font-family: var(--v2-font-family-sans), var(--sans), system-ui, sans-serif;
font-variant-numeric: tabular-nums;
[data-slot="basic-tool-v2-trigger"] {
+1 -1
View File
@@ -11,7 +11,7 @@
justify-content: center;
gap: 6px;
border-radius: 6px;
font-family: var(--font-family-sans);
font-family: var(--v2-font-family-sans);
font-style: normal;
font-weight: 530;
font-size: 13px;
@@ -10,7 +10,7 @@
[data-slot="checkbox-v2-error"] {
color: var(--state-fg-danger);
font-family: var(--font-family-sans);
font-family: var(--v2-font-family-sans);
font-size: 12px;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-normal);
@@ -90,7 +90,7 @@
display: inline-flex;
user-select: none;
color: inherit;
font-family: var(--font-family-sans);
font-family: var(--v2-font-family-sans);
font-style: normal;
font-weight: 440;
font-variant-numeric: tabular-nums;
@@ -109,7 +109,7 @@
[data-slot="checkbox-v2-description"] {
color: var(--text-text-muted);
font-family: var(--font-family-sans);
font-family: var(--v2-font-family-sans);
font-size: 11px;
font-weight: 440;
line-height: 1;
@@ -49,7 +49,7 @@ export const Controlled = {
label="Controlled checkbox"
description="Toggled from Storybook state."
/>
<div style={{ "font-family": "var(--font-family-sans)", "font-size": "12px", color: "#808080" }}>
<div style={{ "font-family": "var(--v2-font-family-sans)", "font-size": "12px", color: "#808080" }}>
Checked: {String(checked())}
</div>
</div>
+2 -2
View File
@@ -64,7 +64,7 @@
[data-slot="dialog-title"] {
margin: 0;
font-family: "Inter", var(--font-family-sans);
font-family: "Inter", var(--v2-font-family-sans);
font-weight: 530;
font-size: 15px;
line-height: 100%;
@@ -74,7 +74,7 @@
}
[data-slot="dialog-description"] {
font-family: "Inter", var(--font-family-sans);
font-family: "Inter", var(--v2-font-family-sans);
font-weight: 440;
font-size: 13px;
line-height: 100%;
@@ -6,7 +6,7 @@
[data-slot="diff-changes-additions"],
[data-slot="diff-changes-deletions"] {
font-family: var(--font-family-sans);
font-family: var(--v2-font-family-sans);
font-size: 11px;
font-style: normal;
font-weight: 440;
+2 -2
View File
@@ -17,7 +17,7 @@
margin: 0;
padding: 0;
border: 0;
font-family: var(--font-family-sans);
font-family: var(--v2-font-family-sans);
font-style: normal;
font-weight: 530;
font-size: 13px;
@@ -76,7 +76,7 @@
align-self: stretch;
width: 100%;
min-height: 11px;
font-family: var(--font-family-sans);
font-family: var(--v2-font-family-sans);
font-style: normal;
font-weight: 440;
font-size: 11px;
@@ -81,7 +81,7 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-family-sans);
font-family: var(--v2-font-family-sans);
font-style: normal;
font-weight: 440;
font-size: 13px;
@@ -138,7 +138,7 @@
border: 0;
background: transparent;
outline: none;
font-family: var(--font-family-sans);
font-family: var(--v2-font-family-sans);
font-style: normal;
font-weight: 440;
font-size: 13px;
@@ -86,7 +86,7 @@ export const Controlled = {
/>
<div
style={{
"font-family": "var(--font-family-sans)",
"font-family": "var(--v2-font-family-sans)",
"font-size": "12px",
color: "var(--text-text-faint)",
}}
+2 -2
View File
@@ -6,7 +6,7 @@
[data-component="keybind-v2"] {
box-sizing: border-box;
font-family: var(--font-family-sans), var(--sans), system-ui, sans-serif;
font-family: var(--v2-font-family-sans), var(--sans), system-ui, sans-serif;
font-variant-numeric: tabular-nums;
display: inline-flex;
flex-direction: row;
@@ -53,7 +53,7 @@
padding: 0px;
flex: 1 1 auto;
align-self: stretch;
font-family: "Inter", var(--font-family-sans), var(--sans), system-ui, sans-serif;
font-family: "Inter", var(--v2-font-family-sans), var(--sans), system-ui, sans-serif;
font-style: normal;
font-weight: 530;
font-size: 11px;
@@ -6,7 +6,7 @@
[data-component="line-comment-v2"] {
box-sizing: border-box;
font-family: var(--font-family-sans), var(--sans), system-ui, sans-serif;
font-family: var(--v2-font-family-sans), var(--sans), system-ui, sans-serif;
font-variant-numeric: tabular-nums;
min-width: 0;
width: 100%;
+9 -9
View File
@@ -6,12 +6,12 @@
padding: 2px;
min-width: 160px;
background: var(--background-bg-layer-01);
background: var(--v2-background-bg-layer-01);
border-radius: 6px;
box-shadow: var(--elevation-floating);
box-shadow: var(--v2-elevation-floating);
outline: none;
font-family: var(--font-family-sans), "Inter", system-ui, sans-serif;
font-family: var(--v2-font-family-sans), "Inter", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@@ -31,9 +31,9 @@
--menu-v2-fg-subtle: var(--text-text-muted);
--menu-v2-icon: var(--icon-icon-base);
--menu-v2-accent: var(--text-text-accent);
--menu-v2-badge-bg: var(--background-bg-layer-02);
--menu-v2-badge-border: var(--border-border-base);
--menu-v2-hover: var(--overlay-simple-overlay-hover);
--menu-v2-badge-bg: var(--v2-background-bg-layer-02);
--menu-v2-badge-border: var(--v2-border-border-base);
--menu-v2-hover: var(--v2-overlay-simple-overlay-hover);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@@ -52,7 +52,7 @@
cursor: default;
user-select: none;
font-family: var(--font-family-sans), "Inter", system-ui, sans-serif;
font-family: var(--v2-font-family-sans), "Inter", system-ui, sans-serif;
font-variation-settings: "slnt" 0;
font-variant-numeric: tabular-nums;
color: var(--menu-v2-fg);
@@ -153,7 +153,7 @@
height: 1px;
width: calc(100% + 4px);
margin: 2px -2px;
background: var(--border-border-muted);
background: var(--v2-border-border-muted);
border: none;
}
@@ -164,7 +164,7 @@
height: 28px;
padding: 0 12px;
font-family: var(--font-family-sans), "Inter", system-ui, sans-serif;
font-family: var(--v2-font-family-sans), "Inter", system-ui, sans-serif;
font-size: 11px;
font-weight: 530;
line-height: 100%;
@@ -179,7 +179,7 @@ export const Context = {
border: "1px dashed rgba(0, 0, 0, 0.2)",
color: "#5c5c5c",
"font-size": "13px",
"font-family": "var(--font-family-sans)",
"font-family": "var(--v2-font-family-sans)",
"user-select": "none",
}}
>
+5 -5
View File
@@ -9,7 +9,7 @@
align-items: center;
user-select: none;
color: var(--text-text-faint);
font-family: var(--font-family-sans);
font-family: var(--v2-font-family-sans);
font-size: 11px;
font-style: normal;
font-weight: 440;
@@ -20,7 +20,7 @@
[data-slot="radio-v2-description"] {
color: var(--text-text-faint);
font-family: var(--font-family-sans);
font-family: var(--v2-font-family-sans);
font-size: 11px;
font-weight: 440;
line-height: 1.2;
@@ -35,7 +35,7 @@
[data-slot="radio-v2-error"] {
color: var(--state-fg-danger);
font-family: var(--font-family-sans);
font-family: var(--v2-font-family-sans);
font-size: 12px;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-normal);
@@ -167,7 +167,7 @@
display: inline-flex;
user-select: none;
color: inherit;
font-family: var(--font-family-sans);
font-family: var(--v2-font-family-sans);
font-style: normal;
font-weight: 440;
font-variant-numeric: tabular-nums;
@@ -186,7 +186,7 @@
[data-slot="radio-v2-item-description"] {
color: var(--text-text-muted);
font-family: var(--font-family-sans);
font-family: var(--v2-font-family-sans);
font-size: 11px;
font-weight: 440;
line-height: 1;
@@ -50,7 +50,7 @@ export const Controlled = {
<RadioItemV2 value="weekly" label="Weekly" />
<RadioItemV2 value="never" label="Never" />
</RadioGroupV2>
<div style={{ "font-family": "var(--font-family-sans)", "font-size": "12px", color: "#808080" }}>
<div style={{ "font-family": "var(--v2-font-family-sans)", "font-size": "12px", color: "#808080" }}>
Selected: {value()}
</div>
</div>
@@ -34,7 +34,7 @@
background: transparent;
box-shadow: none;
cursor: pointer;
font-family: var(--font-family-sans), var(--sans);
font-family: var(--v2-font-family-sans), var(--sans);
font-style: normal;
font-weight: 440;
font-size: 13px;
@@ -56,7 +56,7 @@ export const Controlled = {
<SegmentedControlItemV2 value="b">Grid</SegmentedControlItemV2>
<SegmentedControlItemV2 value="c">Board</SegmentedControlItemV2>
</SegmentedControlV2>
<div style={{ "font-family": "var(--font-family-sans)", "font-size": "12px", color: "#808080" }}>
<div style={{ "font-family": "var(--v2-font-family-sans)", "font-size": "12px", color: "#808080" }}>
Value: {value()}
</div>
</div>
@@ -74,7 +74,7 @@ export const AllowDeselect = {
<SegmentedControlItemV2 value="b">B</SegmentedControlItemV2>
<SegmentedControlItemV2 value="c">C</SegmentedControlItemV2>
</SegmentedControlV2>
<div style={{ "font-family": "var(--font-family-sans)", "font-size": "12px", color: "#808080" }}>
<div style={{ "font-family": "var(--v2-font-family-sans)", "font-size": "12px", color: "#808080" }}>
Value: {value() === null ? "none" : value()}
</div>
</div>
+1 -1
View File
@@ -110,7 +110,7 @@
background: transparent;
outline: none;
text-align: left;
font-family: var(--font-family-sans);
font-family: var(--v2-font-family-sans);
font-style: normal;
font-weight: 440;
font-size: 13px;

Some files were not shown because too many files have changed in this diff Show More